@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.
- package/LICENSE +191 -152
- package/README.md +18 -21
- package/cloudflare/shortlinks.js +10 -26
- package/cloudflare/wrangler.example.toml +1 -3
- package/dist/cli/index.js +574 -6377
- package/dist/cloudflare.d.ts +0 -1
- package/dist/cloudflare.js +10 -28
- package/dist/config.d.ts +0 -8
- package/dist/index.d.ts +1 -8
- package/dist/index.js +195 -5990
- package/dist/pg-store.d.ts +1 -3
- package/dist/server.d.ts +1 -18
- package/dist/server.js +29 -298
- package/dist/store.d.ts +0 -1
- package/dist/types.d.ts +0 -3
- package/infra/aws-ec2-user-data.sh +16 -65
- package/package.json +6 -11
- package/dist/api-client.d.ts +0 -30
- package/dist/pg-migrate.d.ts +0 -7
- package/dist/remote-storage.d.ts +0 -11
- package/dist/storage-config.d.ts +0 -37
- package/dist/storage-sync.d.ts +0 -36
- package/dist/storage.d.ts +0 -6
- package/dist/storage.js +0 -5561
package/dist/pg-store.d.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
...
|
|
53
|
+
...loadConfig(),
|
|
55
54
|
...patch,
|
|
56
|
-
api: {
|
|
57
|
-
...current.api,
|
|
58
|
-
...patch.api
|
|
59
|
-
},
|
|
60
55
|
cloudflare: {
|
|
61
|
-
...
|
|
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,
|
|
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, ?, ?,
|
|
397
|
-
`).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt,
|
|
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
|
-
|
|
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:
|
|
571
|
-
});
|
|
572
|
-
}
|
|
573
|
-
function htmlEscape(value) {
|
|
574
|
-
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
|
573
|
+
return json({ error: "Invalid slug." }, 400);
|
|
828
574
|
}
|
|
829
575
|
if (!slug)
|
|
830
|
-
return
|
|
831
|
-
if (
|
|
832
|
-
return
|
|
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
|
|
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
|
|
587
|
+
return json({ error: "Shortlink not found.", slug, host }, 404);
|
|
846
588
|
}
|
|
847
589
|
if (!link)
|
|
848
|
-
return
|
|
590
|
+
return json({ error: "Shortlink not found.", slug, host }, 404);
|
|
849
591
|
if (!link.active)
|
|
850
|
-
return
|
|
592
|
+
return json({ error: "Shortlink is disabled.", slug, host }, 410);
|
|
851
593
|
if (isExpired(link))
|
|
852
|
-
return
|
|
853
|
-
if (
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
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="
|
|
8
|
-
export RDS_HOST="
|
|
9
|
-
export 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/
|
|
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
|
-
|
|
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": "
|
|
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/
|
|
45
|
-
chmod 600 "${SHORTLINKS_HOME}/.hasna/
|
|
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 "
|
|
55
|
+
--secret-id "rds!db-7a451ce6-83a9-40fa-b24a-81e5d5943511" \
|
|
65
56
|
--query SecretString \
|
|
66
57
|
--output text)"
|
|
67
58
|
|
|
68
|
-
export
|
|
69
|
-
|
|
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
|
-
|
|
119
|
-
ExecStart=/usr/local/bin/shortlinks-env-exec shortlinks serve --
|
|
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
|
-
|
|
152
|
-
|
|
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
|