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:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
test-data/
|
||||||
|
*.db
|
||||||
|
*.db-*
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal 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
47
README.md
Normal 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
118
index.html
Normal 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, 3–32 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/<código></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
170
server.js
Normal 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})`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user