diff --git a/server/package-lock.json b/server/package-lock.json index 44b333f..f3eea12 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -8,6 +8,7 @@ "name": "motif-backend", "version": "0.1.0", "dependencies": { + "@vercel/kv": "^3.0.0", "cheerio": "^1.0.0-rc.12", "cors": "^2.8.5", "crypto": "^1.0.1", @@ -616,6 +617,27 @@ "@types/node": "*" } }, + "node_modules/@upstash/redis": { + "version": "1.36.0", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.36.0.tgz", + "integrity": "sha512-9zN2UV9QJGPnXfWU3yZBLVQaqqENDh7g+Y4J2vJuSxBCi9FQ0aUOtaXlzuFhnsiZvCqM+eS27ic+tgmkWUsfOg==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, + "node_modules/@vercel/kv": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@vercel/kv/-/kv-3.0.0.tgz", + "integrity": "sha512-pKT8fRnfyYk2MgvyB6fn6ipJPCdfZwiKDdw7vB+HL50rjboEBHDVBEcnwfkEpVSp2AjNtoaOUH7zG+bVC/rvSg==", + "license": "Apache-2.0", + "dependencies": { + "@upstash/redis": "^1.34.0" + }, + "engines": { + "node": ">=14.6" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -1826,6 +1848,12 @@ "node": ">=14.17" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz", diff --git a/server/package.json b/server/package.json index 5b57b23..ec97b5a 100644 --- a/server/package.json +++ b/server/package.json @@ -10,6 +10,7 @@ "start": "node dist/server.js" }, "dependencies": { + "@vercel/kv": "^3.0.0", "cheerio": "^1.0.0-rc.12", "cors": "^2.8.5", "crypto": "^1.0.1", diff --git a/server/src/server.ts b/server/src/server.ts index 77af493..e7255b1 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,5 +1,7 @@ import express from 'express'; import cors from 'cors'; +import crypto from 'node:crypto'; +import { kv } from '@vercel/kv'; import { MIDISearchService } from './services/MIDISearchService.js'; import { MIDIFetchService } from './services/MIDIFetchService.js'; import { MIDIParseService } from './services/MIDIParseService.js'; @@ -14,6 +16,58 @@ const searchService = new MIDISearchService(); const fetchService = new MIDIFetchService(); const parseService = new MIDIParseService(); +type SharePayload = + | { + kind: 'bitmidi'; + id: string; + title?: string; + createdAt: string; + v: number; + } + | { + kind: 'url'; + u: string; + title?: string; + createdAt: string; + v: number; + }; + +const localShareStore = new Map(); + +function base62(bytes: Uint8Array): string { + const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; + let out = ''; + for (const b of bytes) out += alphabet[b % alphabet.length]; + return out; +} + +function newCode(): string { + // 8 chars base62-ish from random bytes (sufficient for our scale) + return base62(crypto.randomBytes(8)); +} + +async function shareSet(code: string, payload: SharePayload): Promise { + const key = `share:${code}`; + // 30 days TTL + const exSec = 60 * 60 * 24 * 30; + try { + await kv.set(key, payload, { ex: exSec }); + } catch { + // Local dev / KV not configured: best-effort in-memory store. + localShareStore.set(code, payload); + } +} + +async function shareGet(code: string): Promise { + const key = `share:${code}`; + try { + const val = await kv.get(key); + return val ?? null; + } catch { + return localShareStore.get(code) ?? null; + } +} + // Search for MIDI files app.get('/api/midi/search', async (req, res) => { try { @@ -31,6 +85,73 @@ app.get('/api/midi/search', async (req, res) => { } }); +// Create a short share link +app.post('/api/share', async (req, res) => { + try { + const body = (req.body || {}) as any; + const now = new Date().toISOString(); + const title = typeof body.title === 'string' ? body.title.slice(0, 200) : undefined; + + let payload: SharePayload | null = null; + if (body.src === 'bitmidi' && typeof body.id === 'string' && /^\d+$/.test(body.id)) { + payload = { kind: 'bitmidi', id: body.id, title, createdAt: now, v: 1 }; + } else if (typeof body.u === 'string' && body.u.startsWith('http')) { + payload = { kind: 'url', u: body.u, title, createdAt: now, v: 1 }; + } + + if (!payload) { + return res.status(400).json({ error: 'Invalid payload. Expected {src:\"bitmidi\",id:\"123\"} or {u:\"https://...\"}.' }); + } + + // Avoid collisions (extremely unlikely, but cheap to check a few times) + let code = newCode(); + for (let i = 0; i < 3; i++) { + const existing = await shareGet(code); + if (!existing) break; + code = newCode(); + } + + await shareSet(code, payload); + res.json({ + code, + url: `/s/${code}`, + }); + } catch (error) { + console.error('Share create error:', error); + res.status(500).json({ error: 'Share create failed' }); + } +}); + +// Resolve a short share link and redirect to /play +app.get('/s/:code', async (req, res) => { + try { + const code = String(req.params.code || '').trim(); + if (!code) return res.status(400).send('Missing code'); + + const payload = await shareGet(code); + if (!payload) return res.status(404).send('Not found'); + + let dest = '/play'; + if (payload.kind === 'bitmidi') { + const sp = new URLSearchParams(); + sp.set('src', 'bitmidi'); + sp.set('id', payload.id); + if (payload.title) sp.set('title', payload.title); + dest = `/play?${sp.toString()}`; + } else if (payload.kind === 'url') { + const sp = new URLSearchParams(); + sp.set('u', payload.u); + if (payload.title) sp.set('title', payload.title); + dest = `/play?${sp.toString()}`; + } + + res.redirect(302, dest); + } catch (error) { + console.error('Share resolve error:', error); + res.status(500).send('Resolve failed'); + } +}); + // Fetch and proxy MIDI file app.get('/api/midi/fetch', async (req, res) => { try { diff --git a/ux-test.html b/ux-test.html index 3df4089..094d62b 100644 --- a/ux-test.html +++ b/ux-test.html @@ -659,7 +659,7 @@ async function copyShareLink() { if (!currentMidiUrl) return; - const url = buildShareUrl(); + const url = await buildShortShareUrl(); try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(url); @@ -704,14 +704,45 @@ return `${window.location.origin}/play?u=${encodeURIComponent(currentMidiUrl)}&title=${encodeURIComponent(title)}`; } + async function buildShortShareUrl() { + const title = cleanTitleForShare(currentTitle); + + // Prefer creating a backend short link for sharing on X. + try { + let payload = null; + if (currentSource === 'bitmidi') { + const m = String(currentMidiUrl).match(/\/uploads\/(\d+)\.mid/i); + if (m && m[1]) payload = { src: 'bitmidi', id: m[1], title }; + } + if (!payload) { + payload = { u: currentMidiUrl, title }; + } + + const resp = await fetch('/api/share', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) throw new Error(`share:${resp.status}`); + const data = await resp.json(); + if (data && data.url) return `${window.location.origin}${data.url}`; + } catch (e) { + // fall back + } + + return buildShareUrl(); + } + function shareOnX() { if (!currentMidiUrl) return; - const url = buildShareUrl(); - const niceTitle = cleanTitleForShare(currentTitle); - // Keep tweet text clean; URL is passed separately. - const text = niceTitle ? `MOTIF chiptune: ${niceTitle}` : 'MOTIF chiptune'; - const intent = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`; - window.open(intent, '_blank', 'noopener,noreferrer'); + (async () => { + const url = await buildShortShareUrl(); + const niceTitle = cleanTitleForShare(currentTitle); + // Keep tweet text clean; URL is passed separately. + const text = niceTitle ? `MOTIF chiptune: ${niceTitle}` : 'MOTIF chiptune'; + const intent = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`; + window.open(intent, '_blank', 'noopener,noreferrer'); + })(); } function setStatus(msg, isError = false) { diff --git a/vercel.json b/vercel.json index 5e19d49..487fbd1 100644 --- a/vercel.json +++ b/vercel.json @@ -22,6 +22,10 @@ "src": "/health", "dest": "server/src/server.ts" }, + { + "src": "/s/(.*)", + "dest": "server/src/server.ts" + }, { "src": "/embed", "dest": "/embed.html"