@hasna/shortlinks 0.1.14 → 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/dist/api-client.d.ts +30 -0
- package/dist/cli/index.js +331 -14
- package/dist/config.d.ts +8 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +289 -5
- 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 +7 -1
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -9635,9 +9918,9 @@ SHORTLINKS_RESERVED_PATH_PREFIXES = "a"
|
|
|
9635
9918
|
return { workerPath, wranglerPath };
|
|
9636
9919
|
}
|
|
9637
9920
|
function cloudflareAuthHeaders(token) {
|
|
9638
|
-
const
|
|
9639
|
-
if (
|
|
9640
|
-
return { authorization: `Bearer ${
|
|
9921
|
+
const apiToken2 = token || process.env.CLOUDFLARE_API_TOKEN;
|
|
9922
|
+
if (apiToken2)
|
|
9923
|
+
return { authorization: `Bearer ${apiToken2}` };
|
|
9641
9924
|
const apiKey = process.env.CLOUDFLARE_API_KEY;
|
|
9642
9925
|
const email = process.env.CLOUDFLARE_EMAIL;
|
|
9643
9926
|
if (apiKey && email) {
|
|
@@ -9823,13 +10106,19 @@ function withStore(fn) {
|
|
|
9823
10106
|
}
|
|
9824
10107
|
function storeMode() {
|
|
9825
10108
|
const opts = program2.opts();
|
|
9826
|
-
const
|
|
9827
|
-
|
|
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")
|
|
9828
10112
|
throw new Error(`Unknown store mode: ${value}`);
|
|
9829
10113
|
return value;
|
|
9830
10114
|
}
|
|
9831
10115
|
async function withRuntimeStore(fn) {
|
|
9832
|
-
|
|
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") {
|
|
9833
10122
|
const store2 = await PgShortlinksStore.fromStorage("shortlinks");
|
|
9834
10123
|
try {
|
|
9835
10124
|
return await fn(store2);
|
|
@@ -9851,7 +10140,7 @@ function commandExists(command) {
|
|
|
9851
10140
|
const result = spawnSync3("which", [command], { encoding: "utf-8" });
|
|
9852
10141
|
return result.status === 0;
|
|
9853
10142
|
}
|
|
9854
|
-
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");
|
|
9855
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) => {
|
|
9856
10145
|
try {
|
|
9857
10146
|
const result = await withRuntimeStore(async (store) => {
|
|
@@ -9891,19 +10180,42 @@ program2.command("init").description("Initialize local shortlinks storage").opti
|
|
|
9891
10180
|
});
|
|
9892
10181
|
var configCmd = program2.command("config").description("View and update local config");
|
|
9893
10182
|
configCmd.command("show").description("Show local config").option("-j, --json", "Output JSON").action((opts) => {
|
|
9894
|
-
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
|
+
};
|
|
9895
10191
|
print2(data, opts, () => console.log(JSON.stringify(data, null, 2)));
|
|
9896
10192
|
});
|
|
9897
|
-
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) => {
|
|
9898
10194
|
try {
|
|
9899
10195
|
let config = loadConfig();
|
|
9900
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
|
+
}
|
|
9901
10204
|
case "default-domain":
|
|
9902
10205
|
config = updateConfig({ defaultDomain: value, publicBaseUrl: config.publicBaseUrl || `https://${value}` });
|
|
9903
10206
|
break;
|
|
9904
10207
|
case "public-base-url":
|
|
9905
10208
|
config = updateConfig({ publicBaseUrl: value });
|
|
9906
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;
|
|
9907
10219
|
case "cloudflare-account-id":
|
|
9908
10220
|
config = updateConfig({ cloudflare: { accountId: value } });
|
|
9909
10221
|
break;
|
|
@@ -9916,7 +10228,11 @@ configCmd.command("set <key> <value>").description("Set config value: default-do
|
|
|
9916
10228
|
default:
|
|
9917
10229
|
throw new Error(`Unknown config key: ${key}`);
|
|
9918
10230
|
}
|
|
9919
|
-
|
|
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}.`)));
|
|
9920
10236
|
} catch (error) {
|
|
9921
10237
|
handleError(error);
|
|
9922
10238
|
}
|
|
@@ -10111,7 +10427,7 @@ program2.command("stats [slug]").description("Show overall stats or stats for a
|
|
|
10111
10427
|
handleError(error);
|
|
10112
10428
|
}
|
|
10113
10429
|
});
|
|
10114
|
-
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) => {
|
|
10115
10431
|
try {
|
|
10116
10432
|
const store = opts.remote || opts.cloud || storeMode() === "remote" ? await PgShortlinksStore.fromStorage("shortlinks") : undefined;
|
|
10117
10433
|
const server = serveShortlinks({
|
|
@@ -10119,7 +10435,8 @@ program2.command("serve").description("Run the redirect server that records clic
|
|
|
10119
10435
|
dbPath: program2.opts().db,
|
|
10120
10436
|
host: opts.host,
|
|
10121
10437
|
port: Number(opts.port),
|
|
10122
|
-
defaultHost: opts.defaultHost
|
|
10438
|
+
defaultHost: opts.defaultHost,
|
|
10439
|
+
apiPathPrefix: opts.apiPathPrefix
|
|
10123
10440
|
});
|
|
10124
10441
|
const mode = store ? "remote" : "local";
|
|
10125
10442
|
console.log(source_default.green(`shortlinks redirect server listening on http://${server.hostname}:${server.port} (${mode})`));
|
package/dist/config.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ export declare const DEFAULT_DATA_DIR: string;
|
|
|
3
3
|
export interface ShortlinksConfig {
|
|
4
4
|
defaultDomain?: string;
|
|
5
5
|
publicBaseUrl?: string;
|
|
6
|
+
mode?: "local" | "remote" | "api";
|
|
7
|
+
api?: {
|
|
8
|
+
baseUrl?: string;
|
|
9
|
+
token?: string;
|
|
10
|
+
tokenEnv?: string;
|
|
11
|
+
};
|
|
6
12
|
cloudflare?: {
|
|
7
13
|
accountId?: string;
|
|
8
14
|
workerName?: string;
|
|
@@ -16,5 +22,7 @@ export declare function getDatabasePath(explicitPath?: string): string;
|
|
|
16
22
|
export declare function loadConfig(): ShortlinksConfig;
|
|
17
23
|
export declare function saveConfig(config: ShortlinksConfig): void;
|
|
18
24
|
export declare function updateConfig(patch: ShortlinksConfig): ShortlinksConfig;
|
|
25
|
+
export declare function getApiBaseUrl(config?: ShortlinksConfig): string | null;
|
|
26
|
+
export declare function getApiToken(config?: ShortlinksConfig): string | null;
|
|
19
27
|
export declare function normalizeHostname(input: string): string;
|
|
20
28
|
export declare function formatShortUrl(hostname: string, slug: string, publicBaseUrl?: string): string;
|
package/dist/index.d.ts
CHANGED
|
@@ -8,9 +8,10 @@ export { applyPgMigrations } from "./pg-migrate.js";
|
|
|
8
8
|
export { SHORTLINKS_STORAGE_TABLES, STORAGE_TABLES, getStoragePg, getStorageStatus, parseStorageTables, pullStorageChanges, pushStorageChanges, runStorageMigrations, syncStorageChanges, } from "./storage-sync.js";
|
|
9
9
|
export type { StorageStatus, StorageSyncResult, SyncResult } from "./storage-sync.js";
|
|
10
10
|
export { createShortlinksHandler, serveShortlinks } from "./server.js";
|
|
11
|
+
export { ShortlinksApiClient } from "./api-client.js";
|
|
11
12
|
export { createCloudflarePlan, generateWorkerScript, writeWorkerFiles, upsertCloudflareDnsRecord } from "./cloudflare.js";
|
|
12
13
|
export { createLocalSetupPlan, registerMachinesDns } from "./local.js";
|
|
13
14
|
export { PG_MIGRATIONS } from "./pg-migrations.js";
|
|
14
|
-
export { formatShortUrl, getConfigPath, getDataDir, getDatabasePath, loadConfig, normalizeHostname, saveConfig } from "./config.js";
|
|
15
|
+
export { formatShortUrl, getApiBaseUrl, getApiToken, getConfigPath, getDataDir, getDatabasePath, loadConfig, normalizeHostname, saveConfig } from "./config.js";
|
|
15
16
|
export { normalizeSlug, randomToken } from "./slug.js";
|
|
16
17
|
export type { AddDomainInput, Click, ClickInput, CreateLinkInput, Domain, Link, LinkStats } from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -5136,17 +5136,31 @@ function saveConfig(config) {
|
|
|
5136
5136
|
`);
|
|
5137
5137
|
}
|
|
5138
5138
|
function updateConfig(patch) {
|
|
5139
|
+
const current = loadConfig();
|
|
5139
5140
|
const next = {
|
|
5140
|
-
...
|
|
5141
|
+
...current,
|
|
5141
5142
|
...patch,
|
|
5143
|
+
api: {
|
|
5144
|
+
...current.api,
|
|
5145
|
+
...patch.api
|
|
5146
|
+
},
|
|
5142
5147
|
cloudflare: {
|
|
5143
|
-
...
|
|
5148
|
+
...current.cloudflare,
|
|
5144
5149
|
...patch.cloudflare
|
|
5145
5150
|
}
|
|
5146
5151
|
};
|
|
5147
5152
|
saveConfig(next);
|
|
5148
5153
|
return next;
|
|
5149
5154
|
}
|
|
5155
|
+
function getApiBaseUrl(config = loadConfig()) {
|
|
5156
|
+
const baseUrl = process.env.SHORTLINKS_API_URL || process.env.HASNA_SHORTLINKS_API_URL || config.api?.baseUrl || "";
|
|
5157
|
+
return baseUrl ? baseUrl.replace(/\/+$/, "") : null;
|
|
5158
|
+
}
|
|
5159
|
+
function getApiToken(config = loadConfig()) {
|
|
5160
|
+
const envName = config.api?.tokenEnv || "SHORTLINKS_API_TOKEN";
|
|
5161
|
+
const token = process.env[envName] || process.env.SHORTLINKS_API_TOKEN || process.env.HASNA_SHORTLINKS_API_TOKEN || config.api?.token || "";
|
|
5162
|
+
return token || null;
|
|
5163
|
+
}
|
|
5150
5164
|
function normalizeHostname(input) {
|
|
5151
5165
|
const raw = input.trim().toLowerCase();
|
|
5152
5166
|
if (!raw)
|
|
@@ -6212,6 +6226,120 @@ function json(data, status = 200) {
|
|
|
6212
6226
|
headers: { "content-type": "application/json; charset=utf-8" }
|
|
6213
6227
|
});
|
|
6214
6228
|
}
|
|
6229
|
+
function apiToken(options) {
|
|
6230
|
+
return options.apiToken || process.env.SHORTLINKS_API_TOKEN || process.env.HASNA_SHORTLINKS_API_TOKEN || null;
|
|
6231
|
+
}
|
|
6232
|
+
function requestToken(request) {
|
|
6233
|
+
const auth = request.headers.get("authorization") || "";
|
|
6234
|
+
const bearer = auth.toLowerCase().startsWith("bearer ") ? auth.slice(7).trim() : "";
|
|
6235
|
+
return bearer || request.headers.get("x-shortlinks-token") || request.headers.get("x-api-key");
|
|
6236
|
+
}
|
|
6237
|
+
function isAuthorized(request, options) {
|
|
6238
|
+
const token = apiToken(options);
|
|
6239
|
+
if (!token)
|
|
6240
|
+
return true;
|
|
6241
|
+
return requestToken(request) === token;
|
|
6242
|
+
}
|
|
6243
|
+
async function readJsonBody(request) {
|
|
6244
|
+
try {
|
|
6245
|
+
const parsed = await request.json();
|
|
6246
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
6247
|
+
} catch {
|
|
6248
|
+
return {};
|
|
6249
|
+
}
|
|
6250
|
+
}
|
|
6251
|
+
function requireMethod(store, method) {
|
|
6252
|
+
const fn = store[method];
|
|
6253
|
+
if (typeof fn !== "function")
|
|
6254
|
+
throw new Error(`Store does not support ${String(method)}.`);
|
|
6255
|
+
return fn.bind(store);
|
|
6256
|
+
}
|
|
6257
|
+
async function handleApi(request, apiPath, store, options) {
|
|
6258
|
+
if (apiPath === "/health") {
|
|
6259
|
+
return json({ ok: true, service: "shortlinks", api_auth_required: Boolean(apiToken(options)), stats: await store.totalStats() });
|
|
6260
|
+
}
|
|
6261
|
+
if (!isAuthorized(request, options))
|
|
6262
|
+
return json({ error: "Unauthorized" }, 401);
|
|
6263
|
+
const url = new URL(request.url);
|
|
6264
|
+
const segments = apiPath.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
|
|
6265
|
+
try {
|
|
6266
|
+
if (apiPath === "/links" && request.method === "GET") {
|
|
6267
|
+
const listLinks = requireMethod(store, "listLinks");
|
|
6268
|
+
return json(await listLinks({
|
|
6269
|
+
domain: url.searchParams.get("domain") || undefined,
|
|
6270
|
+
activeOnly: url.searchParams.get("active") === "true",
|
|
6271
|
+
limit: Number(url.searchParams.get("limit") || "100")
|
|
6272
|
+
}));
|
|
6273
|
+
}
|
|
6274
|
+
if (apiPath === "/links" && request.method === "POST") {
|
|
6275
|
+
const body = await readJsonBody(request);
|
|
6276
|
+
const destinationUrl = String(body.destination_url || body.url || "");
|
|
6277
|
+
if (!destinationUrl)
|
|
6278
|
+
return json({ error: "destination_url is required" }, 400);
|
|
6279
|
+
const createLink = requireMethod(store, "createLink");
|
|
6280
|
+
return json(await createLink({
|
|
6281
|
+
destinationUrl,
|
|
6282
|
+
domain: typeof body.domain === "string" ? body.domain : undefined,
|
|
6283
|
+
slug: typeof body.slug === "string" ? body.slug : undefined,
|
|
6284
|
+
title: typeof body.title === "string" ? body.title : undefined,
|
|
6285
|
+
expiresAt: typeof body.expires_at === "string" ? body.expires_at : typeof body.expires === "string" ? body.expires : undefined,
|
|
6286
|
+
slugLength: typeof body.length === "number" ? body.length : undefined
|
|
6287
|
+
}), 201);
|
|
6288
|
+
}
|
|
6289
|
+
if (segments[0] === "links" && segments[1] && request.method === "GET") {
|
|
6290
|
+
const getLink = requireMethod(store, "getLink");
|
|
6291
|
+
const domain = url.searchParams.get("domain") || undefined;
|
|
6292
|
+
const link = domain ? await getLink(domain, segments[1]) : await getLink(segments[1]);
|
|
6293
|
+
return link ? json(link) : json({ error: "Link not found." }, 404);
|
|
6294
|
+
}
|
|
6295
|
+
if (segments[0] === "links" && segments[1] && request.method === "DELETE") {
|
|
6296
|
+
const deleteLink = requireMethod(store, "deleteLink");
|
|
6297
|
+
const domain = url.searchParams.get("domain") || undefined;
|
|
6298
|
+
return json(domain ? await deleteLink(domain, segments[1]) : await deleteLink(segments[1]));
|
|
6299
|
+
}
|
|
6300
|
+
if (segments[0] === "links" && segments[1] && segments[2] === "active" && request.method === "POST") {
|
|
6301
|
+
const body = await readJsonBody(request);
|
|
6302
|
+
const setLinkActive = requireMethod(store, "setLinkActive");
|
|
6303
|
+
const domain = url.searchParams.get("domain") || undefined;
|
|
6304
|
+
const active = Boolean(body.active);
|
|
6305
|
+
return json(domain ? await setLinkActive(domain, segments[1], active) : await setLinkActive(segments[1], active));
|
|
6306
|
+
}
|
|
6307
|
+
if (apiPath === "/stats" && request.method === "GET") {
|
|
6308
|
+
return json(await store.totalStats());
|
|
6309
|
+
}
|
|
6310
|
+
if (segments[0] === "stats" && segments[1] && request.method === "GET") {
|
|
6311
|
+
const getStats = requireMethod(store, "getStats");
|
|
6312
|
+
const domain = url.searchParams.get("domain") || undefined;
|
|
6313
|
+
return json(domain ? await getStats(domain, segments[1]) : await getStats(segments[1]));
|
|
6314
|
+
}
|
|
6315
|
+
if (apiPath === "/domains" && request.method === "GET") {
|
|
6316
|
+
const listDomains = requireMethod(store, "listDomains");
|
|
6317
|
+
return json(await listDomains());
|
|
6318
|
+
}
|
|
6319
|
+
if (apiPath === "/domains" && request.method === "POST") {
|
|
6320
|
+
const body = await readJsonBody(request);
|
|
6321
|
+
const hostname2 = String(body.hostname || "");
|
|
6322
|
+
if (!hostname2)
|
|
6323
|
+
return json({ error: "hostname is required" }, 400);
|
|
6324
|
+
const addDomain = requireMethod(store, "addDomain");
|
|
6325
|
+
return json(await addDomain({
|
|
6326
|
+
hostname: hostname2,
|
|
6327
|
+
provider: typeof body.provider === "string" ? body.provider : "manual",
|
|
6328
|
+
defaultDomain: Boolean(body.default_domain || body.default),
|
|
6329
|
+
originUrl: typeof body.origin_url === "string" ? body.origin_url : undefined,
|
|
6330
|
+
notes: typeof body.notes === "string" ? body.notes : undefined
|
|
6331
|
+
}), 201);
|
|
6332
|
+
}
|
|
6333
|
+
if (segments[0] === "domains" && segments[1] && request.method === "GET") {
|
|
6334
|
+
const getDomain = requireMethod(store, "getDomain");
|
|
6335
|
+
const domain = await getDomain(segments[1]);
|
|
6336
|
+
return domain ? json(domain) : json({ error: "Domain not found." }, 404);
|
|
6337
|
+
}
|
|
6338
|
+
} catch (error) {
|
|
6339
|
+
return json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
6340
|
+
}
|
|
6341
|
+
return json({ error: "Not found." }, 404);
|
|
6342
|
+
}
|
|
6215
6343
|
function getHost(request, fallback) {
|
|
6216
6344
|
const forwarded = request.headers.get("x-forwarded-host");
|
|
6217
6345
|
const host = forwarded || request.headers.get("host") || fallback || "";
|
|
@@ -6230,8 +6358,13 @@ function createShortlinksHandler(options = {}) {
|
|
|
6230
6358
|
const store = options.store || new ShortlinksStore(options.dbPath);
|
|
6231
6359
|
const redirectStatus = options.redirectStatus || 302;
|
|
6232
6360
|
const reservedPathPrefixes = new Set((options.reservedPathPrefixes ?? ["a"]).map((prefix) => prefix.toLowerCase()));
|
|
6361
|
+
const apiPathPrefix = (options.apiPathPrefix || process.env.SHORTLINKS_API_PATH_PREFIX || "/api").replace(/\/+$/, "") || "/api";
|
|
6233
6362
|
return async (request) => {
|
|
6234
6363
|
const url = new URL(request.url);
|
|
6364
|
+
if (url.pathname === apiPathPrefix || url.pathname.startsWith(`${apiPathPrefix}/`)) {
|
|
6365
|
+
const apiPath = url.pathname.slice(apiPathPrefix.length) || "/";
|
|
6366
|
+
return handleApi(request, apiPath, store, options);
|
|
6367
|
+
}
|
|
6235
6368
|
if (url.pathname === "/healthz") {
|
|
6236
6369
|
return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
|
|
6237
6370
|
}
|
|
@@ -6283,6 +6416,154 @@ function serveShortlinks(options = {}) {
|
|
|
6283
6416
|
const fetch2 = createShortlinksHandler(options);
|
|
6284
6417
|
return Bun.serve({ hostname: host, port, fetch: fetch2 });
|
|
6285
6418
|
}
|
|
6419
|
+
// src/api-client.ts
|
|
6420
|
+
function normalizeBaseUrl(value) {
|
|
6421
|
+
return value.replace(/\/+$/, "");
|
|
6422
|
+
}
|
|
6423
|
+
function requireBaseUrl(options) {
|
|
6424
|
+
const baseUrl = options.baseUrl || getApiBaseUrl(loadConfig());
|
|
6425
|
+
if (!baseUrl) {
|
|
6426
|
+
throw new Error("Shortlinks API URL is not configured. Run `shortlinks config set api-url <url>` or set SHORTLINKS_API_URL.");
|
|
6427
|
+
}
|
|
6428
|
+
return normalizeBaseUrl(baseUrl);
|
|
6429
|
+
}
|
|
6430
|
+
function requireToken(options) {
|
|
6431
|
+
const token = options.token || getApiToken(loadConfig());
|
|
6432
|
+
if (!token) {
|
|
6433
|
+
throw new Error("Shortlinks API token is not configured. Set SHORTLINKS_API_TOKEN or run `shortlinks config set api-token-env <name>`.");
|
|
6434
|
+
}
|
|
6435
|
+
return token;
|
|
6436
|
+
}
|
|
6437
|
+
async function readJson(response) {
|
|
6438
|
+
const text = await response.text();
|
|
6439
|
+
let parsed = {};
|
|
6440
|
+
if (text) {
|
|
6441
|
+
try {
|
|
6442
|
+
parsed = JSON.parse(text);
|
|
6443
|
+
} catch {
|
|
6444
|
+
parsed = { error: text };
|
|
6445
|
+
}
|
|
6446
|
+
}
|
|
6447
|
+
if (!response.ok) {
|
|
6448
|
+
const message = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `HTTP ${response.status}`;
|
|
6449
|
+
throw new Error(message);
|
|
6450
|
+
}
|
|
6451
|
+
return parsed;
|
|
6452
|
+
}
|
|
6453
|
+
|
|
6454
|
+
class ShortlinksApiClient {
|
|
6455
|
+
options;
|
|
6456
|
+
constructor(options = {}) {
|
|
6457
|
+
this.options = options;
|
|
6458
|
+
}
|
|
6459
|
+
url(path, query) {
|
|
6460
|
+
const url = new URL(`${requireBaseUrl(this.options)}${path}`);
|
|
6461
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
6462
|
+
if (value !== undefined && value !== "")
|
|
6463
|
+
url.searchParams.set(key, String(value));
|
|
6464
|
+
}
|
|
6465
|
+
return url.toString();
|
|
6466
|
+
}
|
|
6467
|
+
headers(extra) {
|
|
6468
|
+
return {
|
|
6469
|
+
authorization: `Bearer ${requireToken(this.options)}`,
|
|
6470
|
+
...extra ?? {}
|
|
6471
|
+
};
|
|
6472
|
+
}
|
|
6473
|
+
async totalStats() {
|
|
6474
|
+
const response = await fetch(this.url("/stats"), { headers: this.headers() });
|
|
6475
|
+
return readJson(response);
|
|
6476
|
+
}
|
|
6477
|
+
async createLink(input) {
|
|
6478
|
+
const response = await fetch(this.url("/links"), {
|
|
6479
|
+
method: "POST",
|
|
6480
|
+
headers: this.headers({ "content-type": "application/json" }),
|
|
6481
|
+
body: JSON.stringify({
|
|
6482
|
+
destination_url: input.destinationUrl,
|
|
6483
|
+
domain: input.domain,
|
|
6484
|
+
slug: input.slug,
|
|
6485
|
+
title: input.title,
|
|
6486
|
+
expires_at: input.expiresAt,
|
|
6487
|
+
length: input.slugLength
|
|
6488
|
+
})
|
|
6489
|
+
});
|
|
6490
|
+
return readJson(response);
|
|
6491
|
+
}
|
|
6492
|
+
async listLinks(options = {}) {
|
|
6493
|
+
const response = await fetch(this.url("/links", {
|
|
6494
|
+
domain: options.domain,
|
|
6495
|
+
active: options.activeOnly,
|
|
6496
|
+
limit: options.limit
|
|
6497
|
+
}), { headers: this.headers() });
|
|
6498
|
+
return readJson(response);
|
|
6499
|
+
}
|
|
6500
|
+
async getLink(domainOrSlug, maybeSlug) {
|
|
6501
|
+
const slug = maybeSlug ?? domainOrSlug;
|
|
6502
|
+
const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}`, {
|
|
6503
|
+
domain: maybeSlug ? domainOrSlug : undefined
|
|
6504
|
+
}), { headers: this.headers() });
|
|
6505
|
+
if (response.status === 404)
|
|
6506
|
+
return null;
|
|
6507
|
+
return readJson(response);
|
|
6508
|
+
}
|
|
6509
|
+
async setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
|
|
6510
|
+
const hasDomain = typeof maybeSlugOrActive === "string";
|
|
6511
|
+
const slug = hasDomain ? maybeSlugOrActive : domainOrSlug;
|
|
6512
|
+
const active = hasDomain ? Boolean(maybeActive) : Boolean(maybeSlugOrActive);
|
|
6513
|
+
const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}/active`, {
|
|
6514
|
+
domain: hasDomain ? domainOrSlug : undefined
|
|
6515
|
+
}), {
|
|
6516
|
+
method: "POST",
|
|
6517
|
+
headers: this.headers({ "content-type": "application/json" }),
|
|
6518
|
+
body: JSON.stringify({ active })
|
|
6519
|
+
});
|
|
6520
|
+
return readJson(response);
|
|
6521
|
+
}
|
|
6522
|
+
async deleteLink(domainOrSlug, maybeSlug) {
|
|
6523
|
+
const slug = maybeSlug ?? domainOrSlug;
|
|
6524
|
+
const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}`, {
|
|
6525
|
+
domain: maybeSlug ? domainOrSlug : undefined
|
|
6526
|
+
}), {
|
|
6527
|
+
method: "DELETE",
|
|
6528
|
+
headers: this.headers()
|
|
6529
|
+
});
|
|
6530
|
+
return readJson(response);
|
|
6531
|
+
}
|
|
6532
|
+
async getStats(domainOrSlug, maybeSlug) {
|
|
6533
|
+
const slug = maybeSlug ?? domainOrSlug;
|
|
6534
|
+
const response = await fetch(this.url(`/stats/${encodeURIComponent(slug)}`, {
|
|
6535
|
+
domain: maybeSlug ? domainOrSlug : undefined
|
|
6536
|
+
}), { headers: this.headers() });
|
|
6537
|
+
return readJson(response);
|
|
6538
|
+
}
|
|
6539
|
+
async addDomain(input) {
|
|
6540
|
+
const response = await fetch(this.url("/domains"), {
|
|
6541
|
+
method: "POST",
|
|
6542
|
+
headers: this.headers({ "content-type": "application/json" }),
|
|
6543
|
+
body: JSON.stringify({
|
|
6544
|
+
hostname: input.hostname,
|
|
6545
|
+
provider: input.provider,
|
|
6546
|
+
default_domain: input.defaultDomain,
|
|
6547
|
+
origin_url: input.originUrl,
|
|
6548
|
+
notes: input.notes
|
|
6549
|
+
})
|
|
6550
|
+
});
|
|
6551
|
+
return readJson(response);
|
|
6552
|
+
}
|
|
6553
|
+
async listDomains() {
|
|
6554
|
+
const response = await fetch(this.url("/domains"), { headers: this.headers() });
|
|
6555
|
+
return readJson(response);
|
|
6556
|
+
}
|
|
6557
|
+
async getDomain(hostname2) {
|
|
6558
|
+
const response = await fetch(this.url(`/domains/${encodeURIComponent(hostname2)}`), { headers: this.headers() });
|
|
6559
|
+
if (response.status === 404)
|
|
6560
|
+
return null;
|
|
6561
|
+
return readJson(response);
|
|
6562
|
+
}
|
|
6563
|
+
async close() {
|
|
6564
|
+
return;
|
|
6565
|
+
}
|
|
6566
|
+
}
|
|
6286
6567
|
// src/cloudflare.ts
|
|
6287
6568
|
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
6288
6569
|
import { join as join4 } from "path";
|
|
@@ -6365,9 +6646,9 @@ SHORTLINKS_RESERVED_PATH_PREFIXES = "a"
|
|
|
6365
6646
|
return { workerPath, wranglerPath };
|
|
6366
6647
|
}
|
|
6367
6648
|
function cloudflareAuthHeaders(token) {
|
|
6368
|
-
const
|
|
6369
|
-
if (
|
|
6370
|
-
return { authorization: `Bearer ${
|
|
6649
|
+
const apiToken2 = token || process.env.CLOUDFLARE_API_TOKEN;
|
|
6650
|
+
if (apiToken2)
|
|
6651
|
+
return { authorization: `Bearer ${apiToken2}` };
|
|
6371
6652
|
const apiKey = process.env.CLOUDFLARE_API_KEY;
|
|
6372
6653
|
const email = process.env.CLOUDFLARE_EMAIL;
|
|
6373
6654
|
if (apiKey && email) {
|
|
@@ -6512,6 +6793,8 @@ export {
|
|
|
6512
6793
|
getConnectionString,
|
|
6513
6794
|
getConfigPath,
|
|
6514
6795
|
getCanonicalShortlinksRdsConfig,
|
|
6796
|
+
getApiToken,
|
|
6797
|
+
getApiBaseUrl,
|
|
6515
6798
|
generateWorkerScript,
|
|
6516
6799
|
formatShortUrl,
|
|
6517
6800
|
createShortlinksHandler,
|
|
@@ -6520,6 +6803,7 @@ export {
|
|
|
6520
6803
|
applyPgMigrations,
|
|
6521
6804
|
ShortlinksStore,
|
|
6522
6805
|
ShortlinksDatabase,
|
|
6806
|
+
ShortlinksApiClient,
|
|
6523
6807
|
STORAGE_TABLES,
|
|
6524
6808
|
STORAGE_MODE_ENV,
|
|
6525
6809
|
STORAGE_DATABASE_ENV,
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ClickInput, Link } from "./types.js";
|
|
1
|
+
import type { AddDomainInput, ClickInput, CreateLinkInput, Domain, Link, LinkStats } from "./types.js";
|
|
2
2
|
export interface ShortlinksRuntimeStore {
|
|
3
3
|
totalStats(): {
|
|
4
4
|
domains: number;
|
|
@@ -11,6 +11,19 @@ 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
|
+
createLink?(input: CreateLinkInput): Link | Promise<Link>;
|
|
15
|
+
listLinks?(input?: {
|
|
16
|
+
domain?: string;
|
|
17
|
+
activeOnly?: boolean;
|
|
18
|
+
limit?: number;
|
|
19
|
+
}): Link[] | Promise<Link[]>;
|
|
20
|
+
getLink?(hostnameOrSlug: string, slug?: string): Link | null | Promise<Link | null>;
|
|
21
|
+
setLinkActive?(hostnameOrSlug: string, slugOrActive: string | boolean, maybeActive?: boolean): Link | Promise<Link>;
|
|
22
|
+
deleteLink?(hostnameOrSlug: string, slug?: string): Link | Promise<Link>;
|
|
23
|
+
getStats?(hostnameOrSlug: string, slug?: string): LinkStats | Promise<LinkStats>;
|
|
24
|
+
addDomain?(input: AddDomainInput): Domain | Promise<Domain>;
|
|
25
|
+
listDomains?(): Domain[] | Promise<Domain[]>;
|
|
26
|
+
getDomain?(hostname: string): Domain | null | Promise<Domain | null>;
|
|
14
27
|
}
|
|
15
28
|
export interface ShortlinksHandlerOptions {
|
|
16
29
|
store?: ShortlinksRuntimeStore;
|
|
@@ -18,6 +31,8 @@ export interface ShortlinksHandlerOptions {
|
|
|
18
31
|
defaultHost?: string;
|
|
19
32
|
redirectStatus?: 301 | 302 | 307 | 308;
|
|
20
33
|
reservedPathPrefixes?: string[];
|
|
34
|
+
apiPathPrefix?: string;
|
|
35
|
+
apiToken?: string | null;
|
|
21
36
|
}
|
|
22
37
|
export declare function createShortlinksHandler(options?: ShortlinksHandlerOptions): (request: Request) => Response | Promise<Response>;
|
|
23
38
|
export declare function serveShortlinks(options?: ShortlinksHandlerOptions & {
|
package/dist/server.js
CHANGED
|
@@ -49,11 +49,16 @@ function saveConfig(config) {
|
|
|
49
49
|
`);
|
|
50
50
|
}
|
|
51
51
|
function updateConfig(patch) {
|
|
52
|
+
const current = loadConfig();
|
|
52
53
|
const next = {
|
|
53
|
-
...
|
|
54
|
+
...current,
|
|
54
55
|
...patch,
|
|
56
|
+
api: {
|
|
57
|
+
...current.api,
|
|
58
|
+
...patch.api
|
|
59
|
+
},
|
|
55
60
|
cloudflare: {
|
|
56
|
-
...
|
|
61
|
+
...current.cloudflare,
|
|
57
62
|
...patch.cloudflare
|
|
58
63
|
}
|
|
59
64
|
};
|
|
@@ -538,6 +543,120 @@ function json(data, status = 200) {
|
|
|
538
543
|
headers: { "content-type": "application/json; charset=utf-8" }
|
|
539
544
|
});
|
|
540
545
|
}
|
|
546
|
+
function apiToken(options) {
|
|
547
|
+
return options.apiToken || process.env.SHORTLINKS_API_TOKEN || process.env.HASNA_SHORTLINKS_API_TOKEN || null;
|
|
548
|
+
}
|
|
549
|
+
function requestToken(request) {
|
|
550
|
+
const auth = request.headers.get("authorization") || "";
|
|
551
|
+
const bearer = auth.toLowerCase().startsWith("bearer ") ? auth.slice(7).trim() : "";
|
|
552
|
+
return bearer || request.headers.get("x-shortlinks-token") || request.headers.get("x-api-key");
|
|
553
|
+
}
|
|
554
|
+
function isAuthorized(request, options) {
|
|
555
|
+
const token = apiToken(options);
|
|
556
|
+
if (!token)
|
|
557
|
+
return true;
|
|
558
|
+
return requestToken(request) === token;
|
|
559
|
+
}
|
|
560
|
+
async function readJsonBody(request) {
|
|
561
|
+
try {
|
|
562
|
+
const parsed = await request.json();
|
|
563
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
564
|
+
} catch {
|
|
565
|
+
return {};
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
function requireMethod(store, method) {
|
|
569
|
+
const fn = store[method];
|
|
570
|
+
if (typeof fn !== "function")
|
|
571
|
+
throw new Error(`Store does not support ${String(method)}.`);
|
|
572
|
+
return fn.bind(store);
|
|
573
|
+
}
|
|
574
|
+
async function handleApi(request, apiPath, store, options) {
|
|
575
|
+
if (apiPath === "/health") {
|
|
576
|
+
return json({ ok: true, service: "shortlinks", api_auth_required: Boolean(apiToken(options)), stats: await store.totalStats() });
|
|
577
|
+
}
|
|
578
|
+
if (!isAuthorized(request, options))
|
|
579
|
+
return json({ error: "Unauthorized" }, 401);
|
|
580
|
+
const url = new URL(request.url);
|
|
581
|
+
const segments = apiPath.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
|
|
582
|
+
try {
|
|
583
|
+
if (apiPath === "/links" && request.method === "GET") {
|
|
584
|
+
const listLinks = requireMethod(store, "listLinks");
|
|
585
|
+
return json(await listLinks({
|
|
586
|
+
domain: url.searchParams.get("domain") || undefined,
|
|
587
|
+
activeOnly: url.searchParams.get("active") === "true",
|
|
588
|
+
limit: Number(url.searchParams.get("limit") || "100")
|
|
589
|
+
}));
|
|
590
|
+
}
|
|
591
|
+
if (apiPath === "/links" && request.method === "POST") {
|
|
592
|
+
const body = await readJsonBody(request);
|
|
593
|
+
const destinationUrl = String(body.destination_url || body.url || "");
|
|
594
|
+
if (!destinationUrl)
|
|
595
|
+
return json({ error: "destination_url is required" }, 400);
|
|
596
|
+
const createLink = requireMethod(store, "createLink");
|
|
597
|
+
return json(await createLink({
|
|
598
|
+
destinationUrl,
|
|
599
|
+
domain: typeof body.domain === "string" ? body.domain : undefined,
|
|
600
|
+
slug: typeof body.slug === "string" ? body.slug : undefined,
|
|
601
|
+
title: typeof body.title === "string" ? body.title : undefined,
|
|
602
|
+
expiresAt: typeof body.expires_at === "string" ? body.expires_at : typeof body.expires === "string" ? body.expires : undefined,
|
|
603
|
+
slugLength: typeof body.length === "number" ? body.length : undefined
|
|
604
|
+
}), 201);
|
|
605
|
+
}
|
|
606
|
+
if (segments[0] === "links" && segments[1] && request.method === "GET") {
|
|
607
|
+
const getLink = requireMethod(store, "getLink");
|
|
608
|
+
const domain = url.searchParams.get("domain") || undefined;
|
|
609
|
+
const link = domain ? await getLink(domain, segments[1]) : await getLink(segments[1]);
|
|
610
|
+
return link ? json(link) : json({ error: "Link not found." }, 404);
|
|
611
|
+
}
|
|
612
|
+
if (segments[0] === "links" && segments[1] && request.method === "DELETE") {
|
|
613
|
+
const deleteLink = requireMethod(store, "deleteLink");
|
|
614
|
+
const domain = url.searchParams.get("domain") || undefined;
|
|
615
|
+
return json(domain ? await deleteLink(domain, segments[1]) : await deleteLink(segments[1]));
|
|
616
|
+
}
|
|
617
|
+
if (segments[0] === "links" && segments[1] && segments[2] === "active" && request.method === "POST") {
|
|
618
|
+
const body = await readJsonBody(request);
|
|
619
|
+
const setLinkActive = requireMethod(store, "setLinkActive");
|
|
620
|
+
const domain = url.searchParams.get("domain") || undefined;
|
|
621
|
+
const active = Boolean(body.active);
|
|
622
|
+
return json(domain ? await setLinkActive(domain, segments[1], active) : await setLinkActive(segments[1], active));
|
|
623
|
+
}
|
|
624
|
+
if (apiPath === "/stats" && request.method === "GET") {
|
|
625
|
+
return json(await store.totalStats());
|
|
626
|
+
}
|
|
627
|
+
if (segments[0] === "stats" && segments[1] && request.method === "GET") {
|
|
628
|
+
const getStats = requireMethod(store, "getStats");
|
|
629
|
+
const domain = url.searchParams.get("domain") || undefined;
|
|
630
|
+
return json(domain ? await getStats(domain, segments[1]) : await getStats(segments[1]));
|
|
631
|
+
}
|
|
632
|
+
if (apiPath === "/domains" && request.method === "GET") {
|
|
633
|
+
const listDomains = requireMethod(store, "listDomains");
|
|
634
|
+
return json(await listDomains());
|
|
635
|
+
}
|
|
636
|
+
if (apiPath === "/domains" && request.method === "POST") {
|
|
637
|
+
const body = await readJsonBody(request);
|
|
638
|
+
const hostname2 = String(body.hostname || "");
|
|
639
|
+
if (!hostname2)
|
|
640
|
+
return json({ error: "hostname is required" }, 400);
|
|
641
|
+
const addDomain = requireMethod(store, "addDomain");
|
|
642
|
+
return json(await addDomain({
|
|
643
|
+
hostname: hostname2,
|
|
644
|
+
provider: typeof body.provider === "string" ? body.provider : "manual",
|
|
645
|
+
defaultDomain: Boolean(body.default_domain || body.default),
|
|
646
|
+
originUrl: typeof body.origin_url === "string" ? body.origin_url : undefined,
|
|
647
|
+
notes: typeof body.notes === "string" ? body.notes : undefined
|
|
648
|
+
}), 201);
|
|
649
|
+
}
|
|
650
|
+
if (segments[0] === "domains" && segments[1] && request.method === "GET") {
|
|
651
|
+
const getDomain = requireMethod(store, "getDomain");
|
|
652
|
+
const domain = await getDomain(segments[1]);
|
|
653
|
+
return domain ? json(domain) : json({ error: "Domain not found." }, 404);
|
|
654
|
+
}
|
|
655
|
+
} catch (error) {
|
|
656
|
+
return json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
657
|
+
}
|
|
658
|
+
return json({ error: "Not found." }, 404);
|
|
659
|
+
}
|
|
541
660
|
function getHost(request, fallback) {
|
|
542
661
|
const forwarded = request.headers.get("x-forwarded-host");
|
|
543
662
|
const host = forwarded || request.headers.get("host") || fallback || "";
|
|
@@ -556,8 +675,13 @@ function createShortlinksHandler(options = {}) {
|
|
|
556
675
|
const store = options.store || new ShortlinksStore(options.dbPath);
|
|
557
676
|
const redirectStatus = options.redirectStatus || 302;
|
|
558
677
|
const reservedPathPrefixes = new Set((options.reservedPathPrefixes ?? ["a"]).map((prefix) => prefix.toLowerCase()));
|
|
678
|
+
const apiPathPrefix = (options.apiPathPrefix || process.env.SHORTLINKS_API_PATH_PREFIX || "/api").replace(/\/+$/, "") || "/api";
|
|
559
679
|
return async (request) => {
|
|
560
680
|
const url = new URL(request.url);
|
|
681
|
+
if (url.pathname === apiPathPrefix || url.pathname.startsWith(`${apiPathPrefix}/`)) {
|
|
682
|
+
const apiPath = url.pathname.slice(apiPathPrefix.length) || "/";
|
|
683
|
+
return handleApi(request, apiPath, store, options);
|
|
684
|
+
}
|
|
561
685
|
if (url.pathname === "/healthz") {
|
|
562
686
|
return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
|
|
563
687
|
}
|
package/dist/storage.js
CHANGED
|
@@ -5139,17 +5139,31 @@ function saveConfig(config) {
|
|
|
5139
5139
|
`);
|
|
5140
5140
|
}
|
|
5141
5141
|
function updateConfig(patch) {
|
|
5142
|
+
const current = loadConfig();
|
|
5142
5143
|
const next = {
|
|
5143
|
-
...
|
|
5144
|
+
...current,
|
|
5144
5145
|
...patch,
|
|
5146
|
+
api: {
|
|
5147
|
+
...current.api,
|
|
5148
|
+
...patch.api
|
|
5149
|
+
},
|
|
5145
5150
|
cloudflare: {
|
|
5146
|
-
...
|
|
5151
|
+
...current.cloudflare,
|
|
5147
5152
|
...patch.cloudflare
|
|
5148
5153
|
}
|
|
5149
5154
|
};
|
|
5150
5155
|
saveConfig(next);
|
|
5151
5156
|
return next;
|
|
5152
5157
|
}
|
|
5158
|
+
function getApiBaseUrl(config = loadConfig()) {
|
|
5159
|
+
const baseUrl = process.env.SHORTLINKS_API_URL || process.env.HASNA_SHORTLINKS_API_URL || config.api?.baseUrl || "";
|
|
5160
|
+
return baseUrl ? baseUrl.replace(/\/+$/, "") : null;
|
|
5161
|
+
}
|
|
5162
|
+
function getApiToken(config = loadConfig()) {
|
|
5163
|
+
const envName = config.api?.tokenEnv || "SHORTLINKS_API_TOKEN";
|
|
5164
|
+
const token = process.env[envName] || process.env.SHORTLINKS_API_TOKEN || process.env.HASNA_SHORTLINKS_API_TOKEN || config.api?.token || "";
|
|
5165
|
+
return token || null;
|
|
5166
|
+
}
|
|
5153
5167
|
function normalizeHostname(input) {
|
|
5154
5168
|
const raw = input.trim().toLowerCase();
|
|
5155
5169
|
if (!raw)
|
|
@@ -9,6 +9,7 @@ export RDS_HOST="${RDS_HOST:-}"
|
|
|
9
9
|
export RDS_USERNAME="${RDS_USERNAME:-}"
|
|
10
10
|
export SHORTLINKS_DOMAIN="${SHORTLINKS_DOMAIN:-}"
|
|
11
11
|
export ATTACHMENTS_ORIGIN="${ATTACHMENTS_ORIGIN:-}"
|
|
12
|
+
export SHORTLINKS_API_PATH_PREFIX="${SHORTLINKS_API_PATH_PREFIX:-/_shortlinks/api}"
|
|
12
13
|
|
|
13
14
|
: "${RDS_SECRET_ID:?Set RDS_SECRET_ID to the AWS Secrets Manager secret for the PostgreSQL database_url}"
|
|
14
15
|
: "${SHORTLINKS_DOMAIN:?Set SHORTLINKS_DOMAIN to the public host served by Caddy}"
|
|
@@ -85,6 +86,7 @@ AWS_REGION=${AWS_REGION}
|
|
|
85
86
|
RDS_SECRET_ID=${RDS_SECRET_ID}
|
|
86
87
|
SHORTLINKS_DOMAIN=${SHORTLINKS_DOMAIN}
|
|
87
88
|
SHORTLINKS_STORE=remote
|
|
89
|
+
SHORTLINKS_API_PATH_PREFIX=${SHORTLINKS_API_PATH_PREFIX}
|
|
88
90
|
SHORTLINKS_ENV
|
|
89
91
|
chmod 640 /etc/default/shortlinks
|
|
90
92
|
chown root:shortlinks /etc/default/shortlinks
|
|
@@ -114,7 +116,7 @@ WorkingDirectory=/var/lib/shortlinks
|
|
|
114
116
|
Environment=HOME=/var/lib/shortlinks
|
|
115
117
|
Environment=PATH=/var/lib/shortlinks/.bun/bin:/usr/local/bin:/usr/bin:/bin
|
|
116
118
|
EnvironmentFile=/etc/default/shortlinks
|
|
117
|
-
ExecStart=/usr/local/bin/shortlinks-env-exec shortlinks serve --remote --host 127.0.0.1 --port 8787 --default-host ${SHORTLINKS_DOMAIN}
|
|
119
|
+
ExecStart=/usr/local/bin/shortlinks-env-exec shortlinks serve --remote --host 127.0.0.1 --port 8787 --default-host ${SHORTLINKS_DOMAIN} --api-path-prefix ${SHORTLINKS_API_PATH_PREFIX}
|
|
118
120
|
Restart=always
|
|
119
121
|
RestartSec=5
|
|
120
122
|
|
|
@@ -159,6 +161,10 @@ ${SHORTLINKS_DOMAIN} {
|
|
|
159
161
|
reverse_proxy ${ATTACHMENTS_ORIGIN}
|
|
160
162
|
}
|
|
161
163
|
|
|
164
|
+
handle /_shortlinks/* {
|
|
165
|
+
reverse_proxy 127.0.0.1:8787
|
|
166
|
+
}
|
|
167
|
+
|
|
162
168
|
handle {
|
|
163
169
|
reverse_proxy 127.0.0.1:8787
|
|
164
170
|
}
|
package/package.json
CHANGED