-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathserver.js
More file actions
201 lines (174 loc) · 6.42 KB
/
Copy pathserver.js
File metadata and controls
201 lines (174 loc) · 6.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
const express = require('express');
const cors = require('cors');
const path = require('path');
const rateLimit = require('express-rate-limit');
const { pool, initSchema } = require('./db');
const app = express();
const PORT = process.env.PORT || 3000;
// Behind a reverse proxy (Render, Railway, nginx, etc.), the incoming request
// hits the proxy over HTTPS, then the proxy forwards plain HTTP to our app.
// Without this, req.protocol returns 'http' even when the user is on HTTPS,
// and the short URLs we generate get the wrong scheme.
app.set('trust proxy', 1);
// Allow browser-based clients on other origins (e.g. Flutter web during dev)
// to call our API. Native mobile clients don't need CORS, but enabling it
// here means the same API works for any frontend you want to point at it.
app.use(cors());
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
const ALPHABET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
function generateShortCode(length = 6) {
let code = '';
for (let i = 0; i < length; i++) {
code += ALPHABET[Math.floor(Math.random() * ALPHABET.length)];
}
return code;
}
function isValidHttpUrl(str) {
try {
const u = new URL(str);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
// Hosts we refuse to shorten. Two goals:
// 1. Prevent chained shortener abuse (our domain cloaking someone else's).
// 2. Prevent loops (shortening our own URLs, which would redirect forever).
const BLOCKED_HOSTS = new Set([
// Known URL shorteners (keep short; a real service would source a larger list)
'bit.ly', 'tinyurl.com', 't.co', 'goo.gl', 'ow.ly',
'short.io', 'is.gd', 'buff.ly', 'rebrand.ly', 't.ly',
'cutt.ly', 'adf.ly', 'shorturl.at', 'tiny.cc',
// Self: added dynamically below based on request host
]);
function isBlockedUrl(str, selfHost) {
try {
const host = new URL(str).hostname.toLowerCase();
if (BLOCKED_HOSTS.has(host)) return true;
if (selfHost && host === selfHost.toLowerCase().split(':')[0]) return true;
return false;
} catch {
return false;
}
}
// 30 shortenings per 10 minutes per IP. Generous for humans, painful for bots.
// Other endpoints are read-only or cheap, so no limit there.
const shortenLimiter = rateLimit({
windowMs: 10 * 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: {
error: 'Too many requests. Please try again in a few minutes.',
},
});
// ─── Routes ─────────────────────────────────────────────────────────────────
// Note: Postgres uses $1, $2, ... placeholders instead of SQLite's ?
// Every DB call is async, so route handlers are `async` and use `await`.
app.post('/api/shorten', shortenLimiter, async (req, res) => {
const { url } = req.body;
if (!url || !isValidHttpUrl(url)) {
return res.status(400).json({ error: 'Please provide a valid http(s) URL' });
}
if (isBlockedUrl(url, req.get('host'))) {
return res.status(400).json({
error: 'That host is not allowed (another shortener or this service itself).',
});
}
let shortCode;
for (let attempt = 0; attempt < 5; attempt++) {
const candidate = generateShortCode();
const existing = await pool.query(
'SELECT 1 FROM urls WHERE short_code = $1',
[candidate]
);
if (existing.rowCount === 0) {
shortCode = candidate;
break;
}
}
if (!shortCode) {
return res.status(500).json({ error: 'Could not generate a unique short code' });
}
await pool.query(
'INSERT INTO urls (short_code, long_url) VALUES ($1, $2)',
[shortCode, url]
);
res.json({
short_code: shortCode,
short_url: `${req.protocol}://${req.get('host')}/${shortCode}`,
long_url: url,
});
});
app.get('/api/stats/:code', async (req, res) => {
const urlRes = await pool.query(
'SELECT id, short_code, long_url, created_at FROM urls WHERE short_code = $1',
[req.params.code]
);
if (urlRes.rowCount === 0) {
return res.status(404).json({ error: 'Short URL not found' });
}
const url = urlRes.rows[0];
// COUNT(*) returns a bigint in Postgres, which the `pg` driver returns as a
// string to avoid JS number precision loss. For small counts we cast to int
// so the JSON response has a real number, not a string.
const totalRes = await pool.query(
'SELECT COUNT(*)::int AS total FROM clicks WHERE url_id = $1',
[url.id]
);
const byDayRes = await pool.query(
`SELECT DATE(clicked_at)::text AS day, COUNT(*)::int AS count
FROM clicks
WHERE url_id = $1
AND clicked_at >= NOW() - INTERVAL '30 days'
GROUP BY DATE(clicked_at)
ORDER BY day DESC`,
[url.id]
);
const referrersRes = await pool.query(
`SELECT COALESCE(NULLIF(referrer, ''), '(direct)') AS referrer,
COUNT(*)::int AS count
FROM clicks
WHERE url_id = $1
GROUP BY referrer
ORDER BY count DESC
LIMIT 10`,
[url.id]
);
res.json({
short_code: url.short_code,
short_url: `${req.protocol}://${req.get('host')}/${url.short_code}`,
long_url: url.long_url,
created_at: url.created_at,
total_clicks: totalRes.rows[0].total,
clicks_by_day: byDayRes.rows,
top_referrers: referrersRes.rows,
});
});
app.get('/:code', async (req, res) => {
const { rows } = await pool.query(
'SELECT id, long_url FROM urls WHERE short_code = $1',
[req.params.code]
);
if (rows.length === 0) return res.status(404).send('Short URL not found');
const row = rows[0];
// Fire-and-forget the click log so it doesn't delay the redirect. If it
// fails we log to the console but the user still gets their redirect.
pool.query(
'INSERT INTO clicks (url_id, referrer, user_agent) VALUES ($1, $2, $3)',
[row.id, req.get('Referer') || null, req.get('User-Agent') || null]
).catch((err) => console.error('Failed to log click:', err.message));
res.redirect(row.long_url);
});
// ─── Startup ────────────────────────────────────────────────────────────────
async function start() {
await initSchema();
app.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});
}
start().catch((err) => {
console.error('Failed to start:', err);
process.exit(1);
});