@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/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
- ...loadConfig(),
5141
+ ...current,
5141
5142
  ...patch,
5143
+ api: {
5144
+ ...current.api,
5145
+ ...patch.api
5146
+ },
5142
5147
  cloudflare: {
5143
- ...loadConfig().cloudflare,
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
- const upstream = new URL(incoming.pathname + incoming.search, origin);
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 apiToken = token || process.env.CLOUDFLARE_API_TOKEN;
6361
- if (apiToken)
6362
- return { authorization: `Bearer ${apiToken}` };
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
- ...loadConfig(),
54
+ ...current,
54
55
  ...patch,
56
+ api: {
57
+ ...current.api,
58
+ ...patch.api
59
+ },
55
60
  cloudflare: {
56
- ...loadConfig().cloudflare,
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
- ...loadConfig(),
5144
+ ...current,
5144
5145
  ...patch,
5146
+ api: {
5147
+ ...current.api,
5148
+ ...patch.api
5149
+ },
5145
5150
  cloudflare: {
5146
- ...loadConfig().cloudflare,
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)