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