commit 309661c9e4417ebabab19cab5adecb86ad490b38 Author: Fable Date: Sat Sep 12 03:38:39 2026 +0200 URL Shrinker: acortador de URLs con Node 24 + node:sqlite App sin dependencias: UI web, POST /api/shorten (codigo aleatorio o personalizado), redireccion 302 con contador de clics, /api/stats/ y /healthz. SQLite en DATA_DIR (/data) para montar volumen persistente. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..065a59e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +test-data/ +*.db +*.db-* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8d6232d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:24-alpine + +WORKDIR /app +COPY server.js index.html ./ + +ENV NODE_ENV=production \ + PORT=3000 \ + DATA_DIR=/data + +RUN mkdir -p /data && chown node:node /data /app +USER node + +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ + CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1 + +CMD ["node", "server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..41b7f65 --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# URL Shrinker + +Acortador de URLs minimalista. Node.js 24 sin dependencias (`node:http` + `node:sqlite`), +un solo contenedor. + +**Producción:** https://shrink.montlab.dev + +## API + +| Método | Ruta | Qué hace | +|---|---|---| +| `POST` | `/api/shorten` | Body `{"url": "https://…", "code": "opcional"}` → `201 {code, url, short_url}` | +| `GET` | `/` | Redirección 302 al destino (cuenta el clic) | +| `GET` | `/api/stats/` | `{code, url, clicks, created_at, short_url}` | +| `GET` | `/healthz` | `{ok, links, clicks}` | +| `GET` | `/` | UI web | + +Códigos generados: 6 caracteres base58. Códigos personalizados: `[A-Za-z0-9_-]{3,32}` +(`api` y `healthz` están reservados). + +```bash +curl -X POST https://shrink.montlab.dev/api/shorten \ + -H 'Content-Type: application/json' \ + -d '{"url": "https://es.wikipedia.org/wiki/Anexo:Ejemplo"}' +``` + +## Configuración + +| Variable | Por defecto | Descripción | +|---|---|---| +| `PORT` | `3000` | Puerto de escucha | +| `DATA_DIR` | `/data` | Directorio de la base SQLite (montar volumen persistente) | +| `BASE_URL` | `http://localhost:3000` | Base con la que se construyen los enlaces cortos | + +## Desarrollo local + +```bash +DATA_DIR=./data BASE_URL=http://localhost:3000 node server.js +``` + +Requiere Node ≥ 24 (usa `node:sqlite`). + +## Despliegue + +Pipeline estándar de MontLab: push a este repo (Gitea) → webhook → Coolify reconstruye el +Dockerfile y publica en `shrink.montlab.dev` (Caddy + Let's Encrypt automáticos). +La base de datos vive en un volumen persistente montado en `/data`. diff --git a/index.html b/index.html new file mode 100644 index 0000000..eee98b3 --- /dev/null +++ b/index.html @@ -0,0 +1,118 @@ + + + + + +URL Shrinker + + + +
+

URL Shrinker

+

Pega una URL larga, llévate una corta.

+
+ + + +
+
+
montlab.dev · los enlaces cuentan sus clics: /api/stats/<código>
+
+ + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..32b5641 --- /dev/null +++ b/server.js @@ -0,0 +1,170 @@ +import { createServer } from 'node:http'; +import { DatabaseSync } from 'node:sqlite'; +import { randomBytes } from 'node:crypto'; +import { mkdirSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const PORT = Number(process.env.PORT || 3000); +const DATA_DIR = process.env.DATA_DIR || '/data'; +const BASE_URL = (process.env.BASE_URL || `http://localhost:${PORT}`).replace(/\/+$/, ''); +const CODE_LENGTH = 6; +const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'; + +mkdirSync(DATA_DIR, { recursive: true }); +const db = new DatabaseSync(join(DATA_DIR, 'shrinker.db')); +db.exec(` + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS links ( + code TEXT PRIMARY KEY, + url TEXT NOT NULL, + clicks INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); +`); + +const insertLink = db.prepare('INSERT INTO links (code, url) VALUES (?, ?)'); +const getLink = db.prepare('SELECT code, url, clicks, created_at FROM links WHERE code = ?'); +const bumpClicks = db.prepare('UPDATE links SET clicks = clicks + 1 WHERE code = ?'); +const countLinks = db.prepare('SELECT COUNT(*) AS n, COALESCE(SUM(clicks), 0) AS c FROM links'); + +const INDEX_HTML = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'index.html')); + +function randomCode() { + const bytes = randomBytes(CODE_LENGTH); + let code = ''; + for (let i = 0; i < CODE_LENGTH; i++) code += ALPHABET[bytes[i] % ALPHABET.length]; + return code; +} + +function validTarget(raw) { + let url; + try { + url = new URL(raw); + } catch { + return null; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + return url.href; +} + +function sendJson(res, status, body) { + const payload = JSON.stringify(body); + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': Buffer.byteLength(payload), + }); + res.end(payload); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + req.on('data', (chunk) => { + size += chunk.length; + if (size > 16384) { + reject(new Error('body too large')); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); + req.on('error', reject); + }); +} + +const server = createServer(async (req, res) => { + const { pathname } = new URL(req.url, BASE_URL); + + if (req.method === 'GET' && pathname === '/') { + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(INDEX_HTML); + return; + } + + if (req.method === 'GET' && pathname === '/healthz') { + const { n, c } = countLinks.get(); + sendJson(res, 200, { ok: true, links: n, clicks: c }); + return; + } + + if (req.method === 'POST' && pathname === '/api/shorten') { + let body; + try { + body = JSON.parse(await readBody(req) || '{}'); + } catch { + sendJson(res, 400, { error: 'invalid JSON body' }); + return; + } + const url = validTarget(String(body.url ?? '')); + if (!url) { + sendJson(res, 400, { error: 'invalid url — must be absolute http(s)' }); + return; + } + + let code = null; + if (body.code !== undefined && body.code !== null && body.code !== '') { + const custom = String(body.code); + if (!/^[A-Za-z0-9_-]{3,32}$/.test(custom)) { + sendJson(res, 400, { error: 'custom code must be 3-32 chars of [A-Za-z0-9_-]' }); + return; + } + if (custom === 'api' || custom === 'healthz') { + sendJson(res, 400, { error: 'that code is reserved' }); + return; + } + try { + insertLink.run(custom, url); + code = custom; + } catch { + sendJson(res, 409, { error: 'code already taken' }); + return; + } + } else { + for (let attempt = 0; attempt < 5 && code === null; attempt++) { + const candidate = randomCode(); + try { + insertLink.run(candidate, url); + code = candidate; + } catch { + /* collision, retry */ + } + } + if (code === null) { + sendJson(res, 500, { error: 'could not allocate a code, try again' }); + return; + } + } + + sendJson(res, 201, { code, url, short_url: `${BASE_URL}/${code}` }); + return; + } + + if (req.method === 'GET' && pathname.startsWith('/api/stats/')) { + const row = getLink.get(pathname.slice('/api/stats/'.length)); + if (!row) { + sendJson(res, 404, { error: 'not found' }); + return; + } + sendJson(res, 200, { ...row, short_url: `${BASE_URL}/${row.code}` }); + return; + } + + if (req.method === 'GET' && /^\/[A-Za-z0-9_-]{3,32}$/.test(pathname)) { + const row = getLink.get(pathname.slice(1)); + if (row) { + bumpClicks.run(row.code); + res.writeHead(302, { Location: row.url, 'Cache-Control': 'no-store' }); + res.end(); + return; + } + } + + sendJson(res, 404, { error: 'not found' }); +}); + +server.listen(PORT, () => { + console.log(`url-shrinker listening on :${PORT} (base ${BASE_URL}, data ${DATA_DIR})`); +});