Initial template
This commit is contained in:
commit
ec6beec87b
4 changed files with 136 additions and 0 deletions
12
Dockerfile
Normal file
12
Dockerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY server.js ./
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
9
README.md
Normal file
9
README.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# url-shortener
|
||||||
|
|
||||||
|
Express + PostgreSQL link shortener.
|
||||||
|
|
||||||
|
- `POST /api/links` with `{"url": "https://example.com"}` returns a 7-char hash and short URL
|
||||||
|
- `GET /<hash>` 302-redirects to the original URL and counts the hit
|
||||||
|
- `GET /api/links` lists recent links with hit counts
|
||||||
|
|
||||||
|
Reads `DATABASE_URL` and `PORT` from the environment — EZKeel provides both at deploy time.
|
||||||
12
package.json
Normal file
12
package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"name": "url-shortener",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"pg": "^8.11.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
103
server.js
Normal file
103
server.js
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function initDB() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS links (
|
||||||
|
hash TEXT PRIMARY KEY,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
hits INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
console.log('Database table ready');
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHash(url) {
|
||||||
|
return crypto.createHash('sha256').update(url).digest('base64url').slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/links {"url": "https://example.com"} -> {"hash": "...", "short_url": "..."}
|
||||||
|
app.post('/api/links', async (req, res) => {
|
||||||
|
const { url } = req.body;
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return res.status(400).json({ error: '"url" must be a valid URL' });
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return res.status(400).json({ error: 'only http/https URLs allowed' });
|
||||||
|
}
|
||||||
|
const hash = makeHash(parsed.href);
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO links (hash, url) VALUES ($1, $2) ON CONFLICT (hash) DO NOTHING',
|
||||||
|
[hash, parsed.href]
|
||||||
|
);
|
||||||
|
res.status(201).json({
|
||||||
|
hash,
|
||||||
|
short_url: `${req.protocol}://${req.get('host')}/${hash}`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to create link' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/links -> recent links with hit counts
|
||||||
|
app.get('/api/links', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'SELECT hash, url, hits, created_at FROM links ORDER BY created_at DESC LIMIT 50'
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to list links' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /:hash -> 302 redirect
|
||||||
|
app.get('/:hash([A-Za-z0-9_-]{7})', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'UPDATE links SET hits = hits + 1 WHERE hash = $1 RETURNING url',
|
||||||
|
[req.params.hash]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return res.status(404).json({ error: 'Link not found' });
|
||||||
|
res.redirect(302, rows[0].url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to resolve link' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
name: 'url-shortener',
|
||||||
|
usage: {
|
||||||
|
shorten: 'POST /api/links {"url": "https://example.com"}',
|
||||||
|
list: 'GET /api/links',
|
||||||
|
follow: 'GET /<hash>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
initDB().then(() => {
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue