@hasna/shortlinks 0.1.17 → 0.1.19

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.
@@ -9,15 +9,13 @@ export declare class PgShortlinksStore {
9
9
  private readonly pg;
10
10
  constructor(pg: PgAdapterLike);
11
11
  static fromConnectionString(connectionString: string): Promise<PgShortlinksStore>;
12
- static fromStorage(service?: string): Promise<PgShortlinksStore>;
13
- static fromCloud: typeof PgShortlinksStore.fromStorage;
12
+ static fromCloud(service?: string): Promise<PgShortlinksStore>;
14
13
  close(): Promise<void>;
15
14
  addDomain(input: AddDomainInput): Promise<Domain>;
16
15
  listDomains(): Promise<Domain[]>;
17
16
  getDomain(hostnameOrId: string): Promise<Domain | null>;
18
17
  getDefaultDomain(): Promise<Domain | null>;
19
18
  createLink(input: CreateLinkInput): Promise<Link>;
20
- consumeLinkUse(link: Link): Promise<Link | null>;
21
19
  listLinks(options?: {
22
20
  domain?: string;
23
21
  activeOnly?: boolean;
package/dist/server.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AddDomainInput, ClickInput, CreateLinkInput, Domain, Link, LinkStats } from "./types.js";
1
+ import type { ClickInput, Link } from "./types.js";
2
2
  export interface ShortlinksRuntimeStore {
3
3
  totalStats(): {
4
4
  domains: number;
@@ -11,29 +11,12 @@ export interface ShortlinksRuntimeStore {
11
11
  }>;
12
12
  resolve(hostname: string, slug: string): Link | null | Promise<Link | null>;
13
13
  recordClick(link: Link, input?: ClickInput): unknown | Promise<unknown>;
14
- consumeLinkUse?(link: Link): Link | null | Promise<Link | null>;
15
- createLink?(input: CreateLinkInput): Link | Promise<Link>;
16
- listLinks?(input?: {
17
- domain?: string;
18
- activeOnly?: boolean;
19
- limit?: number;
20
- }): Link[] | Promise<Link[]>;
21
- getLink?(hostnameOrSlug: string, slug?: string): Link | null | Promise<Link | null>;
22
- setLinkActive?(hostnameOrSlug: string, slugOrActive: string | boolean, maybeActive?: boolean): Link | Promise<Link>;
23
- deleteLink?(hostnameOrSlug: string, slug?: string): Link | Promise<Link>;
24
- getStats?(hostnameOrSlug: string, slug?: string): LinkStats | Promise<LinkStats>;
25
- addDomain?(input: AddDomainInput): Domain | Promise<Domain>;
26
- listDomains?(): Domain[] | Promise<Domain[]>;
27
- getDomain?(hostname: string): Domain | null | Promise<Domain | null>;
28
14
  }
29
15
  export interface ShortlinksHandlerOptions {
30
16
  store?: ShortlinksRuntimeStore;
31
17
  dbPath?: string;
32
18
  defaultHost?: string;
33
19
  redirectStatus?: 301 | 302 | 307 | 308;
34
- reservedPathPrefixes?: string[];
35
- apiPathPrefix?: string;
36
- apiToken?: string | null;
37
20
  }
38
21
  export declare function createShortlinksHandler(options?: ShortlinksHandlerOptions): (request: Request) => Response | Promise<Response>;
39
22
  export declare function serveShortlinks(options?: ShortlinksHandlerOptions & {
package/dist/server.js CHANGED
@@ -49,16 +49,11 @@ function saveConfig(config) {
49
49
  `);
50
50
  }
51
51
  function updateConfig(patch) {
52
- const current = loadConfig();
53
52
  const next = {
54
- ...current,
53
+ ...loadConfig(),
55
54
  ...patch,
56
- api: {
57
- ...current.api,
58
- ...patch.api
59
- },
60
55
  cloudflare: {
61
- ...current.cloudflare,
56
+ ...loadConfig().cloudflare,
62
57
  ...patch.cloudflare
63
58
  }
64
59
  };
@@ -161,10 +156,6 @@ var SQLITE_MIGRATIONS = [
161
156
  CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
162
157
  CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
163
158
  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;
168
159
  `
169
160
  ];
170
161
 
@@ -271,8 +262,6 @@ function linkFromRow(row) {
271
262
  return {
272
263
  ...row,
273
264
  active: Boolean(row.active),
274
- max_uses: row.max_uses ?? null,
275
- used_count: row.used_count ?? 0,
276
265
  metadata: parseJsonObject(row.metadata),
277
266
  short_url: formatShortUrl(row.hostname, row.slug, publicBaseUrl)
278
267
  };
@@ -303,13 +292,6 @@ function isoOrNull(input) {
303
292
  throw new Error(`Invalid date: ${input}`);
304
293
  return date.toISOString();
305
294
  }
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
- }
313
295
 
314
296
  class ShortlinksStore {
315
297
  database;
@@ -385,16 +367,15 @@ class ShortlinksStore {
385
367
  const timestamp = now();
386
368
  const machineId = getMachineId();
387
369
  const expiresAt = isoOrNull(input.expiresAt);
388
- const maxUses = normalizeMaxUses(input.maxUses);
389
370
  const slug = input.slug ? normalizeSlug(input.slug) : this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
390
371
  try {
391
372
  this.database.db.query(`
392
373
  INSERT INTO links (
393
- id, domain_id, slug, destination_url, title, active, expires_at, max_uses, used_count, metadata,
374
+ id, domain_id, slug, destination_url, title, active, expires_at, metadata,
394
375
  machine_id, synced_at, created_at, updated_at
395
376
  )
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);
377
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
378
+ `).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
398
379
  } catch (error) {
399
380
  const message = error instanceof Error ? error.message : String(error);
400
381
  if (message.includes("UNIQUE")) {
@@ -404,19 +385,6 @@ class ShortlinksStore {
404
385
  }
405
386
  return this.getLink(domain.hostname, slug);
406
387
  }
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
- }
420
388
  listLinks(options = {}) {
421
389
  const params = [];
422
390
  let where = "WHERE 1 = 1";
@@ -564,127 +532,15 @@ class ShortlinksStore {
564
532
  }
565
533
 
566
534
  // src/server.ts
567
- function json(data, status = 200) {
535
+ var REDIRECT_ALLOW_HEADER = "GET, HEAD";
536
+ function json(data, status = 200, headers) {
537
+ const responseHeaders = new Headers(headers);
538
+ responseHeaders.set("content-type", "application/json; charset=utf-8");
568
539
  return new Response(JSON.stringify(data, null, 2), {
569
540
  status,
570
- headers: { "content-type": "application/json; charset=utf-8" }
541
+ headers: responseHeaders
571
542
  });
572
543
  }
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
- }
688
544
  function getHost(request, fallback) {
689
545
  const forwarded = request.headers.get("x-forwarded-host");
690
546
  const host = forwarded || request.headers.get("host") || fallback || "";
@@ -702,14 +558,8 @@ function isExpired(link) {
702
558
  function createShortlinksHandler(options = {}) {
703
559
  const store = options.store || new ShortlinksStore(options.dbPath);
704
560
  const redirectStatus = options.redirectStatus || 302;
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";
707
561
  return async (request) => {
708
562
  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
- }
713
563
  if (url.pathname === "/healthz") {
714
564
  return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
715
565
  }
@@ -724,8 +574,8 @@ function createShortlinksHandler(options = {}) {
724
574
  }
725
575
  if (!slug)
726
576
  return json({ error: "Missing slug." }, 404);
727
- if (reservedPathPrefixes.has(slug.toLowerCase())) {
728
- return json({ error: "Reserved path prefix.", slug }, 404);
577
+ if (request.method !== "GET" && request.method !== "HEAD") {
578
+ return json({ error: "Method not allowed." }, 405, { allow: REDIRECT_ALLOW_HEADER });
729
579
  }
730
580
  const host = getHost(request, options.defaultHost);
731
581
  if (!host)
@@ -742,29 +592,18 @@ function createShortlinksHandler(options = {}) {
742
592
  return json({ error: "Shortlink is disabled.", slug, host }, 410);
743
593
  if (isExpired(link))
744
594
  return json({ error: "Shortlink is expired.", slug, host }, 410);
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
- if (request.method.toUpperCase() === "HEAD") {
749
- return new Response(null, {
750
- status: redirectStatus,
751
- headers: { location: link.destination_url }
595
+ if (request.method === "GET") {
596
+ await store.recordClick(link, {
597
+ ip: getClientIp(request),
598
+ userAgent: request.headers.get("user-agent"),
599
+ referer: request.headers.get("referer"),
600
+ country: request.headers.get("cf-ipcountry"),
601
+ metadata: {
602
+ path: url.pathname,
603
+ query: url.search
604
+ }
752
605
  });
753
606
  }
754
- const consumed = store.consumeLinkUse ? await store.consumeLinkUse(link) : link;
755
- if (!consumed) {
756
- return json({ error: "Shortlink max uses reached.", slug, host }, 410);
757
- }
758
- await store.recordClick(consumed, {
759
- ip: getClientIp(request),
760
- userAgent: request.headers.get("user-agent"),
761
- referer: request.headers.get("referer"),
762
- country: request.headers.get("cf-ipcountry"),
763
- metadata: {
764
- path: url.pathname,
765
- query: url.search
766
- }
767
- });
768
607
  return Response.redirect(link.destination_url, redirectStatus);
769
608
  };
770
609
  }
package/dist/store.d.ts CHANGED
@@ -9,7 +9,6 @@ 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;
13
12
  listLinks(options?: {
14
13
  domain?: string;
15
14
  activeOnly?: boolean;
package/dist/types.d.ts CHANGED
@@ -23,8 +23,6 @@ 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;
28
26
  metadata: Record<string, unknown>;
29
27
  machine_id: string | null;
30
28
  synced_at: string | null;
@@ -68,7 +66,6 @@ export interface CreateLinkInput {
68
66
  slug?: string;
69
67
  title?: string;
70
68
  expiresAt?: string;
71
- maxUses?: number | null;
72
69
  metadata?: Record<string, unknown>;
73
70
  slugLength?: number;
74
71
  }
@@ -4,15 +4,9 @@ set -euo pipefail
4
4
  export AWS_REGION="${AWS_REGION:-us-east-1}"
5
5
  export SHORTLINKS_HOME="/var/lib/shortlinks"
6
6
  export SHORTLINKS_PACKAGE="@hasna/shortlinks@latest"
7
- export RDS_SECRET_ID="${RDS_SECRET_ID:-}"
8
- export RDS_HOST="${RDS_HOST:-}"
9
- export RDS_USERNAME="${RDS_USERNAME:-}"
10
- export SHORTLINKS_DOMAIN="${SHORTLINKS_DOMAIN:-}"
11
- export ATTACHMENTS_ORIGIN="${ATTACHMENTS_ORIGIN:-}"
12
- export SHORTLINKS_API_PATH_PREFIX="${SHORTLINKS_API_PATH_PREFIX:-/_shortlinks/api}"
13
-
14
- : "${RDS_SECRET_ID:?Set RDS_SECRET_ID to the AWS Secrets Manager secret for the PostgreSQL database_url}"
15
- : "${SHORTLINKS_DOMAIN:?Set SHORTLINKS_DOMAIN to the public host served by Caddy}"
7
+ export RDS_SECRET_ID="rds!db-7a451ce6-83a9-40fa-b24a-81e5d5943511"
8
+ export RDS_HOST="hasnaxyz-prod-opensource.c4limg0qgqvk.us-east-1.rds.amazonaws.com"
9
+ export RDS_USERNAME="hasna_admin"
16
10
 
17
11
  dnf update -y
18
12
  dnf install -y awscli jq tar gzip shadow-utils libcap
@@ -21,29 +15,28 @@ if ! id shortlinks >/dev/null 2>&1; then
21
15
  useradd --system --create-home --home-dir "${SHORTLINKS_HOME}" --shell /sbin/nologin shortlinks
22
16
  fi
23
17
 
24
- install -d -o shortlinks -g shortlinks "${SHORTLINKS_HOME}/.hasna/shortlinks/storage"
18
+ install -d -o shortlinks -g shortlinks "${SHORTLINKS_HOME}/.hasna/cloud"
25
19
  install -d -o shortlinks -g shortlinks "${SHORTLINKS_HOME}/.hasna/shortlinks"
26
20
 
27
- if [ -n "${RDS_HOST}" ] && [ -n "${RDS_USERNAME}" ]; then
28
- cat > "${SHORTLINKS_HOME}/.hasna/shortlinks/storage/config.json" <<CLOUD_CONFIG
21
+ cat > "${SHORTLINKS_HOME}/.hasna/cloud/config.json" <<CLOUD_CONFIG
29
22
  {
30
23
  "rds": {
31
24
  "host": "${RDS_HOST}",
32
25
  "port": 5432,
33
26
  "username": "${RDS_USERNAME}",
34
- "password_env": "SHORTLINKS_CLOUD_DATABASE_PASSWORD",
27
+ "password_env": "HASNA_RDS_PASSWORD",
35
28
  "ssl": true
36
29
  },
37
30
  "mode": "hybrid",
31
+ "feedback_endpoint": "https://feedback.hasna.com/api/v1/feedback",
38
32
  "auto_sync_interval_minutes": 0,
39
33
  "sync": {
40
34
  "schedule_minutes": 0
41
35
  }
42
36
  }
43
37
  CLOUD_CONFIG
44
- chown shortlinks:shortlinks "${SHORTLINKS_HOME}/.hasna/shortlinks/storage/config.json"
45
- chmod 600 "${SHORTLINKS_HOME}/.hasna/shortlinks/storage/config.json"
46
- fi
38
+ chown shortlinks:shortlinks "${SHORTLINKS_HOME}/.hasna/cloud/config.json"
39
+ chmod 600 "${SHORTLINKS_HOME}/.hasna/cloud/config.json"
47
40
 
48
41
  su -s /bin/bash shortlinks -c 'curl -fsSL https://bun.sh/install | bash'
49
42
  su -s /bin/bash shortlinks -c "${SHORTLINKS_HOME}/.bun/bin/bun install -g ${SHORTLINKS_PACKAGE} --no-cache"
@@ -57,22 +50,14 @@ export HOME="/var/lib/shortlinks"
57
50
  export PATH="/var/lib/shortlinks/.bun/bin:/usr/local/bin:/usr/bin:/bin"
58
51
  export NODE_TLS_REJECT_UNAUTHORIZED="0"
59
52
 
60
- : "${RDS_SECRET_ID:?Set RDS_SECRET_ID to the AWS Secrets Manager secret for the PostgreSQL database_url}"
61
-
62
53
  secret_json="$(aws secretsmanager get-secret-value \
63
54
  --region "${AWS_REGION}" \
64
- --secret-id "${RDS_SECRET_ID}" \
55
+ --secret-id "rds!db-7a451ce6-83a9-40fa-b24a-81e5d5943511" \
65
56
  --query SecretString \
66
57
  --output text)"
67
58
 
68
- export HASNA_SHORTLINKS_DATABASE_URL
69
- HASNA_SHORTLINKS_DATABASE_URL="$(jq -r '.database_url // empty' <<<"${secret_json}")"
70
- if [ -n "${HASNA_SHORTLINKS_DATABASE_URL}" ]; then
71
- export SHORTLINKS_DATABASE_URL="${HASNA_SHORTLINKS_DATABASE_URL}"
72
- else
73
- export SHORTLINKS_CLOUD_DATABASE_PASSWORD
74
- SHORTLINKS_CLOUD_DATABASE_PASSWORD="$(jq -r '.password // empty' <<<"${secret_json}")"
75
- fi
59
+ export HASNA_RDS_PASSWORD
60
+ HASNA_RDS_PASSWORD="$(jq -r '.password' <<<"${secret_json}")"
76
61
 
77
62
  exec "$@"
78
63
  RUNNER
@@ -81,16 +66,6 @@ chown root:shortlinks /usr/local/bin/shortlinks-env-exec
81
66
 
82
67
  su -s /bin/bash shortlinks -c 'PATH=/var/lib/shortlinks/.bun/bin:$PATH shortlinks --version'
83
68
 
84
- cat > /etc/default/shortlinks <<SHORTLINKS_ENV
85
- AWS_REGION=${AWS_REGION}
86
- RDS_SECRET_ID=${RDS_SECRET_ID}
87
- SHORTLINKS_DOMAIN=${SHORTLINKS_DOMAIN}
88
- SHORTLINKS_STORE=remote
89
- SHORTLINKS_API_PATH_PREFIX=${SHORTLINKS_API_PATH_PREFIX}
90
- SHORTLINKS_ENV
91
- chmod 640 /etc/default/shortlinks
92
- chown root:shortlinks /etc/default/shortlinks
93
-
94
69
  caddy_version="$(curl -fsSL https://api.github.com/repos/caddyserver/caddy/releases/latest | jq -r '.tag_name // "v2.10.2"' | sed 's/^v//')"
95
70
  case "$(uname -m)" in
96
71
  aarch64|arm64) caddy_arch="arm64" ;;
@@ -115,8 +90,8 @@ Group=shortlinks
115
90
  WorkingDirectory=/var/lib/shortlinks
116
91
  Environment=HOME=/var/lib/shortlinks
117
92
  Environment=PATH=/var/lib/shortlinks/.bun/bin:/usr/local/bin:/usr/bin:/bin
118
- EnvironmentFile=/etc/default/shortlinks
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}
93
+ Environment=SHORTLINKS_STORE=cloud
94
+ ExecStart=/usr/local/bin/shortlinks-env-exec shortlinks serve --cloud --host 127.0.0.1 --port 8787 --default-host has.na
120
95
  Restart=always
121
96
  RestartSec=5
122
97
 
@@ -148,36 +123,12 @@ WantedBy=multi-user.target
148
123
  SERVICE
149
124
 
150
125
  install -d /etc/caddy
151
- if [ -n "${ATTACHMENTS_ORIGIN}" ]; then
152
- cat > /etc/caddy/Caddyfile <<CADDY
153
- ${SHORTLINKS_DOMAIN} {
154
- encode zstd gzip
155
-
156
- handle /a/* {
157
- reverse_proxy ${ATTACHMENTS_ORIGIN}
158
- }
159
-
160
- handle /api/* {
161
- reverse_proxy ${ATTACHMENTS_ORIGIN}
162
- }
163
-
164
- handle /_shortlinks/* {
165
- reverse_proxy 127.0.0.1:8787
166
- }
167
-
168
- handle {
169
- reverse_proxy 127.0.0.1:8787
170
- }
171
- }
172
- CADDY
173
- else
174
- cat > /etc/caddy/Caddyfile <<CADDY
175
- ${SHORTLINKS_DOMAIN} {
126
+ cat > /etc/caddy/Caddyfile <<'CADDY'
127
+ has.na {
176
128
  encode zstd gzip
177
129
  reverse_proxy 127.0.0.1:8787
178
130
  }
179
131
  CADDY
180
- fi
181
132
 
182
133
  systemctl daemon-reload
183
134
  systemctl enable shortlinks.service caddy.service
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hasna/shortlinks",
3
- "version": "0.1.17",
4
- "description": "CLI-only shortlink manager for custom domains, click tracking, Cloudflare setup, and repo-native storage sync",
3
+ "version": "0.1.19",
4
+ "description": "CLI-only shortlink manager for custom domains, click tracking, Cloudflare setup, and @hasna cloud sync",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -20,10 +20,6 @@
20
20
  "./cloudflare": {
21
21
  "types": "./dist/cloudflare.d.ts",
22
22
  "import": "./dist/cloudflare.js"
23
- },
24
- "./storage": {
25
- "types": "./dist/storage.d.ts",
26
- "import": "./dist/storage.js"
27
23
  }
28
24
  },
29
25
  "files": [
@@ -35,7 +31,7 @@
35
31
  "SECURITY.md"
36
32
  ],
37
33
  "scripts": {
38
- "build": "rm -rf dist && bun build src/cli/index.ts --outdir dist/cli --target bun && bun build src/index.ts src/storage.ts --outdir dist --target bun && bun build src/server.ts --outdir dist --target bun && bun build src/cloudflare.ts --outdir dist --target bun && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
34
+ "build": "rm -rf dist && bun build src/cli/index.ts --outdir dist/cli --target bun --external @hasna/cloud && bun build src/index.ts --outdir dist --target bun --external @hasna/cloud && bun build src/server.ts --outdir dist --target bun --external @hasna/cloud && bun build src/cloudflare.ts --outdir dist --target bun && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
39
35
  "typecheck": "tsc --noEmit",
40
36
  "test": "bun test",
41
37
  "dev:cli": "bun run src/cli/index.ts",
@@ -70,14 +66,13 @@
70
66
  "author": "Andrei Hasna <andrei@hasna.com>",
71
67
  "license": "Apache-2.0",
72
68
  "dependencies": {
69
+ "@hasna/cloud": "0.1.30",
70
+ "@hasna/events": "^0.1.6",
73
71
  "chalk": "^5.4.1",
74
- "commander": "^13.1.0",
75
- "pg": "^8.13.3",
76
- "@hasna/events": "^0.1.6"
72
+ "commander": "^13.1.0"
77
73
  },
78
74
  "devDependencies": {
79
75
  "@types/bun": "^1.2.4",
80
- "@types/pg": "^8.11.11",
81
76
  "typescript": "^5.7.3"
82
77
  }
83
78
  }
@@ -1,30 +0,0 @@
1
- import type { AddDomainInput, CreateLinkInput, Domain, Link, LinkStats } from "./types.js";
2
- export interface ShortlinksApiClientOptions {
3
- baseUrl?: string;
4
- token?: string;
5
- }
6
- export declare class ShortlinksApiClient {
7
- private readonly options;
8
- constructor(options?: ShortlinksApiClientOptions);
9
- private url;
10
- private headers;
11
- totalStats(): Promise<{
12
- domains: number;
13
- links: number;
14
- clicks: number;
15
- }>;
16
- createLink(input: CreateLinkInput): Promise<Link>;
17
- listLinks(options?: {
18
- domain?: string;
19
- activeOnly?: boolean;
20
- limit?: number;
21
- }): Promise<Link[]>;
22
- getLink(domainOrSlug: string, maybeSlug?: string): Promise<Link | null>;
23
- setLinkActive(domainOrSlug: string, maybeSlugOrActive: string | boolean, maybeActive?: boolean): Promise<Link>;
24
- deleteLink(domainOrSlug: string, maybeSlug?: string): Promise<Link>;
25
- getStats(domainOrSlug: string, maybeSlug?: string): Promise<LinkStats>;
26
- addDomain(input: AddDomainInput): Promise<Domain>;
27
- listDomains(): Promise<Domain[]>;
28
- getDomain(hostname: string): Promise<Domain | null>;
29
- close(): Promise<void>;
30
- }
@@ -1,7 +0,0 @@
1
- export interface PgMigrationResult {
2
- applied: number[];
3
- alreadyApplied: number[];
4
- errors: string[];
5
- totalMigrations: number;
6
- }
7
- export declare function applyPgMigrations(connectionString: string): Promise<PgMigrationResult>;
@@ -1,11 +0,0 @@
1
- export declare class PgAdapterAsync {
2
- private readonly pool;
3
- constructor(connectionString: string);
4
- run(sql: string, ...params: unknown[]): Promise<{
5
- changes: number;
6
- }>;
7
- get(sql: string, ...params: unknown[]): Promise<unknown>;
8
- all(sql: string, ...params: unknown[]): Promise<unknown[]>;
9
- exec(sql: string): Promise<void>;
10
- close(): Promise<void>;
11
- }
@@ -1,37 +0,0 @@
1
- export type StorageMode = "local" | "remote" | "hybrid";
2
- export interface StorageConfig {
3
- mode: StorageMode;
4
- rds: {
5
- host: string;
6
- port: number;
7
- username: string;
8
- password_env: string;
9
- ssl: boolean;
10
- };
11
- }
12
- export interface StorageEnv {
13
- name: string;
14
- }
15
- export declare const SHORTLINKS_STORAGE_ENV = "HASNA_SHORTLINKS_DATABASE_URL";
16
- export declare const SHORTLINKS_STORAGE_FALLBACK_ENV = "SHORTLINKS_DATABASE_URL";
17
- export declare const SHORTLINKS_STORAGE_MODE_ENV = "HASNA_SHORTLINKS_STORAGE_MODE";
18
- export declare const SHORTLINKS_STORAGE_MODE_FALLBACK_ENV = "SHORTLINKS_STORAGE_MODE";
19
- export declare const STORAGE_DATABASE_ENV: readonly ["HASNA_SHORTLINKS_DATABASE_URL", "SHORTLINKS_DATABASE_URL"];
20
- export declare const STORAGE_MODE_ENV: readonly ["HASNA_SHORTLINKS_STORAGE_MODE", "SHORTLINKS_STORAGE_MODE"];
21
- export declare const CANONICAL_SHORTLINKS_RDS_CLUSTER = "postgres-compatible-database";
22
- export declare const CANONICAL_SHORTLINKS_RDS_DATABASE = "shortlinks";
23
- export declare const CANONICAL_SHORTLINKS_RDS_SECRET_PATH = "configured-by-environment";
24
- export interface CanonicalShortlinksRdsConfig {
25
- cluster: typeof CANONICAL_SHORTLINKS_RDS_CLUSTER;
26
- database: typeof CANONICAL_SHORTLINKS_RDS_DATABASE;
27
- runtimeSecretPath: typeof CANONICAL_SHORTLINKS_RDS_SECRET_PATH;
28
- primaryEnv: typeof SHORTLINKS_STORAGE_ENV;
29
- fallbackEnv: typeof SHORTLINKS_STORAGE_FALLBACK_ENV;
30
- }
31
- export declare function getCanonicalShortlinksRdsConfig(): CanonicalShortlinksRdsConfig;
32
- export declare function getStorageDatabaseUrl(): string | undefined;
33
- export declare function getStorageDatabaseEnvName(): (typeof STORAGE_DATABASE_ENV)[number] | null;
34
- export declare function getStorageDatabaseEnv(): StorageEnv | null;
35
- export declare function getStorageConfig(): StorageConfig;
36
- export declare function getStorageConnectionString(dbName?: string): string;
37
- export declare const getConnectionString: typeof getStorageConnectionString;