Add backend shortlinks for sharing.
Creates /api/share to mint /s/:code redirects, backed by Vercel KV with a local fallback.
This commit is contained in:
Generated
+28
@@ -8,6 +8,7 @@
|
|||||||
"name": "motif-backend",
|
"name": "motif-backend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@vercel/kv": "^3.0.0",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
@@ -616,6 +617,27 @@
|
|||||||
"@types/node": "*"
|
"@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": {
|
"node_modules/accepts": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||||
@@ -1826,6 +1848,12 @@
|
|||||||
"node": ">=14.17"
|
"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": {
|
"node_modules/undici": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.16.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"start": "node dist/server.js"
|
"start": "node dist/server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@vercel/kv": "^3.0.0",
|
||||||
"cheerio": "^1.0.0-rc.12",
|
"cheerio": "^1.0.0-rc.12",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import { kv } from '@vercel/kv';
|
||||||
import { MIDISearchService } from './services/MIDISearchService.js';
|
import { MIDISearchService } from './services/MIDISearchService.js';
|
||||||
import { MIDIFetchService } from './services/MIDIFetchService.js';
|
import { MIDIFetchService } from './services/MIDIFetchService.js';
|
||||||
import { MIDIParseService } from './services/MIDIParseService.js';
|
import { MIDIParseService } from './services/MIDIParseService.js';
|
||||||
@@ -14,6 +16,58 @@ const searchService = new MIDISearchService();
|
|||||||
const fetchService = new MIDIFetchService();
|
const fetchService = new MIDIFetchService();
|
||||||
const parseService = new MIDIParseService();
|
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<string, SharePayload>();
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<SharePayload | null> {
|
||||||
|
const key = `share:${code}`;
|
||||||
|
try {
|
||||||
|
const val = await kv.get<SharePayload>(key);
|
||||||
|
return val ?? null;
|
||||||
|
} catch {
|
||||||
|
return localShareStore.get(code) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Search for MIDI files
|
// Search for MIDI files
|
||||||
app.get('/api/midi/search', async (req, res) => {
|
app.get('/api/midi/search', async (req, res) => {
|
||||||
try {
|
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
|
// Fetch and proxy MIDI file
|
||||||
app.get('/api/midi/fetch', async (req, res) => {
|
app.get('/api/midi/fetch', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+38
-7
@@ -659,7 +659,7 @@
|
|||||||
|
|
||||||
async function copyShareLink() {
|
async function copyShareLink() {
|
||||||
if (!currentMidiUrl) return;
|
if (!currentMidiUrl) return;
|
||||||
const url = buildShareUrl();
|
const url = await buildShortShareUrl();
|
||||||
try {
|
try {
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
@@ -704,14 +704,45 @@
|
|||||||
return `${window.location.origin}/play?u=${encodeURIComponent(currentMidiUrl)}&title=${encodeURIComponent(title)}`;
|
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() {
|
function shareOnX() {
|
||||||
if (!currentMidiUrl) return;
|
if (!currentMidiUrl) return;
|
||||||
const url = buildShareUrl();
|
(async () => {
|
||||||
const niceTitle = cleanTitleForShare(currentTitle);
|
const url = await buildShortShareUrl();
|
||||||
// Keep tweet text clean; URL is passed separately.
|
const niceTitle = cleanTitleForShare(currentTitle);
|
||||||
const text = niceTitle ? `MOTIF chiptune: ${niceTitle}` : 'MOTIF chiptune';
|
// Keep tweet text clean; URL is passed separately.
|
||||||
const intent = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`;
|
const text = niceTitle ? `MOTIF chiptune: ${niceTitle}` : 'MOTIF chiptune';
|
||||||
window.open(intent, '_blank', 'noopener,noreferrer');
|
const intent = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(url)}`;
|
||||||
|
window.open(intent, '_blank', 'noopener,noreferrer');
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatus(msg, isError = false) {
|
function setStatus(msg, isError = false) {
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
"src": "/health",
|
"src": "/health",
|
||||||
"dest": "server/src/server.ts"
|
"dest": "server/src/server.ts"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"src": "/s/(.*)",
|
||||||
|
"dest": "server/src/server.ts"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"src": "/embed",
|
"src": "/embed",
|
||||||
"dest": "/embed.html"
|
"dest": "/embed.html"
|
||||||
|
|||||||
Reference in New Issue
Block a user