@hasna/shortlinks 0.1.14 → 0.1.16

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/server.js CHANGED
@@ -49,11 +49,16 @@ function saveConfig(config) {
49
49
  `);
50
50
  }
51
51
  function updateConfig(patch) {
52
+ const current = loadConfig();
52
53
  const next = {
53
- ...loadConfig(),
54
+ ...current,
54
55
  ...patch,
56
+ api: {
57
+ ...current.api,
58
+ ...patch.api
59
+ },
55
60
  cloudflare: {
56
- ...loadConfig().cloudflare,
61
+ ...current.cloudflare,
57
62
  ...patch.cloudflare
58
63
  }
59
64
  };
@@ -156,6 +161,10 @@ var SQLITE_MIGRATIONS = [
156
161
  CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
157
162
  CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
158
163
  CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
164
+ `,
165
+ `
166
+ ALTER TABLE links ADD COLUMN max_uses INTEGER;
167
+ ALTER TABLE links ADD COLUMN used_count INTEGER NOT NULL DEFAULT 0;
159
168
  `
160
169
  ];
161
170
 
@@ -262,6 +271,8 @@ function linkFromRow(row) {
262
271
  return {
263
272
  ...row,
264
273
  active: Boolean(row.active),
274
+ max_uses: row.max_uses ?? null,
275
+ used_count: row.used_count ?? 0,
265
276
  metadata: parseJsonObject(row.metadata),
266
277
  short_url: formatShortUrl(row.hostname, row.slug, publicBaseUrl)
267
278
  };
@@ -292,6 +303,13 @@ function isoOrNull(input) {
292
303
  throw new Error(`Invalid date: ${input}`);
293
304
  return date.toISOString();
294
305
  }
306
+ function normalizeMaxUses(value) {
307
+ if (value === null || value === undefined)
308
+ return null;
309
+ if (!Number.isInteger(value) || value <= 0)
310
+ throw new Error("maxUses must be a positive integer.");
311
+ return value;
312
+ }
295
313
 
296
314
  class ShortlinksStore {
297
315
  database;
@@ -367,15 +385,16 @@ class ShortlinksStore {
367
385
  const timestamp = now();
368
386
  const machineId = getMachineId();
369
387
  const expiresAt = isoOrNull(input.expiresAt);
388
+ const maxUses = normalizeMaxUses(input.maxUses);
370
389
  const slug = input.slug ? normalizeSlug(input.slug) : this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
371
390
  try {
372
391
  this.database.db.query(`
373
392
  INSERT INTO links (
374
- id, domain_id, slug, destination_url, title, active, expires_at, metadata,
393
+ id, domain_id, slug, destination_url, title, active, expires_at, max_uses, used_count, metadata,
375
394
  machine_id, synced_at, created_at, updated_at
376
395
  )
377
- VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
378
- `).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
396
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, 0, ?, ?, NULL, ?, ?)
397
+ `).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, maxUses, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
379
398
  } catch (error) {
380
399
  const message = error instanceof Error ? error.message : String(error);
381
400
  if (message.includes("UNIQUE")) {
@@ -385,6 +404,19 @@ class ShortlinksStore {
385
404
  }
386
405
  return this.getLink(domain.hostname, slug);
387
406
  }
407
+ consumeLinkUse(link) {
408
+ const timestamp = now();
409
+ const result = this.database.db.query(`
410
+ UPDATE links
411
+ SET used_count = used_count + 1, updated_at = ?, synced_at = NULL
412
+ WHERE id = ?
413
+ AND active = 1
414
+ AND (max_uses IS NULL OR used_count < max_uses)
415
+ `).run(timestamp, link.id);
416
+ if (result.changes === 0)
417
+ return null;
418
+ return this.getLink(link.hostname, link.slug);
419
+ }
388
420
  listLinks(options = {}) {
389
421
  const params = [];
390
422
  let where = "WHERE 1 = 1";
@@ -538,6 +570,121 @@ function json(data, status = 200) {
538
570
  headers: { "content-type": "application/json; charset=utf-8" }
539
571
  });
540
572
  }
573
+ function apiToken(options) {
574
+ return options.apiToken || process.env.SHORTLINKS_API_TOKEN || process.env.HASNA_SHORTLINKS_API_TOKEN || null;
575
+ }
576
+ function requestToken(request) {
577
+ const auth = request.headers.get("authorization") || "";
578
+ const bearer = auth.toLowerCase().startsWith("bearer ") ? auth.slice(7).trim() : "";
579
+ return bearer || request.headers.get("x-shortlinks-token") || request.headers.get("x-api-key");
580
+ }
581
+ function isAuthorized(request, options) {
582
+ const token = apiToken(options);
583
+ if (!token)
584
+ return true;
585
+ return requestToken(request) === token;
586
+ }
587
+ async function readJsonBody(request) {
588
+ try {
589
+ const parsed = await request.json();
590
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
591
+ } catch {
592
+ return {};
593
+ }
594
+ }
595
+ function requireMethod(store, method) {
596
+ const fn = store[method];
597
+ if (typeof fn !== "function")
598
+ throw new Error(`Store does not support ${String(method)}.`);
599
+ return fn.bind(store);
600
+ }
601
+ async function handleApi(request, apiPath, store, options) {
602
+ if (apiPath === "/health") {
603
+ return json({ ok: true, service: "shortlinks", api_auth_required: Boolean(apiToken(options)), stats: await store.totalStats() });
604
+ }
605
+ if (!isAuthorized(request, options))
606
+ return json({ error: "Unauthorized" }, 401);
607
+ const url = new URL(request.url);
608
+ const segments = apiPath.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
609
+ try {
610
+ if (apiPath === "/links" && request.method === "GET") {
611
+ const listLinks = requireMethod(store, "listLinks");
612
+ return json(await listLinks({
613
+ domain: url.searchParams.get("domain") || undefined,
614
+ activeOnly: url.searchParams.get("active") === "true",
615
+ limit: Number(url.searchParams.get("limit") || "100")
616
+ }));
617
+ }
618
+ if (apiPath === "/links" && request.method === "POST") {
619
+ const body = await readJsonBody(request);
620
+ const destinationUrl = String(body.destination_url || body.url || "");
621
+ if (!destinationUrl)
622
+ return json({ error: "destination_url is required" }, 400);
623
+ const createLink = requireMethod(store, "createLink");
624
+ return json(await createLink({
625
+ destinationUrl,
626
+ domain: typeof body.domain === "string" ? body.domain : undefined,
627
+ slug: typeof body.slug === "string" ? body.slug : undefined,
628
+ title: typeof body.title === "string" ? body.title : undefined,
629
+ expiresAt: typeof body.expires_at === "string" ? body.expires_at : typeof body.expires === "string" ? body.expires : undefined,
630
+ maxUses: typeof body.max_uses === "number" ? body.max_uses : typeof body.maxUses === "number" ? body.maxUses : undefined,
631
+ slugLength: typeof body.length === "number" ? body.length : undefined
632
+ }), 201);
633
+ }
634
+ if (segments[0] === "links" && segments[1] && request.method === "GET") {
635
+ const getLink = requireMethod(store, "getLink");
636
+ const domain = url.searchParams.get("domain") || undefined;
637
+ const link = domain ? await getLink(domain, segments[1]) : await getLink(segments[1]);
638
+ return link ? json(link) : json({ error: "Link not found." }, 404);
639
+ }
640
+ if (segments[0] === "links" && segments[1] && request.method === "DELETE") {
641
+ const deleteLink = requireMethod(store, "deleteLink");
642
+ const domain = url.searchParams.get("domain") || undefined;
643
+ return json(domain ? await deleteLink(domain, segments[1]) : await deleteLink(segments[1]));
644
+ }
645
+ if (segments[0] === "links" && segments[1] && segments[2] === "active" && request.method === "POST") {
646
+ const body = await readJsonBody(request);
647
+ const setLinkActive = requireMethod(store, "setLinkActive");
648
+ const domain = url.searchParams.get("domain") || undefined;
649
+ const active = Boolean(body.active);
650
+ return json(domain ? await setLinkActive(domain, segments[1], active) : await setLinkActive(segments[1], active));
651
+ }
652
+ if (apiPath === "/stats" && request.method === "GET") {
653
+ return json(await store.totalStats());
654
+ }
655
+ if (segments[0] === "stats" && segments[1] && request.method === "GET") {
656
+ const getStats = requireMethod(store, "getStats");
657
+ const domain = url.searchParams.get("domain") || undefined;
658
+ return json(domain ? await getStats(domain, segments[1]) : await getStats(segments[1]));
659
+ }
660
+ if (apiPath === "/domains" && request.method === "GET") {
661
+ const listDomains = requireMethod(store, "listDomains");
662
+ return json(await listDomains());
663
+ }
664
+ if (apiPath === "/domains" && request.method === "POST") {
665
+ const body = await readJsonBody(request);
666
+ const hostname2 = String(body.hostname || "");
667
+ if (!hostname2)
668
+ return json({ error: "hostname is required" }, 400);
669
+ const addDomain = requireMethod(store, "addDomain");
670
+ return json(await addDomain({
671
+ hostname: hostname2,
672
+ provider: typeof body.provider === "string" ? body.provider : "manual",
673
+ defaultDomain: Boolean(body.default_domain || body.default),
674
+ originUrl: typeof body.origin_url === "string" ? body.origin_url : undefined,
675
+ notes: typeof body.notes === "string" ? body.notes : undefined
676
+ }), 201);
677
+ }
678
+ if (segments[0] === "domains" && segments[1] && request.method === "GET") {
679
+ const getDomain = requireMethod(store, "getDomain");
680
+ const domain = await getDomain(segments[1]);
681
+ return domain ? json(domain) : json({ error: "Domain not found." }, 404);
682
+ }
683
+ } catch (error) {
684
+ return json({ error: error instanceof Error ? error.message : String(error) }, 400);
685
+ }
686
+ return json({ error: "Not found." }, 404);
687
+ }
541
688
  function getHost(request, fallback) {
542
689
  const forwarded = request.headers.get("x-forwarded-host");
543
690
  const host = forwarded || request.headers.get("host") || fallback || "";
@@ -556,8 +703,13 @@ function createShortlinksHandler(options = {}) {
556
703
  const store = options.store || new ShortlinksStore(options.dbPath);
557
704
  const redirectStatus = options.redirectStatus || 302;
558
705
  const reservedPathPrefixes = new Set((options.reservedPathPrefixes ?? ["a"]).map((prefix) => prefix.toLowerCase()));
706
+ const apiPathPrefix = (options.apiPathPrefix || process.env.SHORTLINKS_API_PATH_PREFIX || "/api").replace(/\/+$/, "") || "/api";
559
707
  return async (request) => {
560
708
  const url = new URL(request.url);
709
+ if (url.pathname === apiPathPrefix || url.pathname.startsWith(`${apiPathPrefix}/`)) {
710
+ const apiPath = url.pathname.slice(apiPathPrefix.length) || "/";
711
+ return handleApi(request, apiPath, store, options);
712
+ }
561
713
  if (url.pathname === "/healthz") {
562
714
  return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
563
715
  }
@@ -590,7 +742,14 @@ function createShortlinksHandler(options = {}) {
590
742
  return json({ error: "Shortlink is disabled.", slug, host }, 410);
591
743
  if (isExpired(link))
592
744
  return json({ error: "Shortlink is expired.", slug, host }, 410);
593
- await store.recordClick(link, {
745
+ if (link.max_uses !== null && link.used_count >= link.max_uses) {
746
+ return json({ error: "Shortlink max uses reached.", slug, host }, 410);
747
+ }
748
+ const consumed = store.consumeLinkUse ? await store.consumeLinkUse(link) : link;
749
+ if (!consumed) {
750
+ return json({ error: "Shortlink max uses reached.", slug, host }, 410);
751
+ }
752
+ await store.recordClick(consumed, {
594
753
  ip: getClientIp(request),
595
754
  userAgent: request.headers.get("user-agent"),
596
755
  referer: request.headers.get("referer"),
package/dist/storage.js CHANGED
@@ -5139,17 +5139,31 @@ function saveConfig(config) {
5139
5139
  `);
5140
5140
  }
5141
5141
  function updateConfig(patch) {
5142
+ const current = loadConfig();
5142
5143
  const next = {
5143
- ...loadConfig(),
5144
+ ...current,
5144
5145
  ...patch,
5146
+ api: {
5147
+ ...current.api,
5148
+ ...patch.api
5149
+ },
5145
5150
  cloudflare: {
5146
- ...loadConfig().cloudflare,
5151
+ ...current.cloudflare,
5147
5152
  ...patch.cloudflare
5148
5153
  }
5149
5154
  };
5150
5155
  saveConfig(next);
5151
5156
  return next;
5152
5157
  }
5158
+ function getApiBaseUrl(config = loadConfig()) {
5159
+ const baseUrl = process.env.SHORTLINKS_API_URL || process.env.HASNA_SHORTLINKS_API_URL || config.api?.baseUrl || "";
5160
+ return baseUrl ? baseUrl.replace(/\/+$/, "") : null;
5161
+ }
5162
+ function getApiToken(config = loadConfig()) {
5163
+ const envName = config.api?.tokenEnv || "SHORTLINKS_API_TOKEN";
5164
+ const token = process.env[envName] || process.env.SHORTLINKS_API_TOKEN || process.env.HASNA_SHORTLINKS_API_TOKEN || config.api?.token || "";
5165
+ return token || null;
5166
+ }
5153
5167
  function normalizeHostname(input) {
5154
5168
  const raw = input.trim().toLowerCase();
5155
5169
  if (!raw)
@@ -5246,6 +5260,10 @@ var SQLITE_MIGRATIONS = [
5246
5260
  CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
5247
5261
  CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
5248
5262
  CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
5263
+ `,
5264
+ `
5265
+ ALTER TABLE links ADD COLUMN max_uses INTEGER;
5266
+ ALTER TABLE links ADD COLUMN used_count INTEGER NOT NULL DEFAULT 0;
5249
5267
  `
5250
5268
  ];
5251
5269
 
@@ -5351,6 +5369,10 @@ var PG_MIGRATIONS = [
5351
5369
  CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
5352
5370
  CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
5353
5371
  CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
5372
+ `,
5373
+ `
5374
+ ALTER TABLE links ADD COLUMN IF NOT EXISTS max_uses INTEGER;
5375
+ ALTER TABLE links ADD COLUMN IF NOT EXISTS used_count INTEGER NOT NULL DEFAULT 0;
5354
5376
  `
5355
5377
  ];
5356
5378
 
package/dist/store.d.ts CHANGED
@@ -9,6 +9,7 @@ export declare class ShortlinksStore {
9
9
  getDomain(hostnameOrId: string): Domain | null;
10
10
  getDefaultDomain(): Domain | null;
11
11
  createLink(input: CreateLinkInput): Link;
12
+ consumeLinkUse(link: Link): Link | null;
12
13
  listLinks(options?: {
13
14
  domain?: string;
14
15
  activeOnly?: boolean;
package/dist/types.d.ts CHANGED
@@ -23,6 +23,8 @@ export interface Link {
23
23
  title: string | null;
24
24
  active: boolean;
25
25
  expires_at: string | null;
26
+ max_uses: number | null;
27
+ used_count: number;
26
28
  metadata: Record<string, unknown>;
27
29
  machine_id: string | null;
28
30
  synced_at: string | null;
@@ -66,6 +68,7 @@ export interface CreateLinkInput {
66
68
  slug?: string;
67
69
  title?: string;
68
70
  expiresAt?: string;
71
+ maxUses?: number | null;
69
72
  metadata?: Record<string, unknown>;
70
73
  slugLength?: number;
71
74
  }
@@ -9,6 +9,7 @@ export RDS_HOST="${RDS_HOST:-}"
9
9
  export RDS_USERNAME="${RDS_USERNAME:-}"
10
10
  export SHORTLINKS_DOMAIN="${SHORTLINKS_DOMAIN:-}"
11
11
  export ATTACHMENTS_ORIGIN="${ATTACHMENTS_ORIGIN:-}"
12
+ export SHORTLINKS_API_PATH_PREFIX="${SHORTLINKS_API_PATH_PREFIX:-/_shortlinks/api}"
12
13
 
13
14
  : "${RDS_SECRET_ID:?Set RDS_SECRET_ID to the AWS Secrets Manager secret for the PostgreSQL database_url}"
14
15
  : "${SHORTLINKS_DOMAIN:?Set SHORTLINKS_DOMAIN to the public host served by Caddy}"
@@ -85,6 +86,7 @@ AWS_REGION=${AWS_REGION}
85
86
  RDS_SECRET_ID=${RDS_SECRET_ID}
86
87
  SHORTLINKS_DOMAIN=${SHORTLINKS_DOMAIN}
87
88
  SHORTLINKS_STORE=remote
89
+ SHORTLINKS_API_PATH_PREFIX=${SHORTLINKS_API_PATH_PREFIX}
88
90
  SHORTLINKS_ENV
89
91
  chmod 640 /etc/default/shortlinks
90
92
  chown root:shortlinks /etc/default/shortlinks
@@ -114,7 +116,7 @@ WorkingDirectory=/var/lib/shortlinks
114
116
  Environment=HOME=/var/lib/shortlinks
115
117
  Environment=PATH=/var/lib/shortlinks/.bun/bin:/usr/local/bin:/usr/bin:/bin
116
118
  EnvironmentFile=/etc/default/shortlinks
117
- ExecStart=/usr/local/bin/shortlinks-env-exec shortlinks serve --remote --host 127.0.0.1 --port 8787 --default-host ${SHORTLINKS_DOMAIN}
119
+ ExecStart=/usr/local/bin/shortlinks-env-exec shortlinks serve --remote --host 127.0.0.1 --port 8787 --default-host ${SHORTLINKS_DOMAIN} --api-path-prefix ${SHORTLINKS_API_PATH_PREFIX}
118
120
  Restart=always
119
121
  RestartSec=5
120
122
 
@@ -159,6 +161,10 @@ ${SHORTLINKS_DOMAIN} {
159
161
  reverse_proxy ${ATTACHMENTS_ORIGIN}
160
162
  }
161
163
 
164
+ handle /_shortlinks/* {
165
+ reverse_proxy 127.0.0.1:8787
166
+ }
167
+
162
168
  handle {
163
169
  reverse_proxy 127.0.0.1:8787
164
170
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/shortlinks",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "CLI-only shortlink manager for custom domains, click tracking, Cloudflare setup, and repo-native storage sync",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",