@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/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";
|
|
@@ -6315,26 +6596,33 @@ function generateWorkerScript() {
|
|
|
6315
6596
|
}
|
|
6316
6597
|
|
|
6317
6598
|
const incoming = new URL(request.url);
|
|
6599
|
+
const proxyTo = (targetOrigin, marker) => {
|
|
6600
|
+
const upstream = new URL(incoming.pathname + incoming.search, targetOrigin);
|
|
6601
|
+
const headers = new Headers(request.headers);
|
|
6602
|
+
headers.set("x-forwarded-host", incoming.host);
|
|
6603
|
+
headers.set("x-shortlinks-worker", marker);
|
|
6604
|
+
|
|
6605
|
+
return fetch(upstream.toString(), {
|
|
6606
|
+
method: request.method,
|
|
6607
|
+
headers,
|
|
6608
|
+
body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
|
|
6609
|
+
redirect: "manual"
|
|
6610
|
+
});
|
|
6611
|
+
};
|
|
6612
|
+
|
|
6318
6613
|
const reserved = (env.SHORTLINKS_RESERVED_PATH_PREFIXES || "a")
|
|
6319
6614
|
.split(",")
|
|
6320
6615
|
.map((value) => value.trim().toLowerCase())
|
|
6321
6616
|
.filter(Boolean);
|
|
6322
6617
|
const firstSegment = decodeURIComponent(incoming.pathname.replace(/^\\/+/, "").split("/")[0] || "").toLowerCase();
|
|
6323
6618
|
if (firstSegment && reserved.includes(firstSegment)) {
|
|
6619
|
+
if (env.ATTACHMENTS_ORIGIN) {
|
|
6620
|
+
return proxyTo(env.ATTACHMENTS_ORIGIN, "attachments");
|
|
6621
|
+
}
|
|
6324
6622
|
return new Response("Reserved path prefix", { status: 404 });
|
|
6325
6623
|
}
|
|
6326
6624
|
|
|
6327
|
-
|
|
6328
|
-
const headers = new Headers(request.headers);
|
|
6329
|
-
headers.set("x-forwarded-host", incoming.host);
|
|
6330
|
-
headers.set("x-shortlinks-worker", "cloudflare");
|
|
6331
|
-
|
|
6332
|
-
return fetch(upstream.toString(), {
|
|
6333
|
-
method: request.method,
|
|
6334
|
-
headers,
|
|
6335
|
-
body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
|
|
6336
|
-
redirect: "manual"
|
|
6337
|
-
});
|
|
6625
|
+
return proxyTo(origin, "cloudflare");
|
|
6338
6626
|
}
|
|
6339
6627
|
};
|
|
6340
6628
|
`;
|
|
@@ -6352,14 +6640,15 @@ compatibility_date = "2026-05-01"
|
|
|
6352
6640
|
|
|
6353
6641
|
[vars]
|
|
6354
6642
|
SHORTLINKS_ORIGIN = "${options.origin || "https://shortlinks.example.com"}"
|
|
6643
|
+
ATTACHMENTS_ORIGIN = "${options.attachmentsOrigin || ""}"
|
|
6355
6644
|
SHORTLINKS_RESERVED_PATH_PREFIXES = "a"
|
|
6356
6645
|
`);
|
|
6357
6646
|
return { workerPath, wranglerPath };
|
|
6358
6647
|
}
|
|
6359
6648
|
function cloudflareAuthHeaders(token) {
|
|
6360
|
-
const
|
|
6361
|
-
if (
|
|
6362
|
-
return { authorization: `Bearer ${
|
|
6649
|
+
const apiToken2 = token || process.env.CLOUDFLARE_API_TOKEN;
|
|
6650
|
+
if (apiToken2)
|
|
6651
|
+
return { authorization: `Bearer ${apiToken2}` };
|
|
6363
6652
|
const apiKey = process.env.CLOUDFLARE_API_KEY;
|
|
6364
6653
|
const email = process.env.CLOUDFLARE_EMAIL;
|
|
6365
6654
|
if (apiKey && email) {
|
|
@@ -6504,6 +6793,8 @@ export {
|
|
|
6504
6793
|
getConnectionString,
|
|
6505
6794
|
getConfigPath,
|
|
6506
6795
|
getCanonicalShortlinksRdsConfig,
|
|
6796
|
+
getApiToken,
|
|
6797
|
+
getApiBaseUrl,
|
|
6507
6798
|
generateWorkerScript,
|
|
6508
6799
|
formatShortUrl,
|
|
6509
6800
|
createShortlinksHandler,
|
|
@@ -6512,6 +6803,7 @@ export {
|
|
|
6512
6803
|
applyPgMigrations,
|
|
6513
6804
|
ShortlinksStore,
|
|
6514
6805
|
ShortlinksDatabase,
|
|
6806
|
+
ShortlinksApiClient,
|
|
6515
6807
|
STORAGE_TABLES,
|
|
6516
6808
|
STORAGE_MODE_ENV,
|
|
6517
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)
|