@hasna/shortlinks 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js DELETED
@@ -1,877 +0,0 @@
1
- // @bun
2
- // src/database.ts
3
- import { Database } from "bun:sqlite";
4
- import { mkdirSync as mkdirSync2 } from "fs";
5
- import { dirname as dirname2 } from "path";
6
-
7
- // src/config.ts
8
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
9
- import { homedir } from "os";
10
- import { dirname, join, resolve } from "path";
11
- var SERVICE_NAME = "shortlinks";
12
- var DEFAULT_DATA_DIR = join(homedir(), ".hasna", SERVICE_NAME);
13
- function getDataDir() {
14
- return resolve(process.env.SHORTLINKS_HOME || DEFAULT_DATA_DIR);
15
- }
16
- function ensureDataDir() {
17
- const dir = getDataDir();
18
- mkdirSync(dir, { recursive: true });
19
- return dir;
20
- }
21
- function getConfigPath() {
22
- return join(ensureDataDir(), "config.json");
23
- }
24
- function getDatabasePath(explicitPath) {
25
- if (explicitPath)
26
- return resolve(explicitPath);
27
- if (process.env.SHORTLINKS_DB)
28
- return resolve(process.env.SHORTLINKS_DB);
29
- return join(ensureDataDir(), `${SERVICE_NAME}.db`);
30
- }
31
- function loadConfig() {
32
- const path = getConfigPath();
33
- if (!existsSync(path))
34
- return {};
35
- try {
36
- const parsed = JSON.parse(readFileSync(path, "utf-8"));
37
- return parsed && typeof parsed === "object" ? parsed : {};
38
- } catch {
39
- return {};
40
- }
41
- }
42
- function saveConfig(config) {
43
- const path = getConfigPath();
44
- mkdirSync(dirname(path), { recursive: true });
45
- writeFileSync(path, `${JSON.stringify(config, null, 2)}
46
- `);
47
- }
48
- function updateConfig(patch) {
49
- const next = {
50
- ...loadConfig(),
51
- ...patch,
52
- cloudflare: {
53
- ...loadConfig().cloudflare,
54
- ...patch.cloudflare
55
- }
56
- };
57
- saveConfig(next);
58
- return next;
59
- }
60
- function normalizeHostname(input) {
61
- const raw = input.trim().toLowerCase();
62
- if (!raw)
63
- throw new Error("Domain is required.");
64
- const withProtocol = raw.includes("://") ? raw : `https://${raw}`;
65
- let hostname;
66
- try {
67
- hostname = new URL(withProtocol).hostname;
68
- } catch {
69
- throw new Error(`Invalid domain: ${input}`);
70
- }
71
- hostname = hostname.replace(/\.$/, "");
72
- if (!/^[a-z0-9.-]+$/.test(hostname) || hostname.includes("..")) {
73
- throw new Error(`Invalid domain: ${input}`);
74
- }
75
- return hostname;
76
- }
77
- function formatShortUrl(hostname, slug, publicBaseUrl) {
78
- if (publicBaseUrl) {
79
- const base = publicBaseUrl.endsWith("/") ? publicBaseUrl : `${publicBaseUrl}/`;
80
- return new URL(slug, base).toString();
81
- }
82
- return `https://${hostname}/${slug}`;
83
- }
84
-
85
- // src/database.ts
86
- function now() {
87
- return new Date().toISOString();
88
- }
89
- function makeId(prefix) {
90
- const bytes = crypto.getRandomValues(new Uint8Array(12));
91
- const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
92
- return `${prefix}_${hex}`;
93
- }
94
- var SQLITE_MIGRATIONS = [
95
- `
96
- CREATE TABLE IF NOT EXISTS domains (
97
- id TEXT PRIMARY KEY,
98
- hostname TEXT NOT NULL UNIQUE,
99
- provider TEXT NOT NULL DEFAULT 'manual',
100
- default_domain INTEGER NOT NULL DEFAULT 0,
101
- cloudflare_zone_id TEXT,
102
- cloudflare_account_id TEXT,
103
- cloudflare_worker_name TEXT,
104
- origin_url TEXT,
105
- notes TEXT,
106
- metadata TEXT NOT NULL DEFAULT '{}',
107
- machine_id TEXT,
108
- synced_at TEXT,
109
- created_at TEXT NOT NULL,
110
- updated_at TEXT NOT NULL
111
- );
112
-
113
- CREATE TABLE IF NOT EXISTS links (
114
- id TEXT PRIMARY KEY,
115
- domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
116
- slug TEXT NOT NULL,
117
- destination_url TEXT NOT NULL,
118
- title TEXT,
119
- active INTEGER NOT NULL DEFAULT 1,
120
- expires_at TEXT,
121
- metadata TEXT NOT NULL DEFAULT '{}',
122
- machine_id TEXT,
123
- synced_at TEXT,
124
- created_at TEXT NOT NULL,
125
- updated_at TEXT NOT NULL,
126
- UNIQUE(domain_id, slug)
127
- );
128
-
129
- CREATE TABLE IF NOT EXISTS clicks (
130
- id TEXT PRIMARY KEY,
131
- link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
132
- domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
133
- slug TEXT NOT NULL,
134
- clicked_at TEXT NOT NULL,
135
- ip_hash TEXT,
136
- user_agent TEXT,
137
- referer TEXT,
138
- country TEXT,
139
- city TEXT,
140
- metadata TEXT NOT NULL DEFAULT '{}',
141
- machine_id TEXT,
142
- synced_at TEXT,
143
- created_at TEXT NOT NULL,
144
- updated_at TEXT NOT NULL
145
- );
146
-
147
- CREATE INDEX IF NOT EXISTS idx_domains_hostname ON domains(hostname);
148
- CREATE INDEX IF NOT EXISTS idx_domains_default ON domains(default_domain);
149
- CREATE INDEX IF NOT EXISTS idx_links_domain_slug ON links(domain_id, slug);
150
- CREATE INDEX IF NOT EXISTS idx_links_active ON links(active);
151
- CREATE INDEX IF NOT EXISTS idx_links_updated ON links(updated_at);
152
- CREATE INDEX IF NOT EXISTS idx_clicks_link ON clicks(link_id);
153
- CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
154
- CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
155
- CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
156
- `
157
- ];
158
-
159
- class ShortlinksDatabase {
160
- db;
161
- path;
162
- constructor(path) {
163
- this.path = getDatabasePath(path);
164
- mkdirSync2(dirname2(this.path), { recursive: true });
165
- this.db = new Database(this.path);
166
- this.db.exec("PRAGMA foreign_keys = ON;");
167
- this.applyMigrations();
168
- }
169
- close() {
170
- this.db.close();
171
- }
172
- applyMigrations() {
173
- this.db.exec(`
174
- CREATE TABLE IF NOT EXISTS _migrations (
175
- id INTEGER PRIMARY KEY,
176
- applied_at TEXT NOT NULL
177
- );
178
- `);
179
- for (let i = 0;i < SQLITE_MIGRATIONS.length; i += 1) {
180
- const id = i + 1;
181
- const applied = this.db.query("SELECT id FROM _migrations WHERE id = ?").get(id);
182
- if (applied)
183
- continue;
184
- const migration = SQLITE_MIGRATIONS[i];
185
- const apply = this.db.transaction(() => {
186
- this.db.exec(migration);
187
- this.db.query("INSERT INTO _migrations (id, applied_at) VALUES (?, ?)").run(id, now());
188
- });
189
- apply();
190
- }
191
- }
192
- }
193
- // src/store.ts
194
- import { createHash } from "crypto";
195
-
196
- // src/machine.ts
197
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
198
- import { hostname } from "os";
199
- import { join as join2 } from "path";
200
-
201
- // src/slug.ts
202
- import { randomBytes } from "crypto";
203
- var SLUG_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
204
- var DEFAULT_SLUG_LENGTH = 7;
205
- function randomToken(length = DEFAULT_SLUG_LENGTH) {
206
- if (length < 1 || length > 128)
207
- throw new Error("Token length must be between 1 and 128.");
208
- const bytes = randomBytes(length);
209
- let out = "";
210
- for (let i = 0;i < length; i += 1) {
211
- out += SLUG_ALPHABET[bytes[i] % SLUG_ALPHABET.length];
212
- }
213
- return out;
214
- }
215
- function normalizeSlug(slug) {
216
- const normalized = slug.trim().replace(/^\/+/, "").replace(/\/+$/, "");
217
- if (!normalized)
218
- throw new Error("Slug is required.");
219
- if (!/^[A-Za-z0-9_-]{1,96}$/.test(normalized)) {
220
- throw new Error("Slug can only contain letters, numbers, underscores, and dashes.");
221
- }
222
- return normalized;
223
- }
224
-
225
- // src/machine.ts
226
- function getMachineId() {
227
- const path = join2(ensureDataDir(), "machine-id");
228
- if (existsSync2(path)) {
229
- const existing = readFileSync2(path, "utf-8").trim();
230
- if (existing)
231
- return existing;
232
- }
233
- const safeHost = hostname().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
234
- const id = `${safeHost || "machine"}-${randomToken(8).toLowerCase()}`;
235
- writeFileSync2(path, `${id}
236
- `);
237
- return id;
238
- }
239
-
240
- // src/store.ts
241
- function parseJsonObject(value) {
242
- if (!value)
243
- return {};
244
- try {
245
- const parsed = JSON.parse(value);
246
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
247
- } catch {
248
- return {};
249
- }
250
- }
251
- function domainFromRow(row) {
252
- return {
253
- ...row,
254
- default_domain: Boolean(row.default_domain),
255
- metadata: parseJsonObject(row.metadata)
256
- };
257
- }
258
- function linkFromRow(row) {
259
- const config = loadConfig();
260
- const publicBaseUrl = config.defaultDomain === row.hostname ? config.publicBaseUrl : undefined;
261
- return {
262
- ...row,
263
- active: Boolean(row.active),
264
- metadata: parseJsonObject(row.metadata),
265
- short_url: formatShortUrl(row.hostname, row.slug, publicBaseUrl)
266
- };
267
- }
268
- function clickFromRow(row) {
269
- return {
270
- ...row,
271
- metadata: parseJsonObject(row.metadata)
272
- };
273
- }
274
- function validateDestinationUrl(url) {
275
- let parsed;
276
- try {
277
- parsed = new URL(url);
278
- } catch {
279
- throw new Error(`Invalid destination URL: ${url}`);
280
- }
281
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
282
- throw new Error("Destination URL must start with http:// or https://.");
283
- }
284
- return parsed.toString();
285
- }
286
- function isoOrNull(input) {
287
- if (!input)
288
- return null;
289
- const date = new Date(input);
290
- if (Number.isNaN(date.getTime()))
291
- throw new Error(`Invalid date: ${input}`);
292
- return date.toISOString();
293
- }
294
-
295
- class ShortlinksStore {
296
- database;
297
- constructor(dbPath) {
298
- this.database = new ShortlinksDatabase(dbPath);
299
- }
300
- close() {
301
- this.database.close();
302
- }
303
- addDomain(input) {
304
- const hostname2 = normalizeHostname(input.hostname);
305
- const timestamp = now();
306
- const machineId = getMachineId();
307
- const existing = this.getDomain(hostname2);
308
- const id = existing?.id || makeId("dom");
309
- if (input.defaultDomain) {
310
- this.database.db.query("UPDATE domains SET default_domain = 0, updated_at = ?, synced_at = NULL").run(timestamp);
311
- updateConfig({ defaultDomain: hostname2, publicBaseUrl: `https://${hostname2}` });
312
- }
313
- this.database.db.query(`
314
- INSERT INTO domains (
315
- id, hostname, provider, default_domain, cloudflare_zone_id, cloudflare_account_id,
316
- cloudflare_worker_name, origin_url, notes, metadata, machine_id, synced_at, created_at, updated_at
317
- )
318
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
319
- ON CONFLICT(hostname) DO UPDATE SET
320
- provider = excluded.provider,
321
- default_domain = excluded.default_domain,
322
- cloudflare_zone_id = COALESCE(excluded.cloudflare_zone_id, domains.cloudflare_zone_id),
323
- cloudflare_account_id = COALESCE(excluded.cloudflare_account_id, domains.cloudflare_account_id),
324
- cloudflare_worker_name = COALESCE(excluded.cloudflare_worker_name, domains.cloudflare_worker_name),
325
- origin_url = COALESCE(excluded.origin_url, domains.origin_url),
326
- notes = COALESCE(excluded.notes, domains.notes),
327
- metadata = excluded.metadata,
328
- machine_id = excluded.machine_id,
329
- synced_at = NULL,
330
- updated_at = excluded.updated_at
331
- `).run(id, hostname2, input.provider || existing?.provider || "manual", input.defaultDomain ?? existing?.default_domain ? 1 : 0, input.cloudflareZoneId || existing?.cloudflare_zone_id || null, input.cloudflareAccountId || existing?.cloudflare_account_id || null, input.cloudflareWorkerName || existing?.cloudflare_worker_name || null, input.originUrl || existing?.origin_url || null, input.notes || existing?.notes || null, JSON.stringify(input.metadata || existing?.metadata || {}), machineId, existing?.created_at || timestamp, timestamp);
332
- return this.getDomain(hostname2);
333
- }
334
- listDomains() {
335
- const rows = this.database.db.query(`
336
- SELECT * FROM domains
337
- ORDER BY default_domain DESC, hostname ASC
338
- `).all();
339
- return rows.map(domainFromRow);
340
- }
341
- getDomain(hostnameOrId) {
342
- const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
343
- const row = this.database.db.query(`
344
- SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
345
- `).get(normalized, hostnameOrId);
346
- return row ? domainFromRow(row) : null;
347
- }
348
- getDefaultDomain() {
349
- const config = loadConfig();
350
- if (config.defaultDomain) {
351
- const configured = this.getDomain(config.defaultDomain);
352
- if (configured)
353
- return configured;
354
- }
355
- const row = this.database.db.query(`
356
- SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
357
- `).get();
358
- return row ? domainFromRow(row) : null;
359
- }
360
- createLink(input) {
361
- const domain = input.domain ? this.getDomain(input.domain) : this.getDefaultDomain();
362
- if (!domain) {
363
- throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
364
- }
365
- const destinationUrl = validateDestinationUrl(input.destinationUrl);
366
- const timestamp = now();
367
- const machineId = getMachineId();
368
- const expiresAt = isoOrNull(input.expiresAt);
369
- const slug = input.slug ? normalizeSlug(input.slug) : this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
370
- try {
371
- this.database.db.query(`
372
- INSERT INTO links (
373
- id, domain_id, slug, destination_url, title, active, expires_at, metadata,
374
- machine_id, synced_at, created_at, updated_at
375
- )
376
- VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
377
- `).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
378
- } catch (error) {
379
- const message = error instanceof Error ? error.message : String(error);
380
- if (message.includes("UNIQUE")) {
381
- throw new Error(`Slug already exists for ${domain.hostname}: ${slug}`);
382
- }
383
- throw error;
384
- }
385
- return this.getLink(domain.hostname, slug);
386
- }
387
- listLinks(options = {}) {
388
- const params = [];
389
- let where = "WHERE 1 = 1";
390
- if (options.domain) {
391
- where += " AND d.hostname = ?";
392
- params.push(normalizeHostname(options.domain));
393
- }
394
- if (options.activeOnly) {
395
- where += " AND l.active = 1";
396
- }
397
- params.push(options.limit || 100);
398
- const rows = this.database.db.query(`
399
- SELECT l.*, d.hostname
400
- FROM links l
401
- JOIN domains d ON d.id = l.domain_id
402
- ${where}
403
- ORDER BY l.created_at DESC
404
- LIMIT ?
405
- `).all(...params);
406
- return rows.map(linkFromRow);
407
- }
408
- getLink(domainOrSlug, maybeSlug) {
409
- const slug = normalizeSlug(maybeSlug || domainOrSlug);
410
- const params = [slug];
411
- let domainClause = "";
412
- if (maybeSlug) {
413
- domainClause = "AND d.hostname = ?";
414
- params.push(normalizeHostname(domainOrSlug));
415
- }
416
- const row = this.database.db.query(`
417
- SELECT l.*, d.hostname
418
- FROM links l
419
- JOIN domains d ON d.id = l.domain_id
420
- WHERE l.slug = ? ${domainClause}
421
- ORDER BY d.default_domain DESC, l.created_at ASC
422
- LIMIT 1
423
- `).get(...params);
424
- return row ? linkFromRow(row) : null;
425
- }
426
- resolve(hostname2, slug) {
427
- const normalizedSlug = normalizeSlug(slug);
428
- const normalizedHost = normalizeHostname(hostname2);
429
- const row = this.database.db.query(`
430
- SELECT l.*, d.hostname
431
- FROM links l
432
- JOIN domains d ON d.id = l.domain_id
433
- WHERE d.hostname = ? AND l.slug = ?
434
- LIMIT 1
435
- `).get(normalizedHost, normalizedSlug);
436
- if (row)
437
- return linkFromRow(row);
438
- const fallback = this.getDefaultDomain();
439
- if (!fallback || fallback.hostname === normalizedHost)
440
- return null;
441
- return this.getLink(fallback.hostname, normalizedSlug);
442
- }
443
- setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
444
- const active = typeof maybeSlugOrActive === "boolean" ? maybeSlugOrActive : Boolean(maybeActive);
445
- const link = typeof maybeSlugOrActive === "boolean" ? this.getLink(domainOrSlug) : this.getLink(domainOrSlug, maybeSlugOrActive);
446
- if (!link)
447
- throw new Error("Link not found.");
448
- const timestamp = now();
449
- this.database.db.query(`
450
- UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
451
- `).run(active ? 1 : 0, timestamp, link.id);
452
- return this.getLink(link.hostname, link.slug);
453
- }
454
- deleteLink(domainOrSlug, maybeSlug) {
455
- const link = maybeSlug ? this.getLink(domainOrSlug, maybeSlug) : this.getLink(domainOrSlug);
456
- if (!link)
457
- throw new Error("Link not found.");
458
- this.database.db.query("DELETE FROM links WHERE id = ?").run(link.id);
459
- return link;
460
- }
461
- recordClick(link, input = {}) {
462
- const timestamp = now();
463
- const machineId = getMachineId();
464
- const ipHash = input.ip ? this.hashIp(input.ip) : null;
465
- const id = makeId("clk");
466
- this.database.db.query(`
467
- INSERT INTO clicks (
468
- id, link_id, domain_id, slug, clicked_at, ip_hash, user_agent, referer,
469
- country, city, metadata, machine_id, synced_at, created_at, updated_at
470
- )
471
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
472
- `).run(id, link.id, link.domain_id, link.slug, timestamp, ipHash, input.userAgent || null, input.referer || null, input.country || null, input.city || null, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
473
- const row = this.database.db.query("SELECT * FROM clicks WHERE id = ?").get(id);
474
- return clickFromRow(row);
475
- }
476
- getStats(domainOrSlug, maybeSlug) {
477
- const link = maybeSlug ? this.getLink(domainOrSlug, maybeSlug) : this.getLink(domainOrSlug);
478
- if (!link)
479
- throw new Error("Link not found.");
480
- const summary = this.database.db.query(`
481
- SELECT COUNT(*) AS clicks, MAX(clicked_at) AS last_clicked_at FROM clicks WHERE link_id = ?
482
- `).get(link.id);
483
- const topReferrers = this.database.db.query(`
484
- SELECT referer, COUNT(*) AS clicks
485
- FROM clicks
486
- WHERE link_id = ?
487
- GROUP BY referer
488
- ORDER BY clicks DESC
489
- LIMIT 10
490
- `).all(link.id);
491
- const topUserAgents = this.database.db.query(`
492
- SELECT user_agent, COUNT(*) AS clicks
493
- FROM clicks
494
- WHERE link_id = ?
495
- GROUP BY user_agent
496
- ORDER BY clicks DESC
497
- LIMIT 10
498
- `).all(link.id);
499
- return {
500
- link,
501
- clicks: summary.clicks,
502
- last_clicked_at: summary.last_clicked_at,
503
- top_referrers: topReferrers,
504
- top_user_agents: topUserAgents
505
- };
506
- }
507
- totalStats() {
508
- const row = this.database.db.query(`
509
- SELECT
510
- (SELECT COUNT(*) FROM domains) AS domains,
511
- (SELECT COUNT(*) FROM links) AS links,
512
- (SELECT COUNT(*) FROM clicks) AS clicks
513
- `).get();
514
- return row;
515
- }
516
- generateAvailableSlug(domainId, length) {
517
- for (let attempt = 0;attempt < 32; attempt += 1) {
518
- const slug = randomToken(length);
519
- const exists = this.database.db.query(`
520
- SELECT 1 FROM links WHERE domain_id = ? AND slug = ? LIMIT 1
521
- `).get(domainId, slug);
522
- if (!exists)
523
- return slug;
524
- }
525
- throw new Error("Could not generate an unused slug after 32 attempts.");
526
- }
527
- hashIp(ip) {
528
- const salt = process.env.SHORTLINKS_CLICK_SALT || "shortlinks-local";
529
- return createHash("sha256").update(`${salt}:${ip}`).digest("hex");
530
- }
531
- }
532
- // src/server.ts
533
- function json(data, status = 200) {
534
- return new Response(JSON.stringify(data, null, 2), {
535
- status,
536
- headers: { "content-type": "application/json; charset=utf-8" }
537
- });
538
- }
539
- function getHost(request, fallback) {
540
- const forwarded = request.headers.get("x-forwarded-host");
541
- const host = forwarded || request.headers.get("host") || fallback || "";
542
- return host.split(",")[0].trim().split(":")[0];
543
- }
544
- function getClientIp(request) {
545
- const forwarded = request.headers.get("x-forwarded-for");
546
- if (forwarded)
547
- return forwarded.split(",")[0].trim();
548
- return request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip");
549
- }
550
- function isExpired(link) {
551
- return Boolean(link.expires_at && new Date(link.expires_at).getTime() <= Date.now());
552
- }
553
- function createShortlinksHandler(options = {}) {
554
- const store = options.store || new ShortlinksStore(options.dbPath);
555
- const redirectStatus = options.redirectStatus || 302;
556
- return async (request) => {
557
- const url = new URL(request.url);
558
- if (url.pathname === "/healthz") {
559
- return json({ ok: true, service: "shortlinks", stats: store.totalStats() });
560
- }
561
- if (url.pathname === "/" || url.pathname === "") {
562
- return json({ service: "shortlinks", ok: true });
563
- }
564
- let slug = "";
565
- try {
566
- slug = decodeURIComponent(url.pathname.replace(/^\/+/, "").split("/")[0] || "");
567
- } catch {
568
- return json({ error: "Invalid slug." }, 400);
569
- }
570
- if (!slug)
571
- return json({ error: "Missing slug." }, 404);
572
- const host = getHost(request, options.defaultHost);
573
- if (!host)
574
- return json({ error: "Missing Host header." }, 400);
575
- let link = null;
576
- try {
577
- link = store.resolve(host, slug);
578
- } catch {
579
- return json({ error: "Shortlink not found.", slug, host }, 404);
580
- }
581
- if (!link)
582
- return json({ error: "Shortlink not found.", slug, host }, 404);
583
- if (!link.active)
584
- return json({ error: "Shortlink is disabled.", slug, host }, 410);
585
- if (isExpired(link))
586
- return json({ error: "Shortlink is expired.", slug, host }, 410);
587
- store.recordClick(link, {
588
- ip: getClientIp(request),
589
- userAgent: request.headers.get("user-agent"),
590
- referer: request.headers.get("referer"),
591
- country: request.headers.get("cf-ipcountry"),
592
- metadata: {
593
- path: url.pathname,
594
- query: url.search
595
- }
596
- });
597
- return Response.redirect(link.destination_url, redirectStatus);
598
- };
599
- }
600
- function serveShortlinks(options = {}) {
601
- const host = options.host || "127.0.0.1";
602
- const port = options.port || 8787;
603
- const fetch2 = createShortlinksHandler(options);
604
- return Bun.serve({ hostname: host, port, fetch: fetch2 });
605
- }
606
- // src/cloudflare.ts
607
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
608
- import { join as join3 } from "path";
609
- function createCloudflarePlan(input) {
610
- const hostname2 = normalizeHostname(input.hostname);
611
- const target = normalizeHostname(input.target);
612
- const workerName = input.workerName || "shortlinks";
613
- const proxied = input.proxied ?? true;
614
- return {
615
- hostname: hostname2,
616
- target,
617
- proxied,
618
- workerName,
619
- origin: input.origin,
620
- dnsRecord: {
621
- type: "CNAME",
622
- name: hostname2,
623
- content: target,
624
- proxied
625
- },
626
- wranglerCommand: `wrangler deploy cloudflare/${workerName}.js --name ${workerName}`
627
- };
628
- }
629
- function generateWorkerScript() {
630
- return `export default {
631
- async fetch(request, env) {
632
- const origin = env.SHORTLINKS_ORIGIN;
633
- if (!origin) {
634
- return new Response("SHORTLINKS_ORIGIN is not configured", { status: 500 });
635
- }
636
-
637
- const incoming = new URL(request.url);
638
- const upstream = new URL(incoming.pathname + incoming.search, origin);
639
- const headers = new Headers(request.headers);
640
- headers.set("x-forwarded-host", incoming.host);
641
- headers.set("x-shortlinks-worker", "cloudflare");
642
-
643
- return fetch(upstream.toString(), {
644
- method: request.method,
645
- headers,
646
- body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
647
- redirect: "manual"
648
- });
649
- }
650
- };
651
- `;
652
- }
653
- function writeWorkerFiles(options = {}) {
654
- const outDir = options.outDir || "cloudflare";
655
- const workerName = options.workerName || "shortlinks";
656
- mkdirSync3(outDir, { recursive: true });
657
- const workerPath = join3(outDir, `${workerName}.js`);
658
- const wranglerPath = join3(outDir, "wrangler.example.toml");
659
- writeFileSync3(workerPath, generateWorkerScript());
660
- writeFileSync3(wranglerPath, `name = "${workerName}"
661
- main = "${workerName}.js"
662
- compatibility_date = "2026-05-01"
663
-
664
- [vars]
665
- SHORTLINKS_ORIGIN = "${options.origin || "https://shortlinks.example.com"}"
666
- `);
667
- return { workerPath, wranglerPath };
668
- }
669
- function cloudflareAuthHeaders(token) {
670
- const apiToken = token || process.env.CLOUDFLARE_API_TOKEN;
671
- if (apiToken)
672
- return { authorization: `Bearer ${apiToken}` };
673
- const apiKey = process.env.CLOUDFLARE_API_KEY;
674
- const email = process.env.CLOUDFLARE_EMAIL;
675
- if (apiKey && email) {
676
- return {
677
- "x-auth-key": apiKey,
678
- "x-auth-email": email
679
- };
680
- }
681
- throw new Error("Cloudflare auth is required: set CLOUDFLARE_API_TOKEN, or CLOUDFLARE_API_KEY plus CLOUDFLARE_EMAIL.");
682
- }
683
- async function cloudflareRequest(token, path, init = {}) {
684
- const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
685
- ...init,
686
- headers: {
687
- ...cloudflareAuthHeaders(token),
688
- "content-type": "application/json",
689
- ...init.headers || {}
690
- }
691
- });
692
- const body = await response.json();
693
- if (!response.ok || body.success === false) {
694
- const message = body.errors?.map((e) => e.message).join("; ") || response.statusText;
695
- throw new Error(`Cloudflare API failed: ${message}`);
696
- }
697
- return body.result;
698
- }
699
- function candidateZones(hostname2) {
700
- const parts = normalizeHostname(hostname2).split(".");
701
- const candidates = [];
702
- for (let i = 0;i < parts.length - 1; i += 1) {
703
- candidates.push(parts.slice(i).join("."));
704
- }
705
- return candidates;
706
- }
707
- async function findCloudflareZoneId(hostname2, token) {
708
- for (const zone of candidateZones(hostname2)) {
709
- const result = await cloudflareRequest(token, `/zones?name=${encodeURIComponent(zone)}`);
710
- if (result[0]?.id)
711
- return result[0].id;
712
- }
713
- throw new Error(`Could not find a Cloudflare zone for ${hostname2}. Pass --zone-id explicitly.`);
714
- }
715
- async function upsertCloudflareDnsRecord(options) {
716
- const token = options.token || process.env.CLOUDFLARE_API_TOKEN;
717
- const plan = createCloudflarePlan({
718
- hostname: options.hostname,
719
- target: options.target,
720
- origin: process.env.SHORTLINKS_ORIGIN || "https://shortlinks.example.com",
721
- proxied: options.proxied
722
- });
723
- if (options.dryRun)
724
- return plan;
725
- const zoneId = options.zoneId || await findCloudflareZoneId(plan.hostname, token);
726
- const existing = await cloudflareRequest(token, `/zones/${zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(plan.hostname)}`);
727
- const payload = JSON.stringify(plan.dnsRecord);
728
- if (existing[0]?.id) {
729
- const updated = await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${existing[0].id}`, {
730
- method: "PUT",
731
- body: payload
732
- });
733
- return { id: updated.id, action: "updated" };
734
- }
735
- const created = await cloudflareRequest(token, `/zones/${zoneId}/dns_records`, {
736
- method: "POST",
737
- body: payload
738
- });
739
- return { id: created.id, action: "created" };
740
- }
741
- // src/local.ts
742
- import { spawnSync } from "child_process";
743
- import { homedir as homedir2 } from "os";
744
- import { join as join4 } from "path";
745
- function createLocalSetupPlan(input) {
746
- const domain = normalizeHostname(input.domain);
747
- const targetHost = input.targetHost || "127.0.0.1";
748
- const port = input.port || 8787;
749
- const certDir = input.certDir || join4(homedir2(), ".hasna", "machines", "certs");
750
- const certPath = join4(certDir, `${domain}.pem`);
751
- const keyPath = join4(certDir, `${domain}-key.pem`);
752
- return {
753
- domain,
754
- targetHost,
755
- port,
756
- hostsEntry: `${targetHost} ${domain}`,
757
- caddySnippet: `${domain} {
758
- reverse_proxy ${targetHost}:${port}
759
- tls ${certPath} ${keyPath}
760
- }`,
761
- certPath,
762
- keyPath,
763
- machinesCommand: `machines dns add --domain ${domain} --target-host ${targetHost} --port ${port} --json`,
764
- sudoRequired: true
765
- };
766
- }
767
- function registerMachinesDns(input) {
768
- const plan = createLocalSetupPlan(input);
769
- const args = [
770
- "dns",
771
- "add",
772
- "--domain",
773
- plan.domain,
774
- "--target-host",
775
- plan.targetHost,
776
- "--port",
777
- String(plan.port),
778
- "--json"
779
- ];
780
- const result = spawnSync("machines", args, { encoding: "utf-8" });
781
- return {
782
- command: `machines ${args.join(" ")}`,
783
- status: result.status,
784
- stdout: result.stdout || "",
785
- stderr: result.stderr || ""
786
- };
787
- }
788
- // src/pg-migrations.ts
789
- var PG_MIGRATIONS = [
790
- `
791
- CREATE TABLE IF NOT EXISTS domains (
792
- id TEXT PRIMARY KEY,
793
- hostname TEXT NOT NULL UNIQUE,
794
- provider TEXT NOT NULL DEFAULT 'manual',
795
- default_domain INTEGER NOT NULL DEFAULT 0,
796
- cloudflare_zone_id TEXT,
797
- cloudflare_account_id TEXT,
798
- cloudflare_worker_name TEXT,
799
- origin_url TEXT,
800
- notes TEXT,
801
- metadata TEXT NOT NULL DEFAULT '{}',
802
- machine_id TEXT,
803
- synced_at TIMESTAMPTZ,
804
- created_at TIMESTAMPTZ NOT NULL,
805
- updated_at TIMESTAMPTZ NOT NULL
806
- );
807
-
808
- CREATE TABLE IF NOT EXISTS links (
809
- id TEXT PRIMARY KEY,
810
- domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
811
- slug TEXT NOT NULL,
812
- destination_url TEXT NOT NULL,
813
- title TEXT,
814
- active INTEGER NOT NULL DEFAULT 1,
815
- expires_at TIMESTAMPTZ,
816
- metadata TEXT NOT NULL DEFAULT '{}',
817
- machine_id TEXT,
818
- synced_at TIMESTAMPTZ,
819
- created_at TIMESTAMPTZ NOT NULL,
820
- updated_at TIMESTAMPTZ NOT NULL,
821
- UNIQUE(domain_id, slug)
822
- );
823
-
824
- CREATE TABLE IF NOT EXISTS clicks (
825
- id TEXT PRIMARY KEY,
826
- link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
827
- domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
828
- slug TEXT NOT NULL,
829
- clicked_at TIMESTAMPTZ NOT NULL,
830
- ip_hash TEXT,
831
- user_agent TEXT,
832
- referer TEXT,
833
- country TEXT,
834
- city TEXT,
835
- metadata TEXT NOT NULL DEFAULT '{}',
836
- machine_id TEXT,
837
- synced_at TIMESTAMPTZ,
838
- created_at TIMESTAMPTZ NOT NULL,
839
- updated_at TIMESTAMPTZ NOT NULL
840
- );
841
-
842
- CREATE INDEX IF NOT EXISTS idx_domains_hostname ON domains(hostname);
843
- CREATE INDEX IF NOT EXISTS idx_domains_default ON domains(default_domain);
844
- CREATE INDEX IF NOT EXISTS idx_links_domain_slug ON links(domain_id, slug);
845
- CREATE INDEX IF NOT EXISTS idx_links_active ON links(active);
846
- CREATE INDEX IF NOT EXISTS idx_links_updated ON links(updated_at);
847
- CREATE INDEX IF NOT EXISTS idx_clicks_link ON clicks(link_id);
848
- CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
849
- CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
850
- CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
851
- `
852
- ];
853
- export {
854
- writeWorkerFiles,
855
- upsertCloudflareDnsRecord,
856
- serveShortlinks,
857
- saveConfig,
858
- registerMachinesDns,
859
- randomToken,
860
- now,
861
- normalizeSlug,
862
- normalizeHostname,
863
- makeId,
864
- loadConfig,
865
- getDatabasePath,
866
- getDataDir,
867
- getConfigPath,
868
- generateWorkerScript,
869
- formatShortUrl,
870
- createShortlinksHandler,
871
- createLocalSetupPlan,
872
- createCloudflarePlan,
873
- ShortlinksStore,
874
- ShortlinksDatabase,
875
- SQLITE_MIGRATIONS,
876
- PG_MIGRATIONS
877
- };