@hasna/shortlinks 0.1.9 → 0.1.11

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 ADDED
@@ -0,0 +1,1199 @@
1
+ // @bun
2
+ var __require = import.meta.require;
3
+
4
+ // src/database.ts
5
+ import { Database } from "bun:sqlite";
6
+ import { mkdirSync as mkdirSync2 } from "fs";
7
+ import { dirname as dirname2 } from "path";
8
+
9
+ // src/config.ts
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
11
+ import { homedir } from "os";
12
+ import { dirname, join, resolve } from "path";
13
+ var SERVICE_NAME = "shortlinks";
14
+ var DEFAULT_DATA_DIR = join(homedir(), ".hasna", SERVICE_NAME);
15
+ function getDataDir() {
16
+ return resolve(process.env.SHORTLINKS_HOME || DEFAULT_DATA_DIR);
17
+ }
18
+ function ensureDataDir() {
19
+ const dir = getDataDir();
20
+ mkdirSync(dir, { recursive: true });
21
+ return dir;
22
+ }
23
+ function getConfigPath() {
24
+ return join(ensureDataDir(), "config.json");
25
+ }
26
+ function getDatabasePath(explicitPath) {
27
+ if (explicitPath)
28
+ return resolve(explicitPath);
29
+ if (process.env.SHORTLINKS_DB)
30
+ return resolve(process.env.SHORTLINKS_DB);
31
+ return join(ensureDataDir(), `${SERVICE_NAME}.db`);
32
+ }
33
+ function loadConfig() {
34
+ const path = getConfigPath();
35
+ if (!existsSync(path))
36
+ return {};
37
+ try {
38
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
39
+ return parsed && typeof parsed === "object" ? parsed : {};
40
+ } catch {
41
+ return {};
42
+ }
43
+ }
44
+ function saveConfig(config) {
45
+ const path = getConfigPath();
46
+ mkdirSync(dirname(path), { recursive: true });
47
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}
48
+ `);
49
+ }
50
+ function updateConfig(patch) {
51
+ const next = {
52
+ ...loadConfig(),
53
+ ...patch,
54
+ cloudflare: {
55
+ ...loadConfig().cloudflare,
56
+ ...patch.cloudflare
57
+ }
58
+ };
59
+ saveConfig(next);
60
+ return next;
61
+ }
62
+ function normalizeHostname(input) {
63
+ const raw = input.trim().toLowerCase();
64
+ if (!raw)
65
+ throw new Error("Domain is required.");
66
+ const withProtocol = raw.includes("://") ? raw : `https://${raw}`;
67
+ let hostname;
68
+ try {
69
+ hostname = new URL(withProtocol).hostname;
70
+ } catch {
71
+ throw new Error(`Invalid domain: ${input}`);
72
+ }
73
+ hostname = hostname.replace(/\.$/, "");
74
+ if (!/^[a-z0-9.-]+$/.test(hostname) || hostname.includes("..")) {
75
+ throw new Error(`Invalid domain: ${input}`);
76
+ }
77
+ return hostname;
78
+ }
79
+ function formatShortUrl(hostname, slug, publicBaseUrl) {
80
+ if (publicBaseUrl) {
81
+ const base = publicBaseUrl.endsWith("/") ? publicBaseUrl : `${publicBaseUrl}/`;
82
+ return new URL(slug, base).toString();
83
+ }
84
+ return `https://${hostname}/${slug}`;
85
+ }
86
+
87
+ // src/database.ts
88
+ function now() {
89
+ return new Date().toISOString();
90
+ }
91
+ function makeId(prefix) {
92
+ const bytes = crypto.getRandomValues(new Uint8Array(12));
93
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
94
+ return `${prefix}_${hex}`;
95
+ }
96
+ var SQLITE_MIGRATIONS = [
97
+ `
98
+ CREATE TABLE IF NOT EXISTS domains (
99
+ id TEXT PRIMARY KEY,
100
+ hostname TEXT NOT NULL UNIQUE,
101
+ provider TEXT NOT NULL DEFAULT 'manual',
102
+ default_domain INTEGER NOT NULL DEFAULT 0,
103
+ cloudflare_zone_id TEXT,
104
+ cloudflare_account_id TEXT,
105
+ cloudflare_worker_name TEXT,
106
+ origin_url TEXT,
107
+ notes TEXT,
108
+ metadata TEXT NOT NULL DEFAULT '{}',
109
+ machine_id TEXT,
110
+ synced_at TEXT,
111
+ created_at TEXT NOT NULL,
112
+ updated_at TEXT NOT NULL
113
+ );
114
+
115
+ CREATE TABLE IF NOT EXISTS links (
116
+ id TEXT PRIMARY KEY,
117
+ domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
118
+ slug TEXT NOT NULL,
119
+ destination_url TEXT NOT NULL,
120
+ title TEXT,
121
+ active INTEGER NOT NULL DEFAULT 1,
122
+ expires_at TEXT,
123
+ metadata TEXT NOT NULL DEFAULT '{}',
124
+ machine_id TEXT,
125
+ synced_at TEXT,
126
+ created_at TEXT NOT NULL,
127
+ updated_at TEXT NOT NULL,
128
+ UNIQUE(domain_id, slug)
129
+ );
130
+
131
+ CREATE TABLE IF NOT EXISTS clicks (
132
+ id TEXT PRIMARY KEY,
133
+ link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
134
+ domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
135
+ slug TEXT NOT NULL,
136
+ clicked_at TEXT NOT NULL,
137
+ ip_hash TEXT,
138
+ user_agent TEXT,
139
+ referer TEXT,
140
+ country TEXT,
141
+ city TEXT,
142
+ metadata TEXT NOT NULL DEFAULT '{}',
143
+ machine_id TEXT,
144
+ synced_at TEXT,
145
+ created_at TEXT NOT NULL,
146
+ updated_at TEXT NOT NULL
147
+ );
148
+
149
+ CREATE INDEX IF NOT EXISTS idx_domains_hostname ON domains(hostname);
150
+ CREATE INDEX IF NOT EXISTS idx_domains_default ON domains(default_domain);
151
+ CREATE INDEX IF NOT EXISTS idx_links_domain_slug ON links(domain_id, slug);
152
+ CREATE INDEX IF NOT EXISTS idx_links_active ON links(active);
153
+ CREATE INDEX IF NOT EXISTS idx_links_updated ON links(updated_at);
154
+ CREATE INDEX IF NOT EXISTS idx_clicks_link ON clicks(link_id);
155
+ CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
156
+ CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
157
+ CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
158
+ `
159
+ ];
160
+
161
+ class ShortlinksDatabase {
162
+ db;
163
+ path;
164
+ constructor(path) {
165
+ this.path = getDatabasePath(path);
166
+ mkdirSync2(dirname2(this.path), { recursive: true });
167
+ this.db = new Database(this.path);
168
+ this.db.exec("PRAGMA foreign_keys = ON;");
169
+ this.applyMigrations();
170
+ }
171
+ close() {
172
+ this.db.close();
173
+ }
174
+ applyMigrations() {
175
+ this.db.exec(`
176
+ CREATE TABLE IF NOT EXISTS _migrations (
177
+ id INTEGER PRIMARY KEY,
178
+ applied_at TEXT NOT NULL
179
+ );
180
+ `);
181
+ for (let i = 0;i < SQLITE_MIGRATIONS.length; i += 1) {
182
+ const id = i + 1;
183
+ const applied = this.db.query("SELECT id FROM _migrations WHERE id = ?").get(id);
184
+ if (applied)
185
+ continue;
186
+ const migration = SQLITE_MIGRATIONS[i];
187
+ const apply = this.db.transaction(() => {
188
+ this.db.exec(migration);
189
+ this.db.query("INSERT INTO _migrations (id, applied_at) VALUES (?, ?)").run(id, now());
190
+ });
191
+ apply();
192
+ }
193
+ }
194
+ }
195
+ // src/store.ts
196
+ import { createHash } from "crypto";
197
+
198
+ // src/machine.ts
199
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
200
+ import { hostname } from "os";
201
+ import { join as join2 } from "path";
202
+
203
+ // src/slug.ts
204
+ import { randomBytes } from "crypto";
205
+ var SLUG_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
206
+ var DEFAULT_SLUG_LENGTH = 7;
207
+ function randomToken(length = DEFAULT_SLUG_LENGTH) {
208
+ if (length < 1 || length > 128)
209
+ throw new Error("Token length must be between 1 and 128.");
210
+ const bytes = randomBytes(length);
211
+ let out = "";
212
+ for (let i = 0;i < length; i += 1) {
213
+ out += SLUG_ALPHABET[bytes[i] % SLUG_ALPHABET.length];
214
+ }
215
+ return out;
216
+ }
217
+ function normalizeSlug(slug) {
218
+ const normalized = slug.trim().replace(/^\/+/, "").replace(/\/+$/, "");
219
+ if (!normalized)
220
+ throw new Error("Slug is required.");
221
+ if (!/^[A-Za-z0-9_-]{1,96}$/.test(normalized)) {
222
+ throw new Error("Slug can only contain letters, numbers, underscores, and dashes.");
223
+ }
224
+ return normalized;
225
+ }
226
+
227
+ // src/machine.ts
228
+ function getMachineId() {
229
+ const path = join2(ensureDataDir(), "machine-id");
230
+ if (existsSync2(path)) {
231
+ const existing = readFileSync2(path, "utf-8").trim();
232
+ if (existing)
233
+ return existing;
234
+ }
235
+ const safeHost = hostname().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
236
+ const id = `${safeHost || "machine"}-${randomToken(8).toLowerCase()}`;
237
+ writeFileSync2(path, `${id}
238
+ `);
239
+ return id;
240
+ }
241
+
242
+ // src/store.ts
243
+ function parseJsonObject(value) {
244
+ if (!value)
245
+ return {};
246
+ try {
247
+ const parsed = JSON.parse(value);
248
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
249
+ } catch {
250
+ return {};
251
+ }
252
+ }
253
+ function domainFromRow(row) {
254
+ return {
255
+ ...row,
256
+ default_domain: Boolean(row.default_domain),
257
+ metadata: parseJsonObject(row.metadata)
258
+ };
259
+ }
260
+ function linkFromRow(row) {
261
+ const config = loadConfig();
262
+ const publicBaseUrl = config.defaultDomain === row.hostname ? config.publicBaseUrl : undefined;
263
+ return {
264
+ ...row,
265
+ active: Boolean(row.active),
266
+ metadata: parseJsonObject(row.metadata),
267
+ short_url: formatShortUrl(row.hostname, row.slug, publicBaseUrl)
268
+ };
269
+ }
270
+ function clickFromRow(row) {
271
+ return {
272
+ ...row,
273
+ metadata: parseJsonObject(row.metadata)
274
+ };
275
+ }
276
+ function validateDestinationUrl(url) {
277
+ let parsed;
278
+ try {
279
+ parsed = new URL(url);
280
+ } catch {
281
+ throw new Error(`Invalid destination URL: ${url}`);
282
+ }
283
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
284
+ throw new Error("Destination URL must start with http:// or https://.");
285
+ }
286
+ return parsed.toString();
287
+ }
288
+ function isoOrNull(input) {
289
+ if (!input)
290
+ return null;
291
+ const date = new Date(input);
292
+ if (Number.isNaN(date.getTime()))
293
+ throw new Error(`Invalid date: ${input}`);
294
+ return date.toISOString();
295
+ }
296
+
297
+ class ShortlinksStore {
298
+ database;
299
+ constructor(dbPath) {
300
+ this.database = new ShortlinksDatabase(dbPath);
301
+ }
302
+ close() {
303
+ this.database.close();
304
+ }
305
+ addDomain(input) {
306
+ const hostname2 = normalizeHostname(input.hostname);
307
+ const timestamp = now();
308
+ const machineId = getMachineId();
309
+ const existing = this.getDomain(hostname2);
310
+ const id = existing?.id || makeId("dom");
311
+ if (input.defaultDomain) {
312
+ this.database.db.query("UPDATE domains SET default_domain = 0, updated_at = ?, synced_at = NULL").run(timestamp);
313
+ updateConfig({ defaultDomain: hostname2, publicBaseUrl: `https://${hostname2}` });
314
+ }
315
+ this.database.db.query(`
316
+ INSERT INTO domains (
317
+ id, hostname, provider, default_domain, cloudflare_zone_id, cloudflare_account_id,
318
+ cloudflare_worker_name, origin_url, notes, metadata, machine_id, synced_at, created_at, updated_at
319
+ )
320
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
321
+ ON CONFLICT(hostname) DO UPDATE SET
322
+ provider = excluded.provider,
323
+ default_domain = excluded.default_domain,
324
+ cloudflare_zone_id = COALESCE(excluded.cloudflare_zone_id, domains.cloudflare_zone_id),
325
+ cloudflare_account_id = COALESCE(excluded.cloudflare_account_id, domains.cloudflare_account_id),
326
+ cloudflare_worker_name = COALESCE(excluded.cloudflare_worker_name, domains.cloudflare_worker_name),
327
+ origin_url = COALESCE(excluded.origin_url, domains.origin_url),
328
+ notes = COALESCE(excluded.notes, domains.notes),
329
+ metadata = excluded.metadata,
330
+ machine_id = excluded.machine_id,
331
+ synced_at = NULL,
332
+ updated_at = excluded.updated_at
333
+ `).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);
334
+ return this.getDomain(hostname2);
335
+ }
336
+ listDomains() {
337
+ const rows = this.database.db.query(`
338
+ SELECT * FROM domains
339
+ ORDER BY default_domain DESC, hostname ASC
340
+ `).all();
341
+ return rows.map(domainFromRow);
342
+ }
343
+ getDomain(hostnameOrId) {
344
+ const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
345
+ const row = this.database.db.query(`
346
+ SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
347
+ `).get(normalized, hostnameOrId);
348
+ return row ? domainFromRow(row) : null;
349
+ }
350
+ getDefaultDomain() {
351
+ const config = loadConfig();
352
+ if (config.defaultDomain) {
353
+ const configured = this.getDomain(config.defaultDomain);
354
+ if (configured)
355
+ return configured;
356
+ }
357
+ const row = this.database.db.query(`
358
+ SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
359
+ `).get();
360
+ return row ? domainFromRow(row) : null;
361
+ }
362
+ createLink(input) {
363
+ const domain = input.domain ? this.getDomain(input.domain) : this.getDefaultDomain();
364
+ if (!domain) {
365
+ throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
366
+ }
367
+ const destinationUrl = validateDestinationUrl(input.destinationUrl);
368
+ const timestamp = now();
369
+ const machineId = getMachineId();
370
+ const expiresAt = isoOrNull(input.expiresAt);
371
+ const slug = input.slug ? normalizeSlug(input.slug) : this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
372
+ try {
373
+ this.database.db.query(`
374
+ INSERT INTO links (
375
+ id, domain_id, slug, destination_url, title, active, expires_at, metadata,
376
+ machine_id, synced_at, created_at, updated_at
377
+ )
378
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
379
+ `).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
380
+ } catch (error) {
381
+ const message = error instanceof Error ? error.message : String(error);
382
+ if (message.includes("UNIQUE")) {
383
+ throw new Error(`Slug already exists for ${domain.hostname}: ${slug}`);
384
+ }
385
+ throw error;
386
+ }
387
+ return this.getLink(domain.hostname, slug);
388
+ }
389
+ listLinks(options = {}) {
390
+ const params = [];
391
+ let where = "WHERE 1 = 1";
392
+ if (options.domain) {
393
+ where += " AND d.hostname = ?";
394
+ params.push(normalizeHostname(options.domain));
395
+ }
396
+ if (options.activeOnly) {
397
+ where += " AND l.active = 1";
398
+ }
399
+ params.push(options.limit || 100);
400
+ const rows = this.database.db.query(`
401
+ SELECT l.*, d.hostname
402
+ FROM links l
403
+ JOIN domains d ON d.id = l.domain_id
404
+ ${where}
405
+ ORDER BY l.created_at DESC
406
+ LIMIT ?
407
+ `).all(...params);
408
+ return rows.map(linkFromRow);
409
+ }
410
+ getLink(domainOrSlug, maybeSlug) {
411
+ const slug = normalizeSlug(maybeSlug || domainOrSlug);
412
+ const params = [slug];
413
+ let domainClause = "";
414
+ if (maybeSlug) {
415
+ domainClause = "AND d.hostname = ?";
416
+ params.push(normalizeHostname(domainOrSlug));
417
+ }
418
+ const row = this.database.db.query(`
419
+ SELECT l.*, d.hostname
420
+ FROM links l
421
+ JOIN domains d ON d.id = l.domain_id
422
+ WHERE l.slug = ? ${domainClause}
423
+ ORDER BY d.default_domain DESC, l.created_at ASC
424
+ LIMIT 1
425
+ `).get(...params);
426
+ return row ? linkFromRow(row) : null;
427
+ }
428
+ resolve(hostname2, slug) {
429
+ const normalizedSlug = normalizeSlug(slug);
430
+ const normalizedHost = normalizeHostname(hostname2);
431
+ const row = this.database.db.query(`
432
+ SELECT l.*, d.hostname
433
+ FROM links l
434
+ JOIN domains d ON d.id = l.domain_id
435
+ WHERE d.hostname = ? AND l.slug = ?
436
+ LIMIT 1
437
+ `).get(normalizedHost, normalizedSlug);
438
+ if (row)
439
+ return linkFromRow(row);
440
+ const fallback = this.getDefaultDomain();
441
+ if (!fallback || fallback.hostname === normalizedHost)
442
+ return null;
443
+ return this.getLink(fallback.hostname, normalizedSlug);
444
+ }
445
+ setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
446
+ const active = typeof maybeSlugOrActive === "boolean" ? maybeSlugOrActive : Boolean(maybeActive);
447
+ const link = typeof maybeSlugOrActive === "boolean" ? this.getLink(domainOrSlug) : this.getLink(domainOrSlug, maybeSlugOrActive);
448
+ if (!link)
449
+ throw new Error("Link not found.");
450
+ const timestamp = now();
451
+ this.database.db.query(`
452
+ UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
453
+ `).run(active ? 1 : 0, timestamp, link.id);
454
+ return this.getLink(link.hostname, link.slug);
455
+ }
456
+ deleteLink(domainOrSlug, maybeSlug) {
457
+ const link = maybeSlug ? this.getLink(domainOrSlug, maybeSlug) : this.getLink(domainOrSlug);
458
+ if (!link)
459
+ throw new Error("Link not found.");
460
+ this.database.db.query("DELETE FROM links WHERE id = ?").run(link.id);
461
+ return link;
462
+ }
463
+ recordClick(link, input = {}) {
464
+ const timestamp = now();
465
+ const machineId = getMachineId();
466
+ const ipHash = input.ip ? this.hashIp(input.ip) : null;
467
+ const id = makeId("clk");
468
+ this.database.db.query(`
469
+ INSERT INTO clicks (
470
+ id, link_id, domain_id, slug, clicked_at, ip_hash, user_agent, referer,
471
+ country, city, metadata, machine_id, synced_at, created_at, updated_at
472
+ )
473
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
474
+ `).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);
475
+ const row = this.database.db.query("SELECT * FROM clicks WHERE id = ?").get(id);
476
+ return clickFromRow(row);
477
+ }
478
+ getStats(domainOrSlug, maybeSlug) {
479
+ const link = maybeSlug ? this.getLink(domainOrSlug, maybeSlug) : this.getLink(domainOrSlug);
480
+ if (!link)
481
+ throw new Error("Link not found.");
482
+ const summary = this.database.db.query(`
483
+ SELECT COUNT(*) AS clicks, MAX(clicked_at) AS last_clicked_at FROM clicks WHERE link_id = ?
484
+ `).get(link.id);
485
+ const topReferrers = this.database.db.query(`
486
+ SELECT referer, COUNT(*) AS clicks
487
+ FROM clicks
488
+ WHERE link_id = ?
489
+ GROUP BY referer
490
+ ORDER BY clicks DESC
491
+ LIMIT 10
492
+ `).all(link.id);
493
+ const topUserAgents = this.database.db.query(`
494
+ SELECT user_agent, COUNT(*) AS clicks
495
+ FROM clicks
496
+ WHERE link_id = ?
497
+ GROUP BY user_agent
498
+ ORDER BY clicks DESC
499
+ LIMIT 10
500
+ `).all(link.id);
501
+ return {
502
+ link,
503
+ clicks: summary.clicks,
504
+ last_clicked_at: summary.last_clicked_at,
505
+ top_referrers: topReferrers,
506
+ top_user_agents: topUserAgents
507
+ };
508
+ }
509
+ totalStats() {
510
+ const row = this.database.db.query(`
511
+ SELECT
512
+ (SELECT COUNT(*) FROM domains) AS domains,
513
+ (SELECT COUNT(*) FROM links) AS links,
514
+ (SELECT COUNT(*) FROM clicks) AS clicks
515
+ `).get();
516
+ return row;
517
+ }
518
+ generateAvailableSlug(domainId, length) {
519
+ for (let attempt = 0;attempt < 32; attempt += 1) {
520
+ const slug = randomToken(length);
521
+ const exists = this.database.db.query(`
522
+ SELECT 1 FROM links WHERE domain_id = ? AND slug = ? LIMIT 1
523
+ `).get(domainId, slug);
524
+ if (!exists)
525
+ return slug;
526
+ }
527
+ throw new Error("Could not generate an unused slug after 32 attempts.");
528
+ }
529
+ hashIp(ip) {
530
+ const salt = process.env.SHORTLINKS_CLICK_SALT || "shortlinks-local";
531
+ return createHash("sha256").update(`${salt}:${ip}`).digest("hex");
532
+ }
533
+ }
534
+ // src/pg-store.ts
535
+ import { createHash as createHash2 } from "crypto";
536
+ function parseJsonObject2(value) {
537
+ if (!value)
538
+ return {};
539
+ if (typeof value === "object" && !Array.isArray(value))
540
+ return value;
541
+ try {
542
+ const parsed = JSON.parse(String(value));
543
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
544
+ } catch {
545
+ return {};
546
+ }
547
+ }
548
+ function toIsoString(value) {
549
+ if (value instanceof Date)
550
+ return value.toISOString();
551
+ return String(value);
552
+ }
553
+ function nullableIso(value) {
554
+ if (value === null || value === undefined)
555
+ return null;
556
+ return toIsoString(value);
557
+ }
558
+ function domainFromRow2(row) {
559
+ return {
560
+ ...row,
561
+ default_domain: Boolean(row.default_domain),
562
+ synced_at: nullableIso(row.synced_at),
563
+ created_at: toIsoString(row.created_at),
564
+ updated_at: toIsoString(row.updated_at),
565
+ metadata: parseJsonObject2(row.metadata)
566
+ };
567
+ }
568
+ function linkFromRow2(row) {
569
+ return {
570
+ ...row,
571
+ active: Boolean(row.active),
572
+ expires_at: nullableIso(row.expires_at),
573
+ synced_at: nullableIso(row.synced_at),
574
+ created_at: toIsoString(row.created_at),
575
+ updated_at: toIsoString(row.updated_at),
576
+ metadata: parseJsonObject2(row.metadata),
577
+ short_url: formatShortUrl(row.hostname, row.slug)
578
+ };
579
+ }
580
+ function validateDestinationUrl2(url) {
581
+ let parsed;
582
+ try {
583
+ parsed = new URL(url);
584
+ } catch {
585
+ throw new Error(`Invalid destination URL: ${url}`);
586
+ }
587
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
588
+ throw new Error("Destination URL must start with http:// or https://.");
589
+ }
590
+ return parsed.toString();
591
+ }
592
+ function isoOrNull2(input) {
593
+ if (!input)
594
+ return null;
595
+ const date = new Date(input);
596
+ if (Number.isNaN(date.getTime()))
597
+ throw new Error(`Invalid date: ${input}`);
598
+ return date.toISOString();
599
+ }
600
+ function clickFromRow2(row) {
601
+ return {
602
+ ...row,
603
+ clicked_at: toIsoString(row.clicked_at),
604
+ synced_at: nullableIso(row.synced_at),
605
+ created_at: toIsoString(row.created_at),
606
+ updated_at: toIsoString(row.updated_at),
607
+ metadata: parseJsonObject2(row.metadata)
608
+ };
609
+ }
610
+
611
+ class PgShortlinksStore {
612
+ pg;
613
+ constructor(pg) {
614
+ this.pg = pg;
615
+ }
616
+ static async fromConnectionString(connectionString) {
617
+ const { PgAdapterAsync } = await import("@hasna/cloud");
618
+ return new PgShortlinksStore(new PgAdapterAsync(connectionString));
619
+ }
620
+ static async fromCloud(service = "shortlinks") {
621
+ const { getConnectionString } = await import("@hasna/cloud");
622
+ return PgShortlinksStore.fromConnectionString(getConnectionString(service));
623
+ }
624
+ async close() {
625
+ await this.pg.close?.();
626
+ }
627
+ async addDomain(input) {
628
+ const hostname2 = normalizeHostname(input.hostname);
629
+ const timestamp = now();
630
+ const machineId = getMachineId();
631
+ const existing = await this.getDomain(hostname2);
632
+ const id = existing?.id || makeId("dom");
633
+ if (input.defaultDomain) {
634
+ await this.pg.run("UPDATE domains SET default_domain = 0, updated_at = ? WHERE default_domain = 1", timestamp);
635
+ }
636
+ await this.pg.run(`
637
+ INSERT INTO domains (
638
+ id, hostname, provider, default_domain, cloudflare_zone_id, cloudflare_account_id,
639
+ cloudflare_worker_name, origin_url, notes, metadata, machine_id, synced_at, created_at, updated_at
640
+ )
641
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
642
+ ON CONFLICT(hostname) DO UPDATE SET
643
+ provider = excluded.provider,
644
+ default_domain = excluded.default_domain,
645
+ cloudflare_zone_id = COALESCE(excluded.cloudflare_zone_id, domains.cloudflare_zone_id),
646
+ cloudflare_account_id = COALESCE(excluded.cloudflare_account_id, domains.cloudflare_account_id),
647
+ cloudflare_worker_name = COALESCE(excluded.cloudflare_worker_name, domains.cloudflare_worker_name),
648
+ origin_url = COALESCE(excluded.origin_url, domains.origin_url),
649
+ notes = COALESCE(excluded.notes, domains.notes),
650
+ metadata = excluded.metadata,
651
+ machine_id = excluded.machine_id,
652
+ synced_at = NULL,
653
+ updated_at = excluded.updated_at
654
+ `, 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);
655
+ return await this.getDomain(hostname2);
656
+ }
657
+ async listDomains() {
658
+ const rows = await this.pg.all(`
659
+ SELECT * FROM domains
660
+ ORDER BY default_domain DESC, hostname ASC
661
+ `);
662
+ return rows.map(domainFromRow2);
663
+ }
664
+ async getDomain(hostnameOrId) {
665
+ const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
666
+ const row = await this.pg.get(`
667
+ SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
668
+ `, normalized, hostnameOrId);
669
+ return row ? domainFromRow2(row) : null;
670
+ }
671
+ async getDefaultDomain() {
672
+ const row = await this.pg.get(`
673
+ SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
674
+ `);
675
+ return row ? domainFromRow2(row) : null;
676
+ }
677
+ async createLink(input) {
678
+ const domain = input.domain ? await this.getDomain(input.domain) : await this.getDefaultDomain();
679
+ if (!domain) {
680
+ throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
681
+ }
682
+ const destinationUrl = validateDestinationUrl2(input.destinationUrl);
683
+ const timestamp = now();
684
+ const machineId = getMachineId();
685
+ const expiresAt = isoOrNull2(input.expiresAt);
686
+ const slug = input.slug ? normalizeSlug(input.slug) : await this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
687
+ try {
688
+ await this.pg.run(`
689
+ INSERT INTO links (
690
+ id, domain_id, slug, destination_url, title, active, expires_at, metadata,
691
+ machine_id, synced_at, created_at, updated_at
692
+ )
693
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
694
+ `, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
695
+ } catch (error) {
696
+ const message = error instanceof Error ? error.message : String(error);
697
+ if (message.includes("unique") || message.includes("duplicate")) {
698
+ throw new Error(`Slug already exists for ${domain.hostname}: ${slug}`);
699
+ }
700
+ throw error;
701
+ }
702
+ return await this.getLink(domain.hostname, slug);
703
+ }
704
+ async listLinks(options = {}) {
705
+ const params = [];
706
+ let where = "WHERE 1 = 1";
707
+ if (options.domain) {
708
+ where += " AND d.hostname = ?";
709
+ params.push(normalizeHostname(options.domain));
710
+ }
711
+ if (options.activeOnly)
712
+ where += " AND l.active = 1";
713
+ params.push(options.limit || 100);
714
+ const rows = await this.pg.all(`
715
+ SELECT l.*, d.hostname
716
+ FROM links l
717
+ JOIN domains d ON d.id = l.domain_id
718
+ ${where}
719
+ ORDER BY l.created_at DESC
720
+ LIMIT ?
721
+ `, ...params);
722
+ return rows.map(linkFromRow2);
723
+ }
724
+ async getLink(domainOrSlug, maybeSlug) {
725
+ const slug = normalizeSlug(maybeSlug || domainOrSlug);
726
+ const params = [slug];
727
+ let domainClause = "";
728
+ if (maybeSlug) {
729
+ domainClause = "AND d.hostname = ?";
730
+ params.push(normalizeHostname(domainOrSlug));
731
+ }
732
+ const row = await this.pg.get(`
733
+ SELECT l.*, d.hostname
734
+ FROM links l
735
+ JOIN domains d ON d.id = l.domain_id
736
+ WHERE l.slug = ? ${domainClause}
737
+ ORDER BY d.default_domain DESC, l.created_at ASC
738
+ LIMIT 1
739
+ `, ...params);
740
+ return row ? linkFromRow2(row) : null;
741
+ }
742
+ async totalStats() {
743
+ const row = await this.pg.get(`
744
+ SELECT
745
+ (SELECT COUNT(*)::int FROM domains) AS domains,
746
+ (SELECT COUNT(*)::int FROM links) AS links,
747
+ (SELECT COUNT(*)::int FROM clicks) AS clicks
748
+ `);
749
+ return row;
750
+ }
751
+ async resolve(hostname2, slug) {
752
+ const normalizedHost = normalizeHostname(hostname2);
753
+ const normalizedSlug = normalizeSlug(slug);
754
+ const row = await this.pg.get(`
755
+ SELECT l.*, d.hostname
756
+ FROM links l
757
+ JOIN domains d ON d.id = l.domain_id
758
+ WHERE d.hostname = ? AND l.slug = ?
759
+ LIMIT 1
760
+ `, normalizedHost, normalizedSlug);
761
+ if (row)
762
+ return linkFromRow2(row);
763
+ const fallback = await this.pg.get(`
764
+ SELECT l.*, d.hostname
765
+ FROM links l
766
+ JOIN domains d ON d.id = l.domain_id
767
+ WHERE d.default_domain = 1 AND l.slug = ?
768
+ ORDER BY d.created_at ASC
769
+ LIMIT 1
770
+ `, normalizedSlug);
771
+ return fallback ? linkFromRow2(fallback) : null;
772
+ }
773
+ async setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
774
+ const active = typeof maybeSlugOrActive === "boolean" ? maybeSlugOrActive : Boolean(maybeActive);
775
+ const link = typeof maybeSlugOrActive === "boolean" ? await this.getLink(domainOrSlug) : await this.getLink(domainOrSlug, maybeSlugOrActive);
776
+ if (!link)
777
+ throw new Error("Link not found.");
778
+ const timestamp = now();
779
+ await this.pg.run(`
780
+ UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
781
+ `, active ? 1 : 0, timestamp, link.id);
782
+ return await this.getLink(link.hostname, link.slug);
783
+ }
784
+ async deleteLink(domainOrSlug, maybeSlug) {
785
+ const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
786
+ if (!link)
787
+ throw new Error("Link not found.");
788
+ await this.pg.run("DELETE FROM links WHERE id = ?", link.id);
789
+ return link;
790
+ }
791
+ async recordClick(link, input = {}) {
792
+ const timestamp = now();
793
+ const machineId = getMachineId();
794
+ const ipHash = input.ip ? this.hashIp(input.ip) : null;
795
+ const id = makeId("clk");
796
+ await this.pg.run(`
797
+ INSERT INTO clicks (
798
+ id, link_id, domain_id, slug, clicked_at, ip_hash, user_agent, referer,
799
+ country, city, metadata, machine_id, synced_at, created_at, updated_at
800
+ )
801
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
802
+ `, 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);
803
+ const row = await this.pg.get("SELECT * FROM clicks WHERE id = ?", id);
804
+ return clickFromRow2(row);
805
+ }
806
+ async getStats(domainOrSlug, maybeSlug) {
807
+ const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
808
+ if (!link)
809
+ throw new Error("Link not found.");
810
+ const summary = await this.pg.get(`
811
+ SELECT COUNT(*)::int AS clicks, MAX(clicked_at) AS last_clicked_at FROM clicks WHERE link_id = ?
812
+ `, link.id);
813
+ const topReferrers = await this.pg.all(`
814
+ SELECT referer, COUNT(*)::int AS clicks
815
+ FROM clicks
816
+ WHERE link_id = ?
817
+ GROUP BY referer
818
+ ORDER BY clicks DESC
819
+ LIMIT 10
820
+ `, link.id);
821
+ const topUserAgents = await this.pg.all(`
822
+ SELECT user_agent, COUNT(*)::int AS clicks
823
+ FROM clicks
824
+ WHERE link_id = ?
825
+ GROUP BY user_agent
826
+ ORDER BY clicks DESC
827
+ LIMIT 10
828
+ `, link.id);
829
+ return {
830
+ link,
831
+ clicks: summary.clicks,
832
+ last_clicked_at: nullableIso(summary.last_clicked_at),
833
+ top_referrers: topReferrers,
834
+ top_user_agents: topUserAgents
835
+ };
836
+ }
837
+ hashIp(ip) {
838
+ const salt = process.env.SHORTLINKS_CLICK_SALT || "shortlinks-cloud";
839
+ return createHash2("sha256").update(`${salt}:${ip}`).digest("hex");
840
+ }
841
+ async generateAvailableSlug(domainId, length) {
842
+ for (let attempt = 0;attempt < 32; attempt += 1) {
843
+ const slug = randomToken(length);
844
+ const exists = await this.pg.get(`
845
+ SELECT 1 FROM links WHERE domain_id = ? AND slug = ? LIMIT 1
846
+ `, domainId, slug);
847
+ if (!exists)
848
+ return slug;
849
+ }
850
+ throw new Error("Could not generate an unused slug after 32 attempts.");
851
+ }
852
+ }
853
+ // src/server.ts
854
+ function json(data, status = 200) {
855
+ return new Response(JSON.stringify(data, null, 2), {
856
+ status,
857
+ headers: { "content-type": "application/json; charset=utf-8" }
858
+ });
859
+ }
860
+ function getHost(request, fallback) {
861
+ const forwarded = request.headers.get("x-forwarded-host");
862
+ const host = forwarded || request.headers.get("host") || fallback || "";
863
+ return host.split(",")[0].trim().split(":")[0];
864
+ }
865
+ function getClientIp(request) {
866
+ const forwarded = request.headers.get("x-forwarded-for");
867
+ if (forwarded)
868
+ return forwarded.split(",")[0].trim();
869
+ return request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip");
870
+ }
871
+ function isExpired(link) {
872
+ return Boolean(link.expires_at && new Date(link.expires_at).getTime() <= Date.now());
873
+ }
874
+ function createShortlinksHandler(options = {}) {
875
+ const store = options.store || new ShortlinksStore(options.dbPath);
876
+ const redirectStatus = options.redirectStatus || 302;
877
+ return async (request) => {
878
+ const url = new URL(request.url);
879
+ if (url.pathname === "/healthz") {
880
+ return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
881
+ }
882
+ if (url.pathname === "/" || url.pathname === "") {
883
+ return json({ service: "shortlinks", ok: true });
884
+ }
885
+ let slug = "";
886
+ try {
887
+ slug = decodeURIComponent(url.pathname.replace(/^\/+/, "").split("/")[0] || "");
888
+ } catch {
889
+ return json({ error: "Invalid slug." }, 400);
890
+ }
891
+ if (!slug)
892
+ return json({ error: "Missing slug." }, 404);
893
+ const host = getHost(request, options.defaultHost);
894
+ if (!host)
895
+ return json({ error: "Missing Host header." }, 400);
896
+ let link = null;
897
+ try {
898
+ link = await store.resolve(host, slug);
899
+ } catch {
900
+ return json({ error: "Shortlink not found.", slug, host }, 404);
901
+ }
902
+ if (!link)
903
+ return json({ error: "Shortlink not found.", slug, host }, 404);
904
+ if (!link.active)
905
+ return json({ error: "Shortlink is disabled.", slug, host }, 410);
906
+ if (isExpired(link))
907
+ return json({ error: "Shortlink is expired.", slug, host }, 410);
908
+ await store.recordClick(link, {
909
+ ip: getClientIp(request),
910
+ userAgent: request.headers.get("user-agent"),
911
+ referer: request.headers.get("referer"),
912
+ country: request.headers.get("cf-ipcountry"),
913
+ metadata: {
914
+ path: url.pathname,
915
+ query: url.search
916
+ }
917
+ });
918
+ return Response.redirect(link.destination_url, redirectStatus);
919
+ };
920
+ }
921
+ function serveShortlinks(options = {}) {
922
+ const host = options.host || "127.0.0.1";
923
+ const port = options.port || 8787;
924
+ const fetch2 = createShortlinksHandler(options);
925
+ return Bun.serve({ hostname: host, port, fetch: fetch2 });
926
+ }
927
+ // src/cloudflare.ts
928
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
929
+ import { join as join3 } from "path";
930
+ function createCloudflarePlan(input) {
931
+ const hostname2 = normalizeHostname(input.hostname);
932
+ const target = normalizeHostname(input.target);
933
+ const workerName = input.workerName || "shortlinks";
934
+ const proxied = input.proxied ?? true;
935
+ return {
936
+ hostname: hostname2,
937
+ target,
938
+ proxied,
939
+ workerName,
940
+ origin: input.origin,
941
+ dnsRecord: {
942
+ type: "CNAME",
943
+ name: hostname2,
944
+ content: target,
945
+ proxied
946
+ },
947
+ wranglerCommand: `wrangler deploy cloudflare/${workerName}.js --name ${workerName}`
948
+ };
949
+ }
950
+ function generateWorkerScript() {
951
+ return `export default {
952
+ async fetch(request, env) {
953
+ const origin = env.SHORTLINKS_ORIGIN;
954
+ if (!origin) {
955
+ return new Response("SHORTLINKS_ORIGIN is not configured", { status: 500 });
956
+ }
957
+
958
+ const incoming = new URL(request.url);
959
+ const upstream = new URL(incoming.pathname + incoming.search, origin);
960
+ const headers = new Headers(request.headers);
961
+ headers.set("x-forwarded-host", incoming.host);
962
+ headers.set("x-shortlinks-worker", "cloudflare");
963
+
964
+ return fetch(upstream.toString(), {
965
+ method: request.method,
966
+ headers,
967
+ body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
968
+ redirect: "manual"
969
+ });
970
+ }
971
+ };
972
+ `;
973
+ }
974
+ function writeWorkerFiles(options = {}) {
975
+ const outDir = options.outDir || "cloudflare";
976
+ const workerName = options.workerName || "shortlinks";
977
+ mkdirSync3(outDir, { recursive: true });
978
+ const workerPath = join3(outDir, `${workerName}.js`);
979
+ const wranglerPath = join3(outDir, "wrangler.example.toml");
980
+ writeFileSync3(workerPath, generateWorkerScript());
981
+ writeFileSync3(wranglerPath, `name = "${workerName}"
982
+ main = "${workerName}.js"
983
+ compatibility_date = "2026-05-01"
984
+
985
+ [vars]
986
+ SHORTLINKS_ORIGIN = "${options.origin || "https://shortlinks.example.com"}"
987
+ `);
988
+ return { workerPath, wranglerPath };
989
+ }
990
+ function cloudflareAuthHeaders(token) {
991
+ const apiToken = token || process.env.CLOUDFLARE_API_TOKEN;
992
+ if (apiToken)
993
+ return { authorization: `Bearer ${apiToken}` };
994
+ const apiKey = process.env.CLOUDFLARE_API_KEY;
995
+ const email = process.env.CLOUDFLARE_EMAIL;
996
+ if (apiKey && email) {
997
+ return {
998
+ "x-auth-key": apiKey,
999
+ "x-auth-email": email
1000
+ };
1001
+ }
1002
+ throw new Error("Cloudflare auth is required: set CLOUDFLARE_API_TOKEN, or CLOUDFLARE_API_KEY plus CLOUDFLARE_EMAIL.");
1003
+ }
1004
+ async function cloudflareRequest(token, path, init = {}) {
1005
+ const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
1006
+ ...init,
1007
+ headers: {
1008
+ ...cloudflareAuthHeaders(token),
1009
+ "content-type": "application/json",
1010
+ ...init.headers || {}
1011
+ }
1012
+ });
1013
+ const body = await response.json();
1014
+ if (!response.ok || body.success === false) {
1015
+ const message = body.errors?.map((e) => e.message).join("; ") || response.statusText;
1016
+ throw new Error(`Cloudflare API failed: ${message}`);
1017
+ }
1018
+ return body.result;
1019
+ }
1020
+ function candidateZones(hostname2) {
1021
+ const parts = normalizeHostname(hostname2).split(".");
1022
+ const candidates = [];
1023
+ for (let i = 0;i < parts.length - 1; i += 1) {
1024
+ candidates.push(parts.slice(i).join("."));
1025
+ }
1026
+ return candidates;
1027
+ }
1028
+ async function findCloudflareZoneId(hostname2, token) {
1029
+ for (const zone of candidateZones(hostname2)) {
1030
+ const result = await cloudflareRequest(token, `/zones?name=${encodeURIComponent(zone)}`);
1031
+ if (result[0]?.id)
1032
+ return result[0].id;
1033
+ }
1034
+ throw new Error(`Could not find a Cloudflare zone for ${hostname2}. Pass --zone-id explicitly.`);
1035
+ }
1036
+ async function upsertCloudflareDnsRecord(options) {
1037
+ const token = options.token || process.env.CLOUDFLARE_API_TOKEN;
1038
+ const plan = createCloudflarePlan({
1039
+ hostname: options.hostname,
1040
+ target: options.target,
1041
+ origin: process.env.SHORTLINKS_ORIGIN || "https://shortlinks.example.com",
1042
+ proxied: options.proxied
1043
+ });
1044
+ if (options.dryRun)
1045
+ return plan;
1046
+ const zoneId = options.zoneId || await findCloudflareZoneId(plan.hostname, token);
1047
+ const existing = await cloudflareRequest(token, `/zones/${zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(plan.hostname)}`);
1048
+ const payload = JSON.stringify(plan.dnsRecord);
1049
+ if (existing[0]?.id) {
1050
+ const updated = await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${existing[0].id}`, {
1051
+ method: "PUT",
1052
+ body: payload
1053
+ });
1054
+ return { id: updated.id, action: "updated" };
1055
+ }
1056
+ const created = await cloudflareRequest(token, `/zones/${zoneId}/dns_records`, {
1057
+ method: "POST",
1058
+ body: payload
1059
+ });
1060
+ return { id: created.id, action: "created" };
1061
+ }
1062
+ // src/local.ts
1063
+ import { spawnSync } from "child_process";
1064
+ import { homedir as homedir2 } from "os";
1065
+ import { join as join4 } from "path";
1066
+ function createLocalSetupPlan(input) {
1067
+ const domain = normalizeHostname(input.domain);
1068
+ const targetHost = input.targetHost || "127.0.0.1";
1069
+ const port = input.port || 8787;
1070
+ const certDir = input.certDir || join4(homedir2(), ".hasna", "machines", "certs");
1071
+ const certPath = join4(certDir, `${domain}.pem`);
1072
+ const keyPath = join4(certDir, `${domain}-key.pem`);
1073
+ return {
1074
+ domain,
1075
+ targetHost,
1076
+ port,
1077
+ hostsEntry: `${targetHost} ${domain}`,
1078
+ caddySnippet: `${domain} {
1079
+ reverse_proxy ${targetHost}:${port}
1080
+ tls ${certPath} ${keyPath}
1081
+ }`,
1082
+ certPath,
1083
+ keyPath,
1084
+ machinesCommand: `machines dns add --domain ${domain} --target-host ${targetHost} --port ${port} --json`,
1085
+ sudoRequired: true
1086
+ };
1087
+ }
1088
+ function registerMachinesDns(input) {
1089
+ const plan = createLocalSetupPlan(input);
1090
+ const args = [
1091
+ "dns",
1092
+ "add",
1093
+ "--domain",
1094
+ plan.domain,
1095
+ "--target-host",
1096
+ plan.targetHost,
1097
+ "--port",
1098
+ String(plan.port),
1099
+ "--json"
1100
+ ];
1101
+ const result = spawnSync("machines", args, { encoding: "utf-8" });
1102
+ return {
1103
+ command: `machines ${args.join(" ")}`,
1104
+ status: result.status,
1105
+ stdout: result.stdout || "",
1106
+ stderr: result.stderr || ""
1107
+ };
1108
+ }
1109
+ // src/pg-migrations.ts
1110
+ var PG_MIGRATIONS = [
1111
+ `
1112
+ CREATE TABLE IF NOT EXISTS domains (
1113
+ id TEXT PRIMARY KEY,
1114
+ hostname TEXT NOT NULL UNIQUE,
1115
+ provider TEXT NOT NULL DEFAULT 'manual',
1116
+ default_domain INTEGER NOT NULL DEFAULT 0,
1117
+ cloudflare_zone_id TEXT,
1118
+ cloudflare_account_id TEXT,
1119
+ cloudflare_worker_name TEXT,
1120
+ origin_url TEXT,
1121
+ notes TEXT,
1122
+ metadata TEXT NOT NULL DEFAULT '{}',
1123
+ machine_id TEXT,
1124
+ synced_at TIMESTAMPTZ,
1125
+ created_at TIMESTAMPTZ NOT NULL,
1126
+ updated_at TIMESTAMPTZ NOT NULL
1127
+ );
1128
+
1129
+ CREATE TABLE IF NOT EXISTS links (
1130
+ id TEXT PRIMARY KEY,
1131
+ domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
1132
+ slug TEXT NOT NULL,
1133
+ destination_url TEXT NOT NULL,
1134
+ title TEXT,
1135
+ active INTEGER NOT NULL DEFAULT 1,
1136
+ expires_at TIMESTAMPTZ,
1137
+ metadata TEXT NOT NULL DEFAULT '{}',
1138
+ machine_id TEXT,
1139
+ synced_at TIMESTAMPTZ,
1140
+ created_at TIMESTAMPTZ NOT NULL,
1141
+ updated_at TIMESTAMPTZ NOT NULL,
1142
+ UNIQUE(domain_id, slug)
1143
+ );
1144
+
1145
+ CREATE TABLE IF NOT EXISTS clicks (
1146
+ id TEXT PRIMARY KEY,
1147
+ link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
1148
+ domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
1149
+ slug TEXT NOT NULL,
1150
+ clicked_at TIMESTAMPTZ NOT NULL,
1151
+ ip_hash TEXT,
1152
+ user_agent TEXT,
1153
+ referer TEXT,
1154
+ country TEXT,
1155
+ city TEXT,
1156
+ metadata TEXT NOT NULL DEFAULT '{}',
1157
+ machine_id TEXT,
1158
+ synced_at TIMESTAMPTZ,
1159
+ created_at TIMESTAMPTZ NOT NULL,
1160
+ updated_at TIMESTAMPTZ NOT NULL
1161
+ );
1162
+
1163
+ CREATE INDEX IF NOT EXISTS idx_domains_hostname ON domains(hostname);
1164
+ CREATE INDEX IF NOT EXISTS idx_domains_default ON domains(default_domain);
1165
+ CREATE INDEX IF NOT EXISTS idx_links_domain_slug ON links(domain_id, slug);
1166
+ CREATE INDEX IF NOT EXISTS idx_links_active ON links(active);
1167
+ CREATE INDEX IF NOT EXISTS idx_links_updated ON links(updated_at);
1168
+ CREATE INDEX IF NOT EXISTS idx_clicks_link ON clicks(link_id);
1169
+ CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
1170
+ CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
1171
+ CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
1172
+ `
1173
+ ];
1174
+ export {
1175
+ writeWorkerFiles,
1176
+ upsertCloudflareDnsRecord,
1177
+ serveShortlinks,
1178
+ saveConfig,
1179
+ registerMachinesDns,
1180
+ randomToken,
1181
+ now,
1182
+ normalizeSlug,
1183
+ normalizeHostname,
1184
+ makeId,
1185
+ loadConfig,
1186
+ getDatabasePath,
1187
+ getDataDir,
1188
+ getConfigPath,
1189
+ generateWorkerScript,
1190
+ formatShortUrl,
1191
+ createShortlinksHandler,
1192
+ createLocalSetupPlan,
1193
+ createCloudflarePlan,
1194
+ ShortlinksStore,
1195
+ ShortlinksDatabase,
1196
+ SQLITE_MIGRATIONS,
1197
+ PgShortlinksStore,
1198
+ PG_MIGRATIONS
1199
+ };