@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/local.d.ts DELETED
@@ -1,27 +0,0 @@
1
- export interface LocalSetupPlan {
2
- domain: string;
3
- targetHost: string;
4
- port: number;
5
- hostsEntry: string;
6
- caddySnippet: string;
7
- certPath: string;
8
- keyPath: string;
9
- machinesCommand: string;
10
- sudoRequired: boolean;
11
- }
12
- export declare function createLocalSetupPlan(input: {
13
- domain: string;
14
- port?: number;
15
- targetHost?: string;
16
- certDir?: string;
17
- }): LocalSetupPlan;
18
- export declare function registerMachinesDns(input: {
19
- domain: string;
20
- port?: number;
21
- targetHost?: string;
22
- }): {
23
- command: string;
24
- status: number | null;
25
- stdout: string;
26
- stderr: string;
27
- };
package/dist/machine.d.ts DELETED
@@ -1 +0,0 @@
1
- export declare function getMachineId(): string;
@@ -1 +0,0 @@
1
- export declare const PG_MIGRATIONS: string[];
package/dist/server.d.ts DELETED
@@ -1,12 +0,0 @@
1
- import { ShortlinksStore } from "./store.js";
2
- export interface ShortlinksHandlerOptions {
3
- store?: ShortlinksStore;
4
- dbPath?: string;
5
- defaultHost?: string;
6
- redirectStatus?: 301 | 302 | 307 | 308;
7
- }
8
- export declare function createShortlinksHandler(options?: ShortlinksHandlerOptions): (request: Request) => Response | Promise<Response>;
9
- export declare function serveShortlinks(options?: ShortlinksHandlerOptions & {
10
- host?: string;
11
- port?: number;
12
- }): Bun.Server<undefined>;
package/dist/server.js DELETED
@@ -1,611 +0,0 @@
1
- // @bun
2
- // src/store.ts
3
- import { createHash } from "crypto";
4
-
5
- // src/database.ts
6
- import { Database } from "bun:sqlite";
7
- import { mkdirSync as mkdirSync2 } from "fs";
8
- import { dirname as dirname2 } from "path";
9
-
10
- // src/config.ts
11
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
12
- import { homedir } from "os";
13
- import { dirname, join, resolve } from "path";
14
- var SERVICE_NAME = "shortlinks";
15
- var DEFAULT_DATA_DIR = join(homedir(), ".hasna", SERVICE_NAME);
16
- function getDataDir() {
17
- return resolve(process.env.SHORTLINKS_HOME || DEFAULT_DATA_DIR);
18
- }
19
- function ensureDataDir() {
20
- const dir = getDataDir();
21
- mkdirSync(dir, { recursive: true });
22
- return dir;
23
- }
24
- function getConfigPath() {
25
- return join(ensureDataDir(), "config.json");
26
- }
27
- function getDatabasePath(explicitPath) {
28
- if (explicitPath)
29
- return resolve(explicitPath);
30
- if (process.env.SHORTLINKS_DB)
31
- return resolve(process.env.SHORTLINKS_DB);
32
- return join(ensureDataDir(), `${SERVICE_NAME}.db`);
33
- }
34
- function loadConfig() {
35
- const path = getConfigPath();
36
- if (!existsSync(path))
37
- return {};
38
- try {
39
- const parsed = JSON.parse(readFileSync(path, "utf-8"));
40
- return parsed && typeof parsed === "object" ? parsed : {};
41
- } catch {
42
- return {};
43
- }
44
- }
45
- function saveConfig(config) {
46
- const path = getConfigPath();
47
- mkdirSync(dirname(path), { recursive: true });
48
- writeFileSync(path, `${JSON.stringify(config, null, 2)}
49
- `);
50
- }
51
- function updateConfig(patch) {
52
- const next = {
53
- ...loadConfig(),
54
- ...patch,
55
- cloudflare: {
56
- ...loadConfig().cloudflare,
57
- ...patch.cloudflare
58
- }
59
- };
60
- saveConfig(next);
61
- return next;
62
- }
63
- function normalizeHostname(input) {
64
- const raw = input.trim().toLowerCase();
65
- if (!raw)
66
- throw new Error("Domain is required.");
67
- const withProtocol = raw.includes("://") ? raw : `https://${raw}`;
68
- let hostname;
69
- try {
70
- hostname = new URL(withProtocol).hostname;
71
- } catch {
72
- throw new Error(`Invalid domain: ${input}`);
73
- }
74
- hostname = hostname.replace(/\.$/, "");
75
- if (!/^[a-z0-9.-]+$/.test(hostname) || hostname.includes("..")) {
76
- throw new Error(`Invalid domain: ${input}`);
77
- }
78
- return hostname;
79
- }
80
- function formatShortUrl(hostname, slug, publicBaseUrl) {
81
- if (publicBaseUrl) {
82
- const base = publicBaseUrl.endsWith("/") ? publicBaseUrl : `${publicBaseUrl}/`;
83
- return new URL(slug, base).toString();
84
- }
85
- return `https://${hostname}/${slug}`;
86
- }
87
-
88
- // src/database.ts
89
- function now() {
90
- return new Date().toISOString();
91
- }
92
- function makeId(prefix) {
93
- const bytes = crypto.getRandomValues(new Uint8Array(12));
94
- const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
95
- return `${prefix}_${hex}`;
96
- }
97
- var SQLITE_MIGRATIONS = [
98
- `
99
- CREATE TABLE IF NOT EXISTS domains (
100
- id TEXT PRIMARY KEY,
101
- hostname TEXT NOT NULL UNIQUE,
102
- provider TEXT NOT NULL DEFAULT 'manual',
103
- default_domain INTEGER NOT NULL DEFAULT 0,
104
- cloudflare_zone_id TEXT,
105
- cloudflare_account_id TEXT,
106
- cloudflare_worker_name TEXT,
107
- origin_url TEXT,
108
- notes TEXT,
109
- metadata TEXT NOT NULL DEFAULT '{}',
110
- machine_id TEXT,
111
- synced_at TEXT,
112
- created_at TEXT NOT NULL,
113
- updated_at TEXT NOT NULL
114
- );
115
-
116
- CREATE TABLE IF NOT EXISTS links (
117
- id TEXT PRIMARY KEY,
118
- domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
119
- slug TEXT NOT NULL,
120
- destination_url TEXT NOT NULL,
121
- title TEXT,
122
- active INTEGER NOT NULL DEFAULT 1,
123
- expires_at 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
- UNIQUE(domain_id, slug)
130
- );
131
-
132
- CREATE TABLE IF NOT EXISTS clicks (
133
- id TEXT PRIMARY KEY,
134
- link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
135
- domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
136
- slug TEXT NOT NULL,
137
- clicked_at TEXT NOT NULL,
138
- ip_hash TEXT,
139
- user_agent TEXT,
140
- referer TEXT,
141
- country TEXT,
142
- city TEXT,
143
- metadata TEXT NOT NULL DEFAULT '{}',
144
- machine_id TEXT,
145
- synced_at TEXT,
146
- created_at TEXT NOT NULL,
147
- updated_at TEXT NOT NULL
148
- );
149
-
150
- CREATE INDEX IF NOT EXISTS idx_domains_hostname ON domains(hostname);
151
- CREATE INDEX IF NOT EXISTS idx_domains_default ON domains(default_domain);
152
- CREATE INDEX IF NOT EXISTS idx_links_domain_slug ON links(domain_id, slug);
153
- CREATE INDEX IF NOT EXISTS idx_links_active ON links(active);
154
- CREATE INDEX IF NOT EXISTS idx_links_updated ON links(updated_at);
155
- CREATE INDEX IF NOT EXISTS idx_clicks_link ON clicks(link_id);
156
- CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
157
- CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
158
- CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
159
- `
160
- ];
161
-
162
- class ShortlinksDatabase {
163
- db;
164
- path;
165
- constructor(path) {
166
- this.path = getDatabasePath(path);
167
- mkdirSync2(dirname2(this.path), { recursive: true });
168
- this.db = new Database(this.path);
169
- this.db.exec("PRAGMA foreign_keys = ON;");
170
- this.applyMigrations();
171
- }
172
- close() {
173
- this.db.close();
174
- }
175
- applyMigrations() {
176
- this.db.exec(`
177
- CREATE TABLE IF NOT EXISTS _migrations (
178
- id INTEGER PRIMARY KEY,
179
- applied_at TEXT NOT NULL
180
- );
181
- `);
182
- for (let i = 0;i < SQLITE_MIGRATIONS.length; i += 1) {
183
- const id = i + 1;
184
- const applied = this.db.query("SELECT id FROM _migrations WHERE id = ?").get(id);
185
- if (applied)
186
- continue;
187
- const migration = SQLITE_MIGRATIONS[i];
188
- const apply = this.db.transaction(() => {
189
- this.db.exec(migration);
190
- this.db.query("INSERT INTO _migrations (id, applied_at) VALUES (?, ?)").run(id, now());
191
- });
192
- apply();
193
- }
194
- }
195
- }
196
-
197
- // src/machine.ts
198
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
199
- import { hostname } from "os";
200
- import { join as join2 } from "path";
201
-
202
- // src/slug.ts
203
- import { randomBytes } from "crypto";
204
- var SLUG_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
205
- var DEFAULT_SLUG_LENGTH = 7;
206
- function randomToken(length = DEFAULT_SLUG_LENGTH) {
207
- if (length < 1 || length > 128)
208
- throw new Error("Token length must be between 1 and 128.");
209
- const bytes = randomBytes(length);
210
- let out = "";
211
- for (let i = 0;i < length; i += 1) {
212
- out += SLUG_ALPHABET[bytes[i] % SLUG_ALPHABET.length];
213
- }
214
- return out;
215
- }
216
- function normalizeSlug(slug) {
217
- const normalized = slug.trim().replace(/^\/+/, "").replace(/\/+$/, "");
218
- if (!normalized)
219
- throw new Error("Slug is required.");
220
- if (!/^[A-Za-z0-9_-]{1,96}$/.test(normalized)) {
221
- throw new Error("Slug can only contain letters, numbers, underscores, and dashes.");
222
- }
223
- return normalized;
224
- }
225
-
226
- // src/machine.ts
227
- function getMachineId() {
228
- const path = join2(ensureDataDir(), "machine-id");
229
- if (existsSync2(path)) {
230
- const existing = readFileSync2(path, "utf-8").trim();
231
- if (existing)
232
- return existing;
233
- }
234
- const safeHost = hostname().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
235
- const id = `${safeHost || "machine"}-${randomToken(8).toLowerCase()}`;
236
- writeFileSync2(path, `${id}
237
- `);
238
- return id;
239
- }
240
-
241
- // src/store.ts
242
- function parseJsonObject(value) {
243
- if (!value)
244
- return {};
245
- try {
246
- const parsed = JSON.parse(value);
247
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
248
- } catch {
249
- return {};
250
- }
251
- }
252
- function domainFromRow(row) {
253
- return {
254
- ...row,
255
- default_domain: Boolean(row.default_domain),
256
- metadata: parseJsonObject(row.metadata)
257
- };
258
- }
259
- function linkFromRow(row) {
260
- const config = loadConfig();
261
- const publicBaseUrl = config.defaultDomain === row.hostname ? config.publicBaseUrl : undefined;
262
- return {
263
- ...row,
264
- active: Boolean(row.active),
265
- metadata: parseJsonObject(row.metadata),
266
- short_url: formatShortUrl(row.hostname, row.slug, publicBaseUrl)
267
- };
268
- }
269
- function clickFromRow(row) {
270
- return {
271
- ...row,
272
- metadata: parseJsonObject(row.metadata)
273
- };
274
- }
275
- function validateDestinationUrl(url) {
276
- let parsed;
277
- try {
278
- parsed = new URL(url);
279
- } catch {
280
- throw new Error(`Invalid destination URL: ${url}`);
281
- }
282
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
283
- throw new Error("Destination URL must start with http:// or https://.");
284
- }
285
- return parsed.toString();
286
- }
287
- function isoOrNull(input) {
288
- if (!input)
289
- return null;
290
- const date = new Date(input);
291
- if (Number.isNaN(date.getTime()))
292
- throw new Error(`Invalid date: ${input}`);
293
- return date.toISOString();
294
- }
295
-
296
- class ShortlinksStore {
297
- database;
298
- constructor(dbPath) {
299
- this.database = new ShortlinksDatabase(dbPath);
300
- }
301
- close() {
302
- this.database.close();
303
- }
304
- addDomain(input) {
305
- const hostname2 = normalizeHostname(input.hostname);
306
- const timestamp = now();
307
- const machineId = getMachineId();
308
- const existing = this.getDomain(hostname2);
309
- const id = existing?.id || makeId("dom");
310
- if (input.defaultDomain) {
311
- this.database.db.query("UPDATE domains SET default_domain = 0, updated_at = ?, synced_at = NULL").run(timestamp);
312
- updateConfig({ defaultDomain: hostname2, publicBaseUrl: `https://${hostname2}` });
313
- }
314
- this.database.db.query(`
315
- INSERT INTO domains (
316
- id, hostname, provider, default_domain, cloudflare_zone_id, cloudflare_account_id,
317
- cloudflare_worker_name, origin_url, notes, metadata, machine_id, synced_at, created_at, updated_at
318
- )
319
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
320
- ON CONFLICT(hostname) DO UPDATE SET
321
- provider = excluded.provider,
322
- default_domain = excluded.default_domain,
323
- cloudflare_zone_id = COALESCE(excluded.cloudflare_zone_id, domains.cloudflare_zone_id),
324
- cloudflare_account_id = COALESCE(excluded.cloudflare_account_id, domains.cloudflare_account_id),
325
- cloudflare_worker_name = COALESCE(excluded.cloudflare_worker_name, domains.cloudflare_worker_name),
326
- origin_url = COALESCE(excluded.origin_url, domains.origin_url),
327
- notes = COALESCE(excluded.notes, domains.notes),
328
- metadata = excluded.metadata,
329
- machine_id = excluded.machine_id,
330
- synced_at = NULL,
331
- updated_at = excluded.updated_at
332
- `).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);
333
- return this.getDomain(hostname2);
334
- }
335
- listDomains() {
336
- const rows = this.database.db.query(`
337
- SELECT * FROM domains
338
- ORDER BY default_domain DESC, hostname ASC
339
- `).all();
340
- return rows.map(domainFromRow);
341
- }
342
- getDomain(hostnameOrId) {
343
- const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
344
- const row = this.database.db.query(`
345
- SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
346
- `).get(normalized, hostnameOrId);
347
- return row ? domainFromRow(row) : null;
348
- }
349
- getDefaultDomain() {
350
- const config = loadConfig();
351
- if (config.defaultDomain) {
352
- const configured = this.getDomain(config.defaultDomain);
353
- if (configured)
354
- return configured;
355
- }
356
- const row = this.database.db.query(`
357
- SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
358
- `).get();
359
- return row ? domainFromRow(row) : null;
360
- }
361
- createLink(input) {
362
- const domain = input.domain ? this.getDomain(input.domain) : this.getDefaultDomain();
363
- if (!domain) {
364
- throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
365
- }
366
- const destinationUrl = validateDestinationUrl(input.destinationUrl);
367
- const timestamp = now();
368
- const machineId = getMachineId();
369
- const expiresAt = isoOrNull(input.expiresAt);
370
- const slug = input.slug ? normalizeSlug(input.slug) : this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
371
- try {
372
- this.database.db.query(`
373
- INSERT INTO links (
374
- id, domain_id, slug, destination_url, title, active, expires_at, metadata,
375
- machine_id, synced_at, created_at, updated_at
376
- )
377
- VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
378
- `).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
379
- } catch (error) {
380
- const message = error instanceof Error ? error.message : String(error);
381
- if (message.includes("UNIQUE")) {
382
- throw new Error(`Slug already exists for ${domain.hostname}: ${slug}`);
383
- }
384
- throw error;
385
- }
386
- return this.getLink(domain.hostname, slug);
387
- }
388
- listLinks(options = {}) {
389
- const params = [];
390
- let where = "WHERE 1 = 1";
391
- if (options.domain) {
392
- where += " AND d.hostname = ?";
393
- params.push(normalizeHostname(options.domain));
394
- }
395
- if (options.activeOnly) {
396
- where += " AND l.active = 1";
397
- }
398
- params.push(options.limit || 100);
399
- const rows = this.database.db.query(`
400
- SELECT l.*, d.hostname
401
- FROM links l
402
- JOIN domains d ON d.id = l.domain_id
403
- ${where}
404
- ORDER BY l.created_at DESC
405
- LIMIT ?
406
- `).all(...params);
407
- return rows.map(linkFromRow);
408
- }
409
- getLink(domainOrSlug, maybeSlug) {
410
- const slug = normalizeSlug(maybeSlug || domainOrSlug);
411
- const params = [slug];
412
- let domainClause = "";
413
- if (maybeSlug) {
414
- domainClause = "AND d.hostname = ?";
415
- params.push(normalizeHostname(domainOrSlug));
416
- }
417
- const row = this.database.db.query(`
418
- SELECT l.*, d.hostname
419
- FROM links l
420
- JOIN domains d ON d.id = l.domain_id
421
- WHERE l.slug = ? ${domainClause}
422
- ORDER BY d.default_domain DESC, l.created_at ASC
423
- LIMIT 1
424
- `).get(...params);
425
- return row ? linkFromRow(row) : null;
426
- }
427
- resolve(hostname2, slug) {
428
- const normalizedSlug = normalizeSlug(slug);
429
- const normalizedHost = normalizeHostname(hostname2);
430
- const row = this.database.db.query(`
431
- SELECT l.*, d.hostname
432
- FROM links l
433
- JOIN domains d ON d.id = l.domain_id
434
- WHERE d.hostname = ? AND l.slug = ?
435
- LIMIT 1
436
- `).get(normalizedHost, normalizedSlug);
437
- if (row)
438
- return linkFromRow(row);
439
- const fallback = this.getDefaultDomain();
440
- if (!fallback || fallback.hostname === normalizedHost)
441
- return null;
442
- return this.getLink(fallback.hostname, normalizedSlug);
443
- }
444
- setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
445
- const active = typeof maybeSlugOrActive === "boolean" ? maybeSlugOrActive : Boolean(maybeActive);
446
- const link = typeof maybeSlugOrActive === "boolean" ? this.getLink(domainOrSlug) : this.getLink(domainOrSlug, maybeSlugOrActive);
447
- if (!link)
448
- throw new Error("Link not found.");
449
- const timestamp = now();
450
- this.database.db.query(`
451
- UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
452
- `).run(active ? 1 : 0, timestamp, link.id);
453
- return this.getLink(link.hostname, link.slug);
454
- }
455
- deleteLink(domainOrSlug, maybeSlug) {
456
- const link = maybeSlug ? this.getLink(domainOrSlug, maybeSlug) : this.getLink(domainOrSlug);
457
- if (!link)
458
- throw new Error("Link not found.");
459
- this.database.db.query("DELETE FROM links WHERE id = ?").run(link.id);
460
- return link;
461
- }
462
- recordClick(link, input = {}) {
463
- const timestamp = now();
464
- const machineId = getMachineId();
465
- const ipHash = input.ip ? this.hashIp(input.ip) : null;
466
- const id = makeId("clk");
467
- this.database.db.query(`
468
- INSERT INTO clicks (
469
- id, link_id, domain_id, slug, clicked_at, ip_hash, user_agent, referer,
470
- country, city, metadata, machine_id, synced_at, created_at, updated_at
471
- )
472
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
473
- `).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);
474
- const row = this.database.db.query("SELECT * FROM clicks WHERE id = ?").get(id);
475
- return clickFromRow(row);
476
- }
477
- getStats(domainOrSlug, maybeSlug) {
478
- const link = maybeSlug ? this.getLink(domainOrSlug, maybeSlug) : this.getLink(domainOrSlug);
479
- if (!link)
480
- throw new Error("Link not found.");
481
- const summary = this.database.db.query(`
482
- SELECT COUNT(*) AS clicks, MAX(clicked_at) AS last_clicked_at FROM clicks WHERE link_id = ?
483
- `).get(link.id);
484
- const topReferrers = this.database.db.query(`
485
- SELECT referer, COUNT(*) AS clicks
486
- FROM clicks
487
- WHERE link_id = ?
488
- GROUP BY referer
489
- ORDER BY clicks DESC
490
- LIMIT 10
491
- `).all(link.id);
492
- const topUserAgents = this.database.db.query(`
493
- SELECT user_agent, COUNT(*) AS clicks
494
- FROM clicks
495
- WHERE link_id = ?
496
- GROUP BY user_agent
497
- ORDER BY clicks DESC
498
- LIMIT 10
499
- `).all(link.id);
500
- return {
501
- link,
502
- clicks: summary.clicks,
503
- last_clicked_at: summary.last_clicked_at,
504
- top_referrers: topReferrers,
505
- top_user_agents: topUserAgents
506
- };
507
- }
508
- totalStats() {
509
- const row = this.database.db.query(`
510
- SELECT
511
- (SELECT COUNT(*) FROM domains) AS domains,
512
- (SELECT COUNT(*) FROM links) AS links,
513
- (SELECT COUNT(*) FROM clicks) AS clicks
514
- `).get();
515
- return row;
516
- }
517
- generateAvailableSlug(domainId, length) {
518
- for (let attempt = 0;attempt < 32; attempt += 1) {
519
- const slug = randomToken(length);
520
- const exists = this.database.db.query(`
521
- SELECT 1 FROM links WHERE domain_id = ? AND slug = ? LIMIT 1
522
- `).get(domainId, slug);
523
- if (!exists)
524
- return slug;
525
- }
526
- throw new Error("Could not generate an unused slug after 32 attempts.");
527
- }
528
- hashIp(ip) {
529
- const salt = process.env.SHORTLINKS_CLICK_SALT || "shortlinks-local";
530
- return createHash("sha256").update(`${salt}:${ip}`).digest("hex");
531
- }
532
- }
533
-
534
- // src/server.ts
535
- function json(data, status = 200) {
536
- return new Response(JSON.stringify(data, null, 2), {
537
- status,
538
- headers: { "content-type": "application/json; charset=utf-8" }
539
- });
540
- }
541
- function getHost(request, fallback) {
542
- const forwarded = request.headers.get("x-forwarded-host");
543
- const host = forwarded || request.headers.get("host") || fallback || "";
544
- return host.split(",")[0].trim().split(":")[0];
545
- }
546
- function getClientIp(request) {
547
- const forwarded = request.headers.get("x-forwarded-for");
548
- if (forwarded)
549
- return forwarded.split(",")[0].trim();
550
- return request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip");
551
- }
552
- function isExpired(link) {
553
- return Boolean(link.expires_at && new Date(link.expires_at).getTime() <= Date.now());
554
- }
555
- function createShortlinksHandler(options = {}) {
556
- const store = options.store || new ShortlinksStore(options.dbPath);
557
- const redirectStatus = options.redirectStatus || 302;
558
- return async (request) => {
559
- const url = new URL(request.url);
560
- if (url.pathname === "/healthz") {
561
- return json({ ok: true, service: "shortlinks", stats: store.totalStats() });
562
- }
563
- if (url.pathname === "/" || url.pathname === "") {
564
- return json({ service: "shortlinks", ok: true });
565
- }
566
- let slug = "";
567
- try {
568
- slug = decodeURIComponent(url.pathname.replace(/^\/+/, "").split("/")[0] || "");
569
- } catch {
570
- return json({ error: "Invalid slug." }, 400);
571
- }
572
- if (!slug)
573
- return json({ error: "Missing slug." }, 404);
574
- const host = getHost(request, options.defaultHost);
575
- if (!host)
576
- return json({ error: "Missing Host header." }, 400);
577
- let link = null;
578
- try {
579
- link = store.resolve(host, slug);
580
- } catch {
581
- return json({ error: "Shortlink not found.", slug, host }, 404);
582
- }
583
- if (!link)
584
- return json({ error: "Shortlink not found.", slug, host }, 404);
585
- if (!link.active)
586
- return json({ error: "Shortlink is disabled.", slug, host }, 410);
587
- if (isExpired(link))
588
- return json({ error: "Shortlink is expired.", slug, host }, 410);
589
- store.recordClick(link, {
590
- ip: getClientIp(request),
591
- userAgent: request.headers.get("user-agent"),
592
- referer: request.headers.get("referer"),
593
- country: request.headers.get("cf-ipcountry"),
594
- metadata: {
595
- path: url.pathname,
596
- query: url.search
597
- }
598
- });
599
- return Response.redirect(link.destination_url, redirectStatus);
600
- };
601
- }
602
- function serveShortlinks(options = {}) {
603
- const host = options.host || "127.0.0.1";
604
- const port = options.port || 8787;
605
- const fetch = createShortlinksHandler(options);
606
- return Bun.serve({ hostname: host, port, fetch });
607
- }
608
- export {
609
- serveShortlinks,
610
- createShortlinksHandler
611
- };
package/dist/slug.d.ts DELETED
@@ -1,4 +0,0 @@
1
- export declare const SLUG_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
2
- export declare const DEFAULT_SLUG_LENGTH = 7;
3
- export declare function randomToken(length?: number): string;
4
- export declare function normalizeSlug(slug: string): string;
package/dist/store.d.ts DELETED
@@ -1,30 +0,0 @@
1
- import { ShortlinksDatabase } from "./database.js";
2
- import type { AddDomainInput, Click, ClickInput, CreateLinkInput, Domain, Link, LinkStats } from "./types.js";
3
- export declare class ShortlinksStore {
4
- readonly database: ShortlinksDatabase;
5
- constructor(dbPath?: string);
6
- close(): void;
7
- addDomain(input: AddDomainInput): Domain;
8
- listDomains(): Domain[];
9
- getDomain(hostnameOrId: string): Domain | null;
10
- getDefaultDomain(): Domain | null;
11
- createLink(input: CreateLinkInput): Link;
12
- listLinks(options?: {
13
- domain?: string;
14
- activeOnly?: boolean;
15
- limit?: number;
16
- }): Link[];
17
- getLink(domainOrSlug: string, maybeSlug?: string): Link | null;
18
- resolve(hostname: string, slug: string): Link | null;
19
- setLinkActive(domainOrSlug: string, maybeSlugOrActive: string | boolean, maybeActive?: boolean): Link;
20
- deleteLink(domainOrSlug: string, maybeSlug?: string): Link;
21
- recordClick(link: Link, input?: ClickInput): Click;
22
- getStats(domainOrSlug: string, maybeSlug?: string): LinkStats;
23
- totalStats(): {
24
- domains: number;
25
- links: number;
26
- clicks: number;
27
- };
28
- private generateAvailableSlug;
29
- private hashIp;
30
- }