@hasna/shortlinks 0.1.13 → 0.1.15
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/cloudflare/shortlinks.js +18 -11
- package/cloudflare/wrangler.example.toml +1 -0
- package/dist/api-client.d.ts +30 -0
- package/dist/cli/index.js +357 -27
- package/dist/cloudflare.d.ts +1 -0
- package/dist/cloudflare.js +19 -11
- package/dist/config.d.ts +8 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +308 -16
- package/dist/server.d.ts +16 -1
- package/dist/server.js +126 -2
- package/dist/storage.js +16 -2
- package/infra/aws-ec2-user-data.sh +28 -1
- package/package.json +1 -1
package/cloudflare/shortlinks.js
CHANGED
|
@@ -6,25 +6,32 @@ export default {
|
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
const incoming = new URL(request.url);
|
|
9
|
+
const proxyTo = (targetOrigin, marker) => {
|
|
10
|
+
const upstream = new URL(incoming.pathname + incoming.search, targetOrigin);
|
|
11
|
+
const headers = new Headers(request.headers);
|
|
12
|
+
headers.set("x-forwarded-host", incoming.host);
|
|
13
|
+
headers.set("x-shortlinks-worker", marker);
|
|
14
|
+
|
|
15
|
+
return fetch(upstream.toString(), {
|
|
16
|
+
method: request.method,
|
|
17
|
+
headers,
|
|
18
|
+
body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
|
|
19
|
+
redirect: "manual"
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
|
|
9
23
|
const reserved = (env.SHORTLINKS_RESERVED_PATH_PREFIXES || "a")
|
|
10
24
|
.split(",")
|
|
11
25
|
.map((value) => value.trim().toLowerCase())
|
|
12
26
|
.filter(Boolean);
|
|
13
27
|
const firstSegment = decodeURIComponent(incoming.pathname.replace(/^\/+/, "").split("/")[0] || "").toLowerCase();
|
|
14
28
|
if (firstSegment && reserved.includes(firstSegment)) {
|
|
29
|
+
if (env.ATTACHMENTS_ORIGIN) {
|
|
30
|
+
return proxyTo(env.ATTACHMENTS_ORIGIN, "attachments");
|
|
31
|
+
}
|
|
15
32
|
return new Response("Reserved path prefix", { status: 404 });
|
|
16
33
|
}
|
|
17
34
|
|
|
18
|
-
|
|
19
|
-
const headers = new Headers(request.headers);
|
|
20
|
-
headers.set("x-forwarded-host", incoming.host);
|
|
21
|
-
headers.set("x-shortlinks-worker", "cloudflare");
|
|
22
|
-
|
|
23
|
-
return fetch(upstream.toString(), {
|
|
24
|
-
method: request.method,
|
|
25
|
-
headers,
|
|
26
|
-
body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
|
|
27
|
-
redirect: "manual"
|
|
28
|
-
});
|
|
35
|
+
return proxyTo(origin, "cloudflare");
|
|
29
36
|
}
|
|
30
37
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
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/cli/index.js
CHANGED
|
@@ -2121,17 +2121,31 @@ function saveConfig(config) {
|
|
|
2121
2121
|
`);
|
|
2122
2122
|
}
|
|
2123
2123
|
function updateConfig(patch) {
|
|
2124
|
+
const current = loadConfig();
|
|
2124
2125
|
const next = {
|
|
2125
|
-
...
|
|
2126
|
+
...current,
|
|
2126
2127
|
...patch,
|
|
2128
|
+
api: {
|
|
2129
|
+
...current.api,
|
|
2130
|
+
...patch.api
|
|
2131
|
+
},
|
|
2127
2132
|
cloudflare: {
|
|
2128
|
-
...
|
|
2133
|
+
...current.cloudflare,
|
|
2129
2134
|
...patch.cloudflare
|
|
2130
2135
|
}
|
|
2131
2136
|
};
|
|
2132
2137
|
saveConfig(next);
|
|
2133
2138
|
return next;
|
|
2134
2139
|
}
|
|
2140
|
+
function getApiBaseUrl(config = loadConfig()) {
|
|
2141
|
+
const baseUrl = process.env.SHORTLINKS_API_URL || process.env.HASNA_SHORTLINKS_API_URL || config.api?.baseUrl || "";
|
|
2142
|
+
return baseUrl ? baseUrl.replace(/\/+$/, "") : null;
|
|
2143
|
+
}
|
|
2144
|
+
function getApiToken(config = loadConfig()) {
|
|
2145
|
+
const envName = config.api?.tokenEnv || "SHORTLINKS_API_TOKEN";
|
|
2146
|
+
const token = process.env[envName] || process.env.SHORTLINKS_API_TOKEN || process.env.HASNA_SHORTLINKS_API_TOKEN || config.api?.token || "";
|
|
2147
|
+
return token || null;
|
|
2148
|
+
}
|
|
2135
2149
|
function normalizeHostname(input) {
|
|
2136
2150
|
const raw = input.trim().toLowerCase();
|
|
2137
2151
|
if (!raw)
|
|
@@ -9473,6 +9487,156 @@ class PgShortlinksStore {
|
|
|
9473
9487
|
// src/cli/index.ts
|
|
9474
9488
|
init_config();
|
|
9475
9489
|
|
|
9490
|
+
// src/api-client.ts
|
|
9491
|
+
init_config();
|
|
9492
|
+
function normalizeBaseUrl(value) {
|
|
9493
|
+
return value.replace(/\/+$/, "");
|
|
9494
|
+
}
|
|
9495
|
+
function requireBaseUrl(options) {
|
|
9496
|
+
const baseUrl = options.baseUrl || getApiBaseUrl(loadConfig());
|
|
9497
|
+
if (!baseUrl) {
|
|
9498
|
+
throw new Error("Shortlinks API URL is not configured. Run `shortlinks config set api-url <url>` or set SHORTLINKS_API_URL.");
|
|
9499
|
+
}
|
|
9500
|
+
return normalizeBaseUrl(baseUrl);
|
|
9501
|
+
}
|
|
9502
|
+
function requireToken(options) {
|
|
9503
|
+
const token = options.token || getApiToken(loadConfig());
|
|
9504
|
+
if (!token) {
|
|
9505
|
+
throw new Error("Shortlinks API token is not configured. Set SHORTLINKS_API_TOKEN or run `shortlinks config set api-token-env <name>`.");
|
|
9506
|
+
}
|
|
9507
|
+
return token;
|
|
9508
|
+
}
|
|
9509
|
+
async function readJson(response) {
|
|
9510
|
+
const text = await response.text();
|
|
9511
|
+
let parsed = {};
|
|
9512
|
+
if (text) {
|
|
9513
|
+
try {
|
|
9514
|
+
parsed = JSON.parse(text);
|
|
9515
|
+
} catch {
|
|
9516
|
+
parsed = { error: text };
|
|
9517
|
+
}
|
|
9518
|
+
}
|
|
9519
|
+
if (!response.ok) {
|
|
9520
|
+
const message = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `HTTP ${response.status}`;
|
|
9521
|
+
throw new Error(message);
|
|
9522
|
+
}
|
|
9523
|
+
return parsed;
|
|
9524
|
+
}
|
|
9525
|
+
|
|
9526
|
+
class ShortlinksApiClient {
|
|
9527
|
+
options;
|
|
9528
|
+
constructor(options = {}) {
|
|
9529
|
+
this.options = options;
|
|
9530
|
+
}
|
|
9531
|
+
url(path, query) {
|
|
9532
|
+
const url = new URL(`${requireBaseUrl(this.options)}${path}`);
|
|
9533
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
9534
|
+
if (value !== undefined && value !== "")
|
|
9535
|
+
url.searchParams.set(key, String(value));
|
|
9536
|
+
}
|
|
9537
|
+
return url.toString();
|
|
9538
|
+
}
|
|
9539
|
+
headers(extra) {
|
|
9540
|
+
return {
|
|
9541
|
+
authorization: `Bearer ${requireToken(this.options)}`,
|
|
9542
|
+
...extra ?? {}
|
|
9543
|
+
};
|
|
9544
|
+
}
|
|
9545
|
+
async totalStats() {
|
|
9546
|
+
const response = await fetch(this.url("/stats"), { headers: this.headers() });
|
|
9547
|
+
return readJson(response);
|
|
9548
|
+
}
|
|
9549
|
+
async createLink(input) {
|
|
9550
|
+
const response = await fetch(this.url("/links"), {
|
|
9551
|
+
method: "POST",
|
|
9552
|
+
headers: this.headers({ "content-type": "application/json" }),
|
|
9553
|
+
body: JSON.stringify({
|
|
9554
|
+
destination_url: input.destinationUrl,
|
|
9555
|
+
domain: input.domain,
|
|
9556
|
+
slug: input.slug,
|
|
9557
|
+
title: input.title,
|
|
9558
|
+
expires_at: input.expiresAt,
|
|
9559
|
+
length: input.slugLength
|
|
9560
|
+
})
|
|
9561
|
+
});
|
|
9562
|
+
return readJson(response);
|
|
9563
|
+
}
|
|
9564
|
+
async listLinks(options = {}) {
|
|
9565
|
+
const response = await fetch(this.url("/links", {
|
|
9566
|
+
domain: options.domain,
|
|
9567
|
+
active: options.activeOnly,
|
|
9568
|
+
limit: options.limit
|
|
9569
|
+
}), { headers: this.headers() });
|
|
9570
|
+
return readJson(response);
|
|
9571
|
+
}
|
|
9572
|
+
async getLink(domainOrSlug, maybeSlug) {
|
|
9573
|
+
const slug = maybeSlug ?? domainOrSlug;
|
|
9574
|
+
const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}`, {
|
|
9575
|
+
domain: maybeSlug ? domainOrSlug : undefined
|
|
9576
|
+
}), { headers: this.headers() });
|
|
9577
|
+
if (response.status === 404)
|
|
9578
|
+
return null;
|
|
9579
|
+
return readJson(response);
|
|
9580
|
+
}
|
|
9581
|
+
async setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
|
|
9582
|
+
const hasDomain = typeof maybeSlugOrActive === "string";
|
|
9583
|
+
const slug = hasDomain ? maybeSlugOrActive : domainOrSlug;
|
|
9584
|
+
const active = hasDomain ? Boolean(maybeActive) : Boolean(maybeSlugOrActive);
|
|
9585
|
+
const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}/active`, {
|
|
9586
|
+
domain: hasDomain ? domainOrSlug : undefined
|
|
9587
|
+
}), {
|
|
9588
|
+
method: "POST",
|
|
9589
|
+
headers: this.headers({ "content-type": "application/json" }),
|
|
9590
|
+
body: JSON.stringify({ active })
|
|
9591
|
+
});
|
|
9592
|
+
return readJson(response);
|
|
9593
|
+
}
|
|
9594
|
+
async deleteLink(domainOrSlug, maybeSlug) {
|
|
9595
|
+
const slug = maybeSlug ?? domainOrSlug;
|
|
9596
|
+
const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}`, {
|
|
9597
|
+
domain: maybeSlug ? domainOrSlug : undefined
|
|
9598
|
+
}), {
|
|
9599
|
+
method: "DELETE",
|
|
9600
|
+
headers: this.headers()
|
|
9601
|
+
});
|
|
9602
|
+
return readJson(response);
|
|
9603
|
+
}
|
|
9604
|
+
async getStats(domainOrSlug, maybeSlug) {
|
|
9605
|
+
const slug = maybeSlug ?? domainOrSlug;
|
|
9606
|
+
const response = await fetch(this.url(`/stats/${encodeURIComponent(slug)}`, {
|
|
9607
|
+
domain: maybeSlug ? domainOrSlug : undefined
|
|
9608
|
+
}), { headers: this.headers() });
|
|
9609
|
+
return readJson(response);
|
|
9610
|
+
}
|
|
9611
|
+
async addDomain(input) {
|
|
9612
|
+
const response = await fetch(this.url("/domains"), {
|
|
9613
|
+
method: "POST",
|
|
9614
|
+
headers: this.headers({ "content-type": "application/json" }),
|
|
9615
|
+
body: JSON.stringify({
|
|
9616
|
+
hostname: input.hostname,
|
|
9617
|
+
provider: input.provider,
|
|
9618
|
+
default_domain: input.defaultDomain,
|
|
9619
|
+
origin_url: input.originUrl,
|
|
9620
|
+
notes: input.notes
|
|
9621
|
+
})
|
|
9622
|
+
});
|
|
9623
|
+
return readJson(response);
|
|
9624
|
+
}
|
|
9625
|
+
async listDomains() {
|
|
9626
|
+
const response = await fetch(this.url("/domains"), { headers: this.headers() });
|
|
9627
|
+
return readJson(response);
|
|
9628
|
+
}
|
|
9629
|
+
async getDomain(hostname2) {
|
|
9630
|
+
const response = await fetch(this.url(`/domains/${encodeURIComponent(hostname2)}`), { headers: this.headers() });
|
|
9631
|
+
if (response.status === 404)
|
|
9632
|
+
return null;
|
|
9633
|
+
return readJson(response);
|
|
9634
|
+
}
|
|
9635
|
+
async close() {
|
|
9636
|
+
return;
|
|
9637
|
+
}
|
|
9638
|
+
}
|
|
9639
|
+
|
|
9476
9640
|
// src/server.ts
|
|
9477
9641
|
function json(data, status = 200) {
|
|
9478
9642
|
return new Response(JSON.stringify(data, null, 2), {
|
|
@@ -9480,6 +9644,120 @@ function json(data, status = 200) {
|
|
|
9480
9644
|
headers: { "content-type": "application/json; charset=utf-8" }
|
|
9481
9645
|
});
|
|
9482
9646
|
}
|
|
9647
|
+
function apiToken(options) {
|
|
9648
|
+
return options.apiToken || process.env.SHORTLINKS_API_TOKEN || process.env.HASNA_SHORTLINKS_API_TOKEN || null;
|
|
9649
|
+
}
|
|
9650
|
+
function requestToken(request) {
|
|
9651
|
+
const auth = request.headers.get("authorization") || "";
|
|
9652
|
+
const bearer = auth.toLowerCase().startsWith("bearer ") ? auth.slice(7).trim() : "";
|
|
9653
|
+
return bearer || request.headers.get("x-shortlinks-token") || request.headers.get("x-api-key");
|
|
9654
|
+
}
|
|
9655
|
+
function isAuthorized(request, options) {
|
|
9656
|
+
const token = apiToken(options);
|
|
9657
|
+
if (!token)
|
|
9658
|
+
return true;
|
|
9659
|
+
return requestToken(request) === token;
|
|
9660
|
+
}
|
|
9661
|
+
async function readJsonBody(request) {
|
|
9662
|
+
try {
|
|
9663
|
+
const parsed = await request.json();
|
|
9664
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
9665
|
+
} catch {
|
|
9666
|
+
return {};
|
|
9667
|
+
}
|
|
9668
|
+
}
|
|
9669
|
+
function requireMethod(store, method) {
|
|
9670
|
+
const fn = store[method];
|
|
9671
|
+
if (typeof fn !== "function")
|
|
9672
|
+
throw new Error(`Store does not support ${String(method)}.`);
|
|
9673
|
+
return fn.bind(store);
|
|
9674
|
+
}
|
|
9675
|
+
async function handleApi(request, apiPath, store, options) {
|
|
9676
|
+
if (apiPath === "/health") {
|
|
9677
|
+
return json({ ok: true, service: "shortlinks", api_auth_required: Boolean(apiToken(options)), stats: await store.totalStats() });
|
|
9678
|
+
}
|
|
9679
|
+
if (!isAuthorized(request, options))
|
|
9680
|
+
return json({ error: "Unauthorized" }, 401);
|
|
9681
|
+
const url = new URL(request.url);
|
|
9682
|
+
const segments = apiPath.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
|
|
9683
|
+
try {
|
|
9684
|
+
if (apiPath === "/links" && request.method === "GET") {
|
|
9685
|
+
const listLinks = requireMethod(store, "listLinks");
|
|
9686
|
+
return json(await listLinks({
|
|
9687
|
+
domain: url.searchParams.get("domain") || undefined,
|
|
9688
|
+
activeOnly: url.searchParams.get("active") === "true",
|
|
9689
|
+
limit: Number(url.searchParams.get("limit") || "100")
|
|
9690
|
+
}));
|
|
9691
|
+
}
|
|
9692
|
+
if (apiPath === "/links" && request.method === "POST") {
|
|
9693
|
+
const body = await readJsonBody(request);
|
|
9694
|
+
const destinationUrl = String(body.destination_url || body.url || "");
|
|
9695
|
+
if (!destinationUrl)
|
|
9696
|
+
return json({ error: "destination_url is required" }, 400);
|
|
9697
|
+
const createLink = requireMethod(store, "createLink");
|
|
9698
|
+
return json(await createLink({
|
|
9699
|
+
destinationUrl,
|
|
9700
|
+
domain: typeof body.domain === "string" ? body.domain : undefined,
|
|
9701
|
+
slug: typeof body.slug === "string" ? body.slug : undefined,
|
|
9702
|
+
title: typeof body.title === "string" ? body.title : undefined,
|
|
9703
|
+
expiresAt: typeof body.expires_at === "string" ? body.expires_at : typeof body.expires === "string" ? body.expires : undefined,
|
|
9704
|
+
slugLength: typeof body.length === "number" ? body.length : undefined
|
|
9705
|
+
}), 201);
|
|
9706
|
+
}
|
|
9707
|
+
if (segments[0] === "links" && segments[1] && request.method === "GET") {
|
|
9708
|
+
const getLink = requireMethod(store, "getLink");
|
|
9709
|
+
const domain = url.searchParams.get("domain") || undefined;
|
|
9710
|
+
const link = domain ? await getLink(domain, segments[1]) : await getLink(segments[1]);
|
|
9711
|
+
return link ? json(link) : json({ error: "Link not found." }, 404);
|
|
9712
|
+
}
|
|
9713
|
+
if (segments[0] === "links" && segments[1] && request.method === "DELETE") {
|
|
9714
|
+
const deleteLink = requireMethod(store, "deleteLink");
|
|
9715
|
+
const domain = url.searchParams.get("domain") || undefined;
|
|
9716
|
+
return json(domain ? await deleteLink(domain, segments[1]) : await deleteLink(segments[1]));
|
|
9717
|
+
}
|
|
9718
|
+
if (segments[0] === "links" && segments[1] && segments[2] === "active" && request.method === "POST") {
|
|
9719
|
+
const body = await readJsonBody(request);
|
|
9720
|
+
const setLinkActive = requireMethod(store, "setLinkActive");
|
|
9721
|
+
const domain = url.searchParams.get("domain") || undefined;
|
|
9722
|
+
const active = Boolean(body.active);
|
|
9723
|
+
return json(domain ? await setLinkActive(domain, segments[1], active) : await setLinkActive(segments[1], active));
|
|
9724
|
+
}
|
|
9725
|
+
if (apiPath === "/stats" && request.method === "GET") {
|
|
9726
|
+
return json(await store.totalStats());
|
|
9727
|
+
}
|
|
9728
|
+
if (segments[0] === "stats" && segments[1] && request.method === "GET") {
|
|
9729
|
+
const getStats = requireMethod(store, "getStats");
|
|
9730
|
+
const domain = url.searchParams.get("domain") || undefined;
|
|
9731
|
+
return json(domain ? await getStats(domain, segments[1]) : await getStats(segments[1]));
|
|
9732
|
+
}
|
|
9733
|
+
if (apiPath === "/domains" && request.method === "GET") {
|
|
9734
|
+
const listDomains = requireMethod(store, "listDomains");
|
|
9735
|
+
return json(await listDomains());
|
|
9736
|
+
}
|
|
9737
|
+
if (apiPath === "/domains" && request.method === "POST") {
|
|
9738
|
+
const body = await readJsonBody(request);
|
|
9739
|
+
const hostname2 = String(body.hostname || "");
|
|
9740
|
+
if (!hostname2)
|
|
9741
|
+
return json({ error: "hostname is required" }, 400);
|
|
9742
|
+
const addDomain = requireMethod(store, "addDomain");
|
|
9743
|
+
return json(await addDomain({
|
|
9744
|
+
hostname: hostname2,
|
|
9745
|
+
provider: typeof body.provider === "string" ? body.provider : "manual",
|
|
9746
|
+
defaultDomain: Boolean(body.default_domain || body.default),
|
|
9747
|
+
originUrl: typeof body.origin_url === "string" ? body.origin_url : undefined,
|
|
9748
|
+
notes: typeof body.notes === "string" ? body.notes : undefined
|
|
9749
|
+
}), 201);
|
|
9750
|
+
}
|
|
9751
|
+
if (segments[0] === "domains" && segments[1] && request.method === "GET") {
|
|
9752
|
+
const getDomain = requireMethod(store, "getDomain");
|
|
9753
|
+
const domain = await getDomain(segments[1]);
|
|
9754
|
+
return domain ? json(domain) : json({ error: "Domain not found." }, 404);
|
|
9755
|
+
}
|
|
9756
|
+
} catch (error) {
|
|
9757
|
+
return json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
9758
|
+
}
|
|
9759
|
+
return json({ error: "Not found." }, 404);
|
|
9760
|
+
}
|
|
9483
9761
|
function getHost(request, fallback) {
|
|
9484
9762
|
const forwarded = request.headers.get("x-forwarded-host");
|
|
9485
9763
|
const host = forwarded || request.headers.get("host") || fallback || "";
|
|
@@ -9498,8 +9776,13 @@ function createShortlinksHandler(options = {}) {
|
|
|
9498
9776
|
const store = options.store || new ShortlinksStore(options.dbPath);
|
|
9499
9777
|
const redirectStatus = options.redirectStatus || 302;
|
|
9500
9778
|
const reservedPathPrefixes = new Set((options.reservedPathPrefixes ?? ["a"]).map((prefix) => prefix.toLowerCase()));
|
|
9779
|
+
const apiPathPrefix = (options.apiPathPrefix || process.env.SHORTLINKS_API_PATH_PREFIX || "/api").replace(/\/+$/, "") || "/api";
|
|
9501
9780
|
return async (request) => {
|
|
9502
9781
|
const url = new URL(request.url);
|
|
9782
|
+
if (url.pathname === apiPathPrefix || url.pathname.startsWith(`${apiPathPrefix}/`)) {
|
|
9783
|
+
const apiPath = url.pathname.slice(apiPathPrefix.length) || "/";
|
|
9784
|
+
return handleApi(request, apiPath, store, options);
|
|
9785
|
+
}
|
|
9503
9786
|
if (url.pathname === "/healthz") {
|
|
9504
9787
|
return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
|
|
9505
9788
|
}
|
|
@@ -9585,26 +9868,33 @@ function generateWorkerScript() {
|
|
|
9585
9868
|
}
|
|
9586
9869
|
|
|
9587
9870
|
const incoming = new URL(request.url);
|
|
9871
|
+
const proxyTo = (targetOrigin, marker) => {
|
|
9872
|
+
const upstream = new URL(incoming.pathname + incoming.search, targetOrigin);
|
|
9873
|
+
const headers = new Headers(request.headers);
|
|
9874
|
+
headers.set("x-forwarded-host", incoming.host);
|
|
9875
|
+
headers.set("x-shortlinks-worker", marker);
|
|
9876
|
+
|
|
9877
|
+
return fetch(upstream.toString(), {
|
|
9878
|
+
method: request.method,
|
|
9879
|
+
headers,
|
|
9880
|
+
body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
|
|
9881
|
+
redirect: "manual"
|
|
9882
|
+
});
|
|
9883
|
+
};
|
|
9884
|
+
|
|
9588
9885
|
const reserved = (env.SHORTLINKS_RESERVED_PATH_PREFIXES || "a")
|
|
9589
9886
|
.split(",")
|
|
9590
9887
|
.map((value) => value.trim().toLowerCase())
|
|
9591
9888
|
.filter(Boolean);
|
|
9592
9889
|
const firstSegment = decodeURIComponent(incoming.pathname.replace(/^\\/+/, "").split("/")[0] || "").toLowerCase();
|
|
9593
9890
|
if (firstSegment && reserved.includes(firstSegment)) {
|
|
9891
|
+
if (env.ATTACHMENTS_ORIGIN) {
|
|
9892
|
+
return proxyTo(env.ATTACHMENTS_ORIGIN, "attachments");
|
|
9893
|
+
}
|
|
9594
9894
|
return new Response("Reserved path prefix", { status: 404 });
|
|
9595
9895
|
}
|
|
9596
9896
|
|
|
9597
|
-
|
|
9598
|
-
const headers = new Headers(request.headers);
|
|
9599
|
-
headers.set("x-forwarded-host", incoming.host);
|
|
9600
|
-
headers.set("x-shortlinks-worker", "cloudflare");
|
|
9601
|
-
|
|
9602
|
-
return fetch(upstream.toString(), {
|
|
9603
|
-
method: request.method,
|
|
9604
|
-
headers,
|
|
9605
|
-
body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
|
|
9606
|
-
redirect: "manual"
|
|
9607
|
-
});
|
|
9897
|
+
return proxyTo(origin, "cloudflare");
|
|
9608
9898
|
}
|
|
9609
9899
|
};
|
|
9610
9900
|
`;
|
|
@@ -9622,14 +9912,15 @@ compatibility_date = "2026-05-01"
|
|
|
9622
9912
|
|
|
9623
9913
|
[vars]
|
|
9624
9914
|
SHORTLINKS_ORIGIN = "${options.origin || "https://shortlinks.example.com"}"
|
|
9915
|
+
ATTACHMENTS_ORIGIN = "${options.attachmentsOrigin || ""}"
|
|
9625
9916
|
SHORTLINKS_RESERVED_PATH_PREFIXES = "a"
|
|
9626
9917
|
`);
|
|
9627
9918
|
return { workerPath, wranglerPath };
|
|
9628
9919
|
}
|
|
9629
9920
|
function cloudflareAuthHeaders(token) {
|
|
9630
|
-
const
|
|
9631
|
-
if (
|
|
9632
|
-
return { authorization: `Bearer ${
|
|
9921
|
+
const apiToken2 = token || process.env.CLOUDFLARE_API_TOKEN;
|
|
9922
|
+
if (apiToken2)
|
|
9923
|
+
return { authorization: `Bearer ${apiToken2}` };
|
|
9633
9924
|
const apiKey = process.env.CLOUDFLARE_API_KEY;
|
|
9634
9925
|
const email = process.env.CLOUDFLARE_EMAIL;
|
|
9635
9926
|
if (apiKey && email) {
|
|
@@ -9815,13 +10106,19 @@ function withStore(fn) {
|
|
|
9815
10106
|
}
|
|
9816
10107
|
function storeMode() {
|
|
9817
10108
|
const opts = program2.opts();
|
|
9818
|
-
const
|
|
9819
|
-
|
|
10109
|
+
const config = loadConfig();
|
|
10110
|
+
const value = String(opts.remote ? "remote" : opts.store || process.env.SHORTLINKS_STORE || config.mode || "local").toLowerCase();
|
|
10111
|
+
if (value !== "local" && value !== "remote" && value !== "api")
|
|
9820
10112
|
throw new Error(`Unknown store mode: ${value}`);
|
|
9821
10113
|
return value;
|
|
9822
10114
|
}
|
|
9823
10115
|
async function withRuntimeStore(fn) {
|
|
9824
|
-
|
|
10116
|
+
const mode = storeMode();
|
|
10117
|
+
if (mode === "api") {
|
|
10118
|
+
const store2 = new ShortlinksApiClient({ baseUrl: program2.opts().apiUrl });
|
|
10119
|
+
return await fn(store2);
|
|
10120
|
+
}
|
|
10121
|
+
if (mode === "remote") {
|
|
9825
10122
|
const store2 = await PgShortlinksStore.fromStorage("shortlinks");
|
|
9826
10123
|
try {
|
|
9827
10124
|
return await fn(store2);
|
|
@@ -9843,7 +10140,7 @@ function commandExists(command) {
|
|
|
9843
10140
|
const result = spawnSync3("which", [command], { encoding: "utf-8" });
|
|
9844
10141
|
return result.status === 0;
|
|
9845
10142
|
}
|
|
9846
|
-
program2.name("shortlinks").description("CLI-only shortlink manager with custom domains, click tracking, Cloudflare helpers, and storage sync").version(getPackageVersion()).option("--db <path>", "SQLite database path").option("--store <mode>", "Data store mode: remote or
|
|
10143
|
+
program2.name("shortlinks").description("CLI-only shortlink manager with custom domains, click tracking, Cloudflare helpers, and storage sync").version(getPackageVersion()).option("--db <path>", "SQLite database path").option("--store <mode>", "Data store mode: local, remote, or api", process.env.SHORTLINKS_STORE || loadConfig().mode || "local").option("--api-url <url>", "Shortlinks HTTP API base URL").addOption(new Option("--remote", "Use the shortlinks PostgreSQL storage database directly")).option("-j, --json", "Output JSON for agents and scripts");
|
|
9847
10144
|
program2.command("init").description("Initialize local shortlinks storage").option("--domain <hostname>", "Add a default shortlink domain").option("--public-base-url <url>", "Public URL base for generated links").option("-j, --json", "Output JSON").action(async (opts) => {
|
|
9848
10145
|
try {
|
|
9849
10146
|
const result = await withRuntimeStore(async (store) => {
|
|
@@ -9883,19 +10180,42 @@ program2.command("init").description("Initialize local shortlinks storage").opti
|
|
|
9883
10180
|
});
|
|
9884
10181
|
var configCmd = program2.command("config").description("View and update local config");
|
|
9885
10182
|
configCmd.command("show").description("Show local config").option("-j, --json", "Output JSON").action((opts) => {
|
|
9886
|
-
const
|
|
10183
|
+
const config = loadConfig();
|
|
10184
|
+
const data = {
|
|
10185
|
+
path: getConfigPath(),
|
|
10186
|
+
config: {
|
|
10187
|
+
...config,
|
|
10188
|
+
api: config.api ? { ...config.api, token: config.api.token ? "****" : "" } : undefined
|
|
10189
|
+
}
|
|
10190
|
+
};
|
|
9887
10191
|
print2(data, opts, () => console.log(JSON.stringify(data, null, 2)));
|
|
9888
10192
|
});
|
|
9889
|
-
configCmd.command("set <key> <value>").description("Set config value: default-domain, public-base-url, cloudflare-account-id, cloudflare-worker-name, cloudflare-origin").option("-j, --json", "Output JSON").action((key, value, opts) => {
|
|
10193
|
+
configCmd.command("set <key> <value>").description("Set config value: mode, default-domain, public-base-url, api-url, api-token, api-token-env, cloudflare-account-id, cloudflare-worker-name, cloudflare-origin").option("-j, --json", "Output JSON").action((key, value, opts) => {
|
|
9890
10194
|
try {
|
|
9891
10195
|
let config = loadConfig();
|
|
9892
10196
|
switch (key) {
|
|
10197
|
+
case "mode": {
|
|
10198
|
+
const mode = value.toLowerCase();
|
|
10199
|
+
if (mode !== "local" && mode !== "remote" && mode !== "api")
|
|
10200
|
+
throw new Error("mode must be local, remote, or api");
|
|
10201
|
+
config = updateConfig({ mode });
|
|
10202
|
+
break;
|
|
10203
|
+
}
|
|
9893
10204
|
case "default-domain":
|
|
9894
10205
|
config = updateConfig({ defaultDomain: value, publicBaseUrl: config.publicBaseUrl || `https://${value}` });
|
|
9895
10206
|
break;
|
|
9896
10207
|
case "public-base-url":
|
|
9897
10208
|
config = updateConfig({ publicBaseUrl: value });
|
|
9898
10209
|
break;
|
|
10210
|
+
case "api-url":
|
|
10211
|
+
config = updateConfig({ api: { baseUrl: value.replace(/\/+$/, "") } });
|
|
10212
|
+
break;
|
|
10213
|
+
case "api-token":
|
|
10214
|
+
config = updateConfig({ api: { token: value } });
|
|
10215
|
+
break;
|
|
10216
|
+
case "api-token-env":
|
|
10217
|
+
config = updateConfig({ api: { tokenEnv: value } });
|
|
10218
|
+
break;
|
|
9899
10219
|
case "cloudflare-account-id":
|
|
9900
10220
|
config = updateConfig({ cloudflare: { accountId: value } });
|
|
9901
10221
|
break;
|
|
@@ -9908,7 +10228,11 @@ configCmd.command("set <key> <value>").description("Set config value: default-do
|
|
|
9908
10228
|
default:
|
|
9909
10229
|
throw new Error(`Unknown config key: ${key}`);
|
|
9910
10230
|
}
|
|
9911
|
-
|
|
10231
|
+
const masked = {
|
|
10232
|
+
...config,
|
|
10233
|
+
api: config.api ? { ...config.api, token: config.api.token ? "****" : "" } : undefined
|
|
10234
|
+
};
|
|
10235
|
+
print2({ path: getConfigPath(), config: masked }, opts, () => console.log(source_default.green(`Set ${key}.`)));
|
|
9912
10236
|
} catch (error) {
|
|
9913
10237
|
handleError(error);
|
|
9914
10238
|
}
|
|
@@ -10103,7 +10427,7 @@ program2.command("stats [slug]").description("Show overall stats or stats for a
|
|
|
10103
10427
|
handleError(error);
|
|
10104
10428
|
}
|
|
10105
10429
|
});
|
|
10106
|
-
program2.command("serve").description("Run the redirect server that records clicks").option("--host <host>", "Bind host", "127.0.0.1").option("--port <port>", "Port", "8787").option("--default-host <hostname>", "Fallback host if the request has no Host header").option("--remote", "Serve directly from the shortlinks PostgreSQL storage database").addOption(new Option("--cloud", "Deprecated alias for --remote").hideHelp()).action(async (opts) => {
|
|
10430
|
+
program2.command("serve").description("Run the redirect server that records clicks").option("--host <host>", "Bind host", "127.0.0.1").option("--port <port>", "Port", "8787").option("--default-host <hostname>", "Fallback host if the request has no Host header").option("--api-path-prefix <path>", "Admin API path prefix", process.env.SHORTLINKS_API_PATH_PREFIX || "/api").option("--remote", "Serve directly from the shortlinks PostgreSQL storage database").addOption(new Option("--cloud", "Deprecated alias for --remote").hideHelp()).action(async (opts) => {
|
|
10107
10431
|
try {
|
|
10108
10432
|
const store = opts.remote || opts.cloud || storeMode() === "remote" ? await PgShortlinksStore.fromStorage("shortlinks") : undefined;
|
|
10109
10433
|
const server = serveShortlinks({
|
|
@@ -10111,7 +10435,8 @@ program2.command("serve").description("Run the redirect server that records clic
|
|
|
10111
10435
|
dbPath: program2.opts().db,
|
|
10112
10436
|
host: opts.host,
|
|
10113
10437
|
port: Number(opts.port),
|
|
10114
|
-
defaultHost: opts.defaultHost
|
|
10438
|
+
defaultHost: opts.defaultHost,
|
|
10439
|
+
apiPathPrefix: opts.apiPathPrefix
|
|
10115
10440
|
});
|
|
10116
10441
|
const mode = store ? "remote" : "local";
|
|
10117
10442
|
console.log(source_default.green(`shortlinks redirect server listening on http://${server.hostname}:${server.port} (${mode})`));
|
|
@@ -10134,9 +10459,14 @@ cfCmd.command("plan <hostname>").description("Print the Cloudflare setup plan").
|
|
|
10134
10459
|
handleError(error);
|
|
10135
10460
|
}
|
|
10136
10461
|
});
|
|
10137
|
-
cfCmd.command("worker").description("Write Cloudflare Worker files").option("--out-dir <dir>", "Output directory", "cloudflare").option("--worker <name>", "Worker name", "shortlinks").option("--origin <url>", "Origin redirect server URL", process.env.SHORTLINKS_ORIGIN || "https://shortlinks.example.com").option("-j, --json", "Output JSON").action((opts) => {
|
|
10462
|
+
cfCmd.command("worker").description("Write Cloudflare Worker files").option("--out-dir <dir>", "Output directory", "cloudflare").option("--worker <name>", "Worker name", "shortlinks").option("--origin <url>", "Origin redirect server URL", process.env.SHORTLINKS_ORIGIN || "https://shortlinks.example.com").option("--attachments-origin <url>", "Origin URL for reserved attachment paths", process.env.ATTACHMENTS_ORIGIN).option("-j, --json", "Output JSON").action((opts) => {
|
|
10138
10463
|
try {
|
|
10139
|
-
const result = writeWorkerFiles({
|
|
10464
|
+
const result = writeWorkerFiles({
|
|
10465
|
+
outDir: opts.outDir,
|
|
10466
|
+
workerName: opts.worker,
|
|
10467
|
+
origin: opts.origin,
|
|
10468
|
+
attachmentsOrigin: opts.attachmentsOrigin
|
|
10469
|
+
});
|
|
10140
10470
|
print2(result, opts, () => {
|
|
10141
10471
|
console.log(source_default.green(`Wrote ${result.workerPath}`));
|
|
10142
10472
|
console.log(source_default.green(`Wrote ${result.wranglerPath}`));
|
package/dist/cloudflare.d.ts
CHANGED
package/dist/cloudflare.js
CHANGED
|
@@ -56,26 +56,33 @@ function generateWorkerScript() {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
const incoming = new URL(request.url);
|
|
59
|
+
const proxyTo = (targetOrigin, marker) => {
|
|
60
|
+
const upstream = new URL(incoming.pathname + incoming.search, targetOrigin);
|
|
61
|
+
const headers = new Headers(request.headers);
|
|
62
|
+
headers.set("x-forwarded-host", incoming.host);
|
|
63
|
+
headers.set("x-shortlinks-worker", marker);
|
|
64
|
+
|
|
65
|
+
return fetch(upstream.toString(), {
|
|
66
|
+
method: request.method,
|
|
67
|
+
headers,
|
|
68
|
+
body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
|
|
69
|
+
redirect: "manual"
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
59
73
|
const reserved = (env.SHORTLINKS_RESERVED_PATH_PREFIXES || "a")
|
|
60
74
|
.split(",")
|
|
61
75
|
.map((value) => value.trim().toLowerCase())
|
|
62
76
|
.filter(Boolean);
|
|
63
77
|
const firstSegment = decodeURIComponent(incoming.pathname.replace(/^\\/+/, "").split("/")[0] || "").toLowerCase();
|
|
64
78
|
if (firstSegment && reserved.includes(firstSegment)) {
|
|
79
|
+
if (env.ATTACHMENTS_ORIGIN) {
|
|
80
|
+
return proxyTo(env.ATTACHMENTS_ORIGIN, "attachments");
|
|
81
|
+
}
|
|
65
82
|
return new Response("Reserved path prefix", { status: 404 });
|
|
66
83
|
}
|
|
67
84
|
|
|
68
|
-
|
|
69
|
-
const headers = new Headers(request.headers);
|
|
70
|
-
headers.set("x-forwarded-host", incoming.host);
|
|
71
|
-
headers.set("x-shortlinks-worker", "cloudflare");
|
|
72
|
-
|
|
73
|
-
return fetch(upstream.toString(), {
|
|
74
|
-
method: request.method,
|
|
75
|
-
headers,
|
|
76
|
-
body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
|
|
77
|
-
redirect: "manual"
|
|
78
|
-
});
|
|
85
|
+
return proxyTo(origin, "cloudflare");
|
|
79
86
|
}
|
|
80
87
|
};
|
|
81
88
|
`;
|
|
@@ -93,6 +100,7 @@ compatibility_date = "2026-05-01"
|
|
|
93
100
|
|
|
94
101
|
[vars]
|
|
95
102
|
SHORTLINKS_ORIGIN = "${options.origin || "https://shortlinks.example.com"}"
|
|
103
|
+
ATTACHMENTS_ORIGIN = "${options.attachmentsOrigin || ""}"
|
|
96
104
|
SHORTLINKS_RESERVED_PATH_PREFIXES = "a"
|
|
97
105
|
`);
|
|
98
106
|
return { workerPath, wranglerPath };
|