@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.
- 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 +566 -6261
- 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 +202 -5889
- package/dist/pg-store.d.ts +1 -3
- package/dist/server.d.ts +1 -18
- package/dist/server.js +22 -183
- 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,127 +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:
|
|
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 (
|
|
728
|
-
return json({ error: "
|
|
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 (
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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="
|
|
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
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/shortlinks",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "CLI-only shortlink manager for custom domains, click tracking, Cloudflare setup, and
|
|
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
|
|
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
|
}
|
package/dist/api-client.d.ts
DELETED
|
@@ -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
|
-
}
|
package/dist/pg-migrate.d.ts
DELETED
package/dist/remote-storage.d.ts
DELETED
|
@@ -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
|
-
}
|
package/dist/storage-config.d.ts
DELETED
|
@@ -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;
|