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/<code>
y /healthz. SQLite en DATA_DIR (/data) para montar volumen persistente.
This commit is contained in:
Fable
2026-09-12 03:38:39 +02:00
commit 309661c9e4
5 changed files with 355 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
test-data/
*.db
*.db-*

17
Dockerfile Normal file
View File

@@ -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"]

47
README.md Normal file
View File

@@ -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` | `/<code>` | Redirección 302 al destino (cuenta el clic) |
| `GET` | `/api/stats/<code>` | `{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`.

118
index.html Normal file
View File

@@ -0,0 +1,118 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>URL Shrinker</title>
<style>
:root {
--bg: #0f1117; --card: #181b24; --text: #e8eaf0; --muted: #9aa1b2;
--accent: #5b8def; --accent-hover: #7aa2f2; --error: #e06c75; --ok: #7fbf7f;
--border: #2a2f3d; --radius: 10px;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f4f5f8; --card: #ffffff; --text: #1c1f26; --muted: #5c6270;
--accent: #3b6fd4; --accent-hover: #2a5cbf; --error: #c0392b; --ok: #2e7d32;
--border: #dde0e8;
}
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: var(--bg); color: var(--text);
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; padding: 1.5rem;
}
main { width: 100%; max-width: 34rem; }
h1 { font-size: 1.6rem; margin: 0 0 .25rem; }
h1 span { color: var(--accent); }
p.sub { color: var(--muted); margin: 0 0 1.5rem; }
form {
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 1.25rem; display: grid; gap: .75rem;
}
label { font-size: .85rem; color: var(--muted); display: grid; gap: .3rem; }
input {
width: 100%; padding: .6rem .75rem; border-radius: 6px; border: 1px solid var(--border);
background: var(--bg); color: var(--text); font-size: 1rem;
}
input:focus { outline: 2px solid var(--accent); outline-offset: -1px; border-color: transparent; }
button {
padding: .65rem; border: 0; border-radius: 6px; background: var(--accent); color: #fff;
font-size: 1rem; font-weight: 600; cursor: pointer;
}
button:hover { background: var(--accent-hover); }
button:disabled { opacity: .6; cursor: wait; }
#result {
margin-top: 1rem; padding: 1rem 1.25rem; border-radius: var(--radius); display: none;
background: var(--card); border: 1px solid var(--border); word-break: break-all;
}
#result.ok { display: block; border-color: var(--ok); }
#result.err { display: block; border-color: var(--error); color: var(--error); }
#result a { color: var(--accent); font-weight: 600; text-decoration: none; font-size: 1.1rem; }
#result a:hover { text-decoration: underline; }
#copy {
margin-left: .6rem; padding: .25rem .6rem; font-size: .8rem; font-weight: 500;
background: transparent; color: var(--muted); border: 1px solid var(--border);
}
#copy:hover { color: var(--text); background: transparent; }
footer { margin-top: 1.25rem; text-align: center; color: var(--muted); font-size: .8rem; }
</style>
</head>
<body>
<main>
<h1>URL <span>Shrinker</span></h1>
<p class="sub">Pega una URL larga, llévate una corta.</p>
<form id="form">
<label>URL a acortar
<input id="url" type="url" placeholder="https://ejemplo.com/una/ruta/muy/larga" required autofocus>
</label>
<label>Código personalizado <small>(opcional, 332 caracteres)</small>
<input id="code" type="text" pattern="[A-Za-z0-9_\-]{3,32}" placeholder="mi-enlace">
</label>
<button type="submit" id="submit">Acortar</button>
</form>
<div id="result"></div>
<footer>montlab.dev · los enlaces cuentan sus clics: <code>/api/stats/&lt;código&gt;</code></footer>
</main>
<script>
const form = document.getElementById('form');
const result = document.getElementById('result');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('submit');
btn.disabled = true;
result.className = '';
try {
const body = { url: document.getElementById('url').value };
const code = document.getElementById('code').value.trim();
if (code) body.code = code;
const res = await fetch('/api/shorten', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'error ' + res.status);
result.className = 'ok';
result.innerHTML = '';
const a = document.createElement('a');
a.href = data.short_url; a.textContent = data.short_url; a.target = '_blank'; a.rel = 'noopener';
const copy = document.createElement('button');
copy.id = 'copy'; copy.type = 'button'; copy.textContent = 'Copiar';
copy.onclick = async () => {
await navigator.clipboard.writeText(data.short_url);
copy.textContent = '¡Copiado!';
setTimeout(() => (copy.textContent = 'Copiar'), 1500);
};
result.append(a, copy);
} catch (err) {
result.className = 'err';
result.textContent = err.message;
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>

170
server.js Normal file
View File

@@ -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})`);
});