@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.
@@ -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
- ...loadConfig(),
2126
+ ...current,
2126
2127
  ...patch,
2128
+ api: {
2129
+ ...current.api,
2130
+ ...patch.api
2131
+ },
2127
2132
  cloudflare: {
2128
- ...loadConfig().cloudflare,
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 apiToken = token || process.env.CLOUDFLARE_API_TOKEN;
9639
- if (apiToken)
9640
- return { authorization: `Bearer ${apiToken}` };
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 value = String(opts.remote ? "remote" : opts.store || process.env.SHORTLINKS_STORE || "local").toLowerCase();
9827
- if (value !== "local" && value !== "remote")
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
- if (storeMode() === "remote") {
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 local", process.env.SHORTLINKS_STORE || "local").addOption(new Option("--remote", "Use the shortlinks PostgreSQL storage database directly")).option("-j, --json", "Output JSON for agents and scripts");
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 data = { path: getConfigPath(), config: loadConfig() };
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
- print2({ path: getConfigPath(), config }, opts, () => console.log(source_default.green(`Set ${key}.`)));
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
- ...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";
@@ -6365,9 +6646,9 @@ SHORTLINKS_RESERVED_PATH_PREFIXES = "a"
6365
6646
  return { workerPath, wranglerPath };
6366
6647
  }
6367
6648
  function cloudflareAuthHeaders(token) {
6368
- const apiToken = token || process.env.CLOUDFLARE_API_TOKEN;
6369
- if (apiToken)
6370
- return { authorization: `Bearer ${apiToken}` };
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
- ...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)
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/shortlinks",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "CLI-only shortlink manager for custom domains, click tracking, Cloudflare setup, and repo-native storage sync",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",