@hasna/shortlinks 0.1.18 → 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,231 +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" }
571
- });
572
- }
573
- function htmlEscape(value) {
574
- return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
575
- }
576
- function publicErrorPage(input) {
577
- const detail = input.detail ? `<p class="detail">${htmlEscape(input.detail)}</p>` : "";
578
- const slug = input.slug ? `<p class="slug">${htmlEscape(input.slug)}</p>` : "";
579
- const body = `<!doctype html>
580
- <html lang="en">
581
- <head>
582
- <meta charset="utf-8">
583
- <meta name="viewport" content="width=device-width, initial-scale=1">
584
- <title>${htmlEscape(input.title)} - Shortlink</title>
585
- <style>
586
- :root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
587
- body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #f5f7f8; color: #172026; }
588
- main { width: min(92vw, 520px); border: 1px solid #d7dee3; border-radius: 8px; background: #fff; padding: 28px; box-shadow: 0 18px 48px rgb(23 32 38 / 10%); }
589
- .status { display: inline-flex; align-items: center; min-height: 28px; padding: 0 10px; border-radius: 999px; background: #eef2f5; color: #46545f; font-size: 13px; font-weight: 700; }
590
- h1 { margin: 18px 0 10px; font-size: 24px; line-height: 1.2; letter-spacing: 0; }
591
- p { margin: 0; color: #46545f; line-height: 1.55; }
592
- .detail { margin-top: 12px; color: #6a7780; font-size: 14px; }
593
- .slug { margin-top: 18px; padding: 10px 12px; border: 1px solid #d7dee3; border-radius: 6px; background: #fafbfc; color: #172026; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; overflow-wrap: anywhere; }
594
- @media (prefers-color-scheme: dark) {
595
- body { background: #101417; color: #f4f7f8; }
596
- main { background: #171d21; border-color: #2b363d; box-shadow: none; }
597
- .status { background: #263139; color: #cbd5db; }
598
- p { color: #bac6cc; }
599
- .detail { color: #8c9aa3; }
600
- .slug { background: #101417; border-color: #2b363d; color: #f4f7f8; }
601
- }
602
- </style>
603
- </head>
604
- <body>
605
- <main>
606
- <div class="status">${input.status}</div>
607
- <h1>${htmlEscape(input.title)}</h1>
608
- <p>${htmlEscape(input.message)}</p>
609
- ${detail}
610
- ${slug}
611
- </main>
612
- </body>
613
- </html>`;
614
- return new Response(body, {
615
- status: input.status,
616
- headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" }
617
- });
618
- }
619
- function publicShortlinkError(kind, slug, host) {
620
- if (kind === "disabled") {
621
- return publicErrorPage({
622
- title: "This shortlink is disabled",
623
- message: "The owner has turned this shortlink off.",
624
- detail: "Ask the sender for a new link if you still need access.",
625
- status: 410,
626
- slug: slug ? `${host ?? ""}/${slug}` : undefined
627
- });
628
- }
629
- if (kind === "expired") {
630
- return publicErrorPage({
631
- title: "This shortlink has expired",
632
- message: "The owner set an expiration time for this shortlink, and it is no longer available.",
633
- detail: "Ask the sender to create a fresh link.",
634
- status: 410,
635
- slug: slug ? `${host ?? ""}/${slug}` : undefined
636
- });
637
- }
638
- if (kind === "used") {
639
- return publicErrorPage({
640
- title: "This shortlink has already been used",
641
- message: "The owner limited how many times this shortlink can be opened, and that limit has been reached.",
642
- detail: "Ask the sender for a new link if you still need access.",
643
- status: 410,
644
- slug: slug ? `${host ?? ""}/${slug}` : undefined
645
- });
646
- }
647
- if (kind === "reserved") {
648
- return publicErrorPage({
649
- title: "This path is reserved",
650
- message: "This address is reserved for another has.na feature.",
651
- status: 404,
652
- slug
653
- });
654
- }
655
- if (kind === "invalid") {
656
- return publicErrorPage({
657
- title: "Invalid shortlink",
658
- message: "This shortlink address is not valid.",
659
- status: 400
660
- });
661
- }
662
- if (kind === "missing") {
663
- return publicErrorPage({
664
- title: "Missing shortlink",
665
- message: "No shortlink slug was provided.",
666
- status: 404
667
- });
668
- }
669
- return publicErrorPage({
670
- title: "Shortlink not found",
671
- message: "This shortlink does not exist or is no longer available.",
672
- detail: "Check the address or ask the sender for a new link.",
673
- status: 404,
674
- slug: slug ? `${host ?? ""}/${slug}` : undefined
541
+ headers: responseHeaders
675
542
  });
676
543
  }
677
- function apiToken(options) {
678
- return options.apiToken || process.env.SHORTLINKS_API_TOKEN || process.env.HASNA_SHORTLINKS_API_TOKEN || null;
679
- }
680
- function requestToken(request) {
681
- const auth = request.headers.get("authorization") || "";
682
- const bearer = auth.toLowerCase().startsWith("bearer ") ? auth.slice(7).trim() : "";
683
- return bearer || request.headers.get("x-shortlinks-token") || request.headers.get("x-api-key");
684
- }
685
- function isAuthorized(request, options) {
686
- const token = apiToken(options);
687
- if (!token)
688
- return true;
689
- return requestToken(request) === token;
690
- }
691
- async function readJsonBody(request) {
692
- try {
693
- const parsed = await request.json();
694
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
695
- } catch {
696
- return {};
697
- }
698
- }
699
- function requireMethod(store, method) {
700
- const fn = store[method];
701
- if (typeof fn !== "function")
702
- throw new Error(`Store does not support ${String(method)}.`);
703
- return fn.bind(store);
704
- }
705
- async function handleApi(request, apiPath, store, options) {
706
- if (apiPath === "/health") {
707
- return json({ ok: true, service: "shortlinks", api_auth_required: Boolean(apiToken(options)), stats: await store.totalStats() });
708
- }
709
- if (!isAuthorized(request, options))
710
- return json({ error: "Unauthorized" }, 401);
711
- const url = new URL(request.url);
712
- const segments = apiPath.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
713
- try {
714
- if (apiPath === "/links" && request.method === "GET") {
715
- const listLinks = requireMethod(store, "listLinks");
716
- return json(await listLinks({
717
- domain: url.searchParams.get("domain") || undefined,
718
- activeOnly: url.searchParams.get("active") === "true",
719
- limit: Number(url.searchParams.get("limit") || "100")
720
- }));
721
- }
722
- if (apiPath === "/links" && request.method === "POST") {
723
- const body = await readJsonBody(request);
724
- const destinationUrl = String(body.destination_url || body.url || "");
725
- if (!destinationUrl)
726
- return json({ error: "destination_url is required" }, 400);
727
- const createLink = requireMethod(store, "createLink");
728
- return json(await createLink({
729
- destinationUrl,
730
- domain: typeof body.domain === "string" ? body.domain : undefined,
731
- slug: typeof body.slug === "string" ? body.slug : undefined,
732
- title: typeof body.title === "string" ? body.title : undefined,
733
- expiresAt: typeof body.expires_at === "string" ? body.expires_at : typeof body.expires === "string" ? body.expires : undefined,
734
- maxUses: typeof body.max_uses === "number" ? body.max_uses : typeof body.maxUses === "number" ? body.maxUses : undefined,
735
- slugLength: typeof body.length === "number" ? body.length : undefined
736
- }), 201);
737
- }
738
- if (segments[0] === "links" && segments[1] && request.method === "GET") {
739
- const getLink = requireMethod(store, "getLink");
740
- const domain = url.searchParams.get("domain") || undefined;
741
- const link = domain ? await getLink(domain, segments[1]) : await getLink(segments[1]);
742
- return link ? json(link) : json({ error: "Link not found." }, 404);
743
- }
744
- if (segments[0] === "links" && segments[1] && request.method === "DELETE") {
745
- const deleteLink = requireMethod(store, "deleteLink");
746
- const domain = url.searchParams.get("domain") || undefined;
747
- return json(domain ? await deleteLink(domain, segments[1]) : await deleteLink(segments[1]));
748
- }
749
- if (segments[0] === "links" && segments[1] && segments[2] === "active" && request.method === "POST") {
750
- const body = await readJsonBody(request);
751
- const setLinkActive = requireMethod(store, "setLinkActive");
752
- const domain = url.searchParams.get("domain") || undefined;
753
- const active = Boolean(body.active);
754
- return json(domain ? await setLinkActive(domain, segments[1], active) : await setLinkActive(segments[1], active));
755
- }
756
- if (apiPath === "/stats" && request.method === "GET") {
757
- return json(await store.totalStats());
758
- }
759
- if (segments[0] === "stats" && segments[1] && request.method === "GET") {
760
- const getStats = requireMethod(store, "getStats");
761
- const domain = url.searchParams.get("domain") || undefined;
762
- return json(domain ? await getStats(domain, segments[1]) : await getStats(segments[1]));
763
- }
764
- if (apiPath === "/domains" && request.method === "GET") {
765
- const listDomains = requireMethod(store, "listDomains");
766
- return json(await listDomains());
767
- }
768
- if (apiPath === "/domains" && request.method === "POST") {
769
- const body = await readJsonBody(request);
770
- const hostname2 = String(body.hostname || "");
771
- if (!hostname2)
772
- return json({ error: "hostname is required" }, 400);
773
- const addDomain = requireMethod(store, "addDomain");
774
- return json(await addDomain({
775
- hostname: hostname2,
776
- provider: typeof body.provider === "string" ? body.provider : "manual",
777
- defaultDomain: Boolean(body.default_domain || body.default),
778
- originUrl: typeof body.origin_url === "string" ? body.origin_url : undefined,
779
- notes: typeof body.notes === "string" ? body.notes : undefined
780
- }), 201);
781
- }
782
- if (segments[0] === "domains" && segments[1] && request.method === "GET") {
783
- const getDomain = requireMethod(store, "getDomain");
784
- const domain = await getDomain(segments[1]);
785
- return domain ? json(domain) : json({ error: "Domain not found." }, 404);
786
- }
787
- } catch (error) {
788
- return json({ error: error instanceof Error ? error.message : String(error) }, 400);
789
- }
790
- return json({ error: "Not found." }, 404);
791
- }
792
544
  function getHost(request, fallback) {
793
545
  const forwarded = request.headers.get("x-forwarded-host");
794
546
  const host = forwarded || request.headers.get("host") || fallback || "";
@@ -806,14 +558,8 @@ function isExpired(link) {
806
558
  function createShortlinksHandler(options = {}) {
807
559
  const store = options.store || new ShortlinksStore(options.dbPath);
808
560
  const redirectStatus = options.redirectStatus || 302;
809
- const reservedPathPrefixes = new Set((options.reservedPathPrefixes ?? ["a"]).map((prefix) => prefix.toLowerCase()));
810
- const apiPathPrefix = (options.apiPathPrefix || process.env.SHORTLINKS_API_PATH_PREFIX || "/api").replace(/\/+$/, "") || "/api";
811
561
  return async (request) => {
812
562
  const url = new URL(request.url);
813
- if (url.pathname === apiPathPrefix || url.pathname.startsWith(`${apiPathPrefix}/`)) {
814
- const apiPath = url.pathname.slice(apiPathPrefix.length) || "/";
815
- return handleApi(request, apiPath, store, options);
816
- }
817
563
  if (url.pathname === "/healthz") {
818
564
  return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
819
565
  }
@@ -824,55 +570,40 @@ function createShortlinksHandler(options = {}) {
824
570
  try {
825
571
  slug = decodeURIComponent(url.pathname.replace(/^\/+/, "").split("/")[0] || "");
826
572
  } catch {
827
- return publicShortlinkError("invalid", "");
573
+ return json({ error: "Invalid slug." }, 400);
828
574
  }
829
575
  if (!slug)
830
- return publicShortlinkError("missing", "");
831
- if (reservedPathPrefixes.has(slug.toLowerCase())) {
832
- return publicShortlinkError("reserved", slug);
576
+ return json({ error: "Missing slug." }, 404);
577
+ if (request.method !== "GET" && request.method !== "HEAD") {
578
+ return json({ error: "Method not allowed." }, 405, { allow: REDIRECT_ALLOW_HEADER });
833
579
  }
834
580
  const host = getHost(request, options.defaultHost);
835
581
  if (!host)
836
- return publicErrorPage({
837
- title: "Invalid shortlink request",
838
- message: "This request did not include a host name.",
839
- status: 400
840
- });
582
+ return json({ error: "Missing Host header." }, 400);
841
583
  let link = null;
842
584
  try {
843
585
  link = await store.resolve(host, slug);
844
586
  } catch {
845
- return publicShortlinkError("not_found", slug, host);
587
+ return json({ error: "Shortlink not found.", slug, host }, 404);
846
588
  }
847
589
  if (!link)
848
- return publicShortlinkError("not_found", slug, host);
590
+ return json({ error: "Shortlink not found.", slug, host }, 404);
849
591
  if (!link.active)
850
- return publicShortlinkError("disabled", slug, host);
592
+ return json({ error: "Shortlink is disabled.", slug, host }, 410);
851
593
  if (isExpired(link))
852
- return publicShortlinkError("expired", slug, host);
853
- if (link.max_uses !== null && link.used_count >= link.max_uses) {
854
- return publicShortlinkError("used", slug, host);
855
- }
856
- if (request.method.toUpperCase() === "HEAD") {
857
- return new Response(null, {
858
- status: redirectStatus,
859
- headers: { location: link.destination_url }
594
+ return json({ error: "Shortlink is expired.", slug, host }, 410);
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
+ }
860
605
  });
861
606
  }
862
- const consumed = store.consumeLinkUse ? await store.consumeLinkUse(link) : link;
863
- if (!consumed) {
864
- return publicShortlinkError("used", slug, host);
865
- }
866
- await store.recordClick(consumed, {
867
- ip: getClientIp(request),
868
- userAgent: request.headers.get("user-agent"),
869
- referer: request.headers.get("referer"),
870
- country: request.headers.get("cf-ipcountry"),
871
- metadata: {
872
- path: url.pathname,
873
- query: url.search
874
- }
875
- });
876
607
  return Response.redirect(link.destination_url, redirectStatus);
877
608
  };
878
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