@hasna/shortlinks 0.1.14 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-client.d.ts +30 -0
- package/dist/cli/index.js +416 -23
- package/dist/config.d.ts +8 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +362 -12
- package/dist/pg-store.d.ts +1 -0
- package/dist/server.d.ts +17 -1
- package/dist/server.js +165 -6
- package/dist/storage.js +24 -2
- package/dist/store.d.ts +1 -0
- package/dist/types.d.ts +3 -0
- package/infra/aws-ec2-user-data.sh +7 -1
- package/package.json +1 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AddDomainInput, CreateLinkInput, Domain, Link, LinkStats } from "./types.js";
|
|
2
|
+
export interface ShortlinksApiClientOptions {
|
|
3
|
+
baseUrl?: string;
|
|
4
|
+
token?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare class ShortlinksApiClient {
|
|
7
|
+
private readonly options;
|
|
8
|
+
constructor(options?: ShortlinksApiClientOptions);
|
|
9
|
+
private url;
|
|
10
|
+
private headers;
|
|
11
|
+
totalStats(): Promise<{
|
|
12
|
+
domains: number;
|
|
13
|
+
links: number;
|
|
14
|
+
clicks: number;
|
|
15
|
+
}>;
|
|
16
|
+
createLink(input: CreateLinkInput): Promise<Link>;
|
|
17
|
+
listLinks(options?: {
|
|
18
|
+
domain?: string;
|
|
19
|
+
activeOnly?: boolean;
|
|
20
|
+
limit?: number;
|
|
21
|
+
}): Promise<Link[]>;
|
|
22
|
+
getLink(domainOrSlug: string, maybeSlug?: string): Promise<Link | null>;
|
|
23
|
+
setLinkActive(domainOrSlug: string, maybeSlugOrActive: string | boolean, maybeActive?: boolean): Promise<Link>;
|
|
24
|
+
deleteLink(domainOrSlug: string, maybeSlug?: string): Promise<Link>;
|
|
25
|
+
getStats(domainOrSlug: string, maybeSlug?: string): Promise<LinkStats>;
|
|
26
|
+
addDomain(input: AddDomainInput): Promise<Domain>;
|
|
27
|
+
listDomains(): Promise<Domain[]>;
|
|
28
|
+
getDomain(hostname: string): Promise<Domain | null>;
|
|
29
|
+
close(): Promise<void>;
|
|
30
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -2121,17 +2121,31 @@ function saveConfig(config) {
|
|
|
2121
2121
|
`);
|
|
2122
2122
|
}
|
|
2123
2123
|
function updateConfig(patch) {
|
|
2124
|
+
const current = loadConfig();
|
|
2124
2125
|
const next = {
|
|
2125
|
-
...
|
|
2126
|
+
...current,
|
|
2126
2127
|
...patch,
|
|
2128
|
+
api: {
|
|
2129
|
+
...current.api,
|
|
2130
|
+
...patch.api
|
|
2131
|
+
},
|
|
2127
2132
|
cloudflare: {
|
|
2128
|
-
...
|
|
2133
|
+
...current.cloudflare,
|
|
2129
2134
|
...patch.cloudflare
|
|
2130
2135
|
}
|
|
2131
2136
|
};
|
|
2132
2137
|
saveConfig(next);
|
|
2133
2138
|
return next;
|
|
2134
2139
|
}
|
|
2140
|
+
function getApiBaseUrl(config = loadConfig()) {
|
|
2141
|
+
const baseUrl = process.env.SHORTLINKS_API_URL || process.env.HASNA_SHORTLINKS_API_URL || config.api?.baseUrl || "";
|
|
2142
|
+
return baseUrl ? baseUrl.replace(/\/+$/, "") : null;
|
|
2143
|
+
}
|
|
2144
|
+
function getApiToken(config = loadConfig()) {
|
|
2145
|
+
const envName = config.api?.tokenEnv || "SHORTLINKS_API_TOKEN";
|
|
2146
|
+
const token = process.env[envName] || process.env.SHORTLINKS_API_TOKEN || process.env.HASNA_SHORTLINKS_API_TOKEN || config.api?.token || "";
|
|
2147
|
+
return token || null;
|
|
2148
|
+
}
|
|
2135
2149
|
function normalizeHostname(input) {
|
|
2136
2150
|
const raw = input.trim().toLowerCase();
|
|
2137
2151
|
if (!raw)
|
|
@@ -2273,6 +2287,10 @@ var init_database = __esm(() => {
|
|
|
2273
2287
|
CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
|
|
2274
2288
|
CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
|
|
2275
2289
|
CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
|
|
2290
|
+
`,
|
|
2291
|
+
`
|
|
2292
|
+
ALTER TABLE links ADD COLUMN max_uses INTEGER;
|
|
2293
|
+
ALTER TABLE links ADD COLUMN used_count INTEGER NOT NULL DEFAULT 0;
|
|
2276
2294
|
`
|
|
2277
2295
|
];
|
|
2278
2296
|
});
|
|
@@ -7385,6 +7403,10 @@ var init_pg_migrations = __esm(() => {
|
|
|
7385
7403
|
CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
|
|
7386
7404
|
CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
|
|
7387
7405
|
CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
|
|
7406
|
+
`,
|
|
7407
|
+
`
|
|
7408
|
+
ALTER TABLE links ADD COLUMN IF NOT EXISTS max_uses INTEGER;
|
|
7409
|
+
ALTER TABLE links ADD COLUMN IF NOT EXISTS used_count INTEGER NOT NULL DEFAULT 0;
|
|
7388
7410
|
`
|
|
7389
7411
|
];
|
|
7390
7412
|
});
|
|
@@ -8878,6 +8900,8 @@ function linkFromRow(row) {
|
|
|
8878
8900
|
return {
|
|
8879
8901
|
...row,
|
|
8880
8902
|
active: Boolean(row.active),
|
|
8903
|
+
max_uses: row.max_uses ?? null,
|
|
8904
|
+
used_count: row.used_count ?? 0,
|
|
8881
8905
|
metadata: parseJsonObject2(row.metadata),
|
|
8882
8906
|
short_url: formatShortUrl(row.hostname, row.slug, publicBaseUrl)
|
|
8883
8907
|
};
|
|
@@ -8908,6 +8932,13 @@ function isoOrNull(input) {
|
|
|
8908
8932
|
throw new Error(`Invalid date: ${input}`);
|
|
8909
8933
|
return date.toISOString();
|
|
8910
8934
|
}
|
|
8935
|
+
function normalizeMaxUses(value) {
|
|
8936
|
+
if (value === null || value === undefined)
|
|
8937
|
+
return null;
|
|
8938
|
+
if (!Number.isInteger(value) || value <= 0)
|
|
8939
|
+
throw new Error("maxUses must be a positive integer.");
|
|
8940
|
+
return value;
|
|
8941
|
+
}
|
|
8911
8942
|
|
|
8912
8943
|
class ShortlinksStore {
|
|
8913
8944
|
database;
|
|
@@ -8983,15 +9014,16 @@ class ShortlinksStore {
|
|
|
8983
9014
|
const timestamp = now2();
|
|
8984
9015
|
const machineId = getMachineId();
|
|
8985
9016
|
const expiresAt = isoOrNull(input.expiresAt);
|
|
9017
|
+
const maxUses = normalizeMaxUses(input.maxUses);
|
|
8986
9018
|
const slug = input.slug ? normalizeSlug(input.slug) : this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
|
|
8987
9019
|
try {
|
|
8988
9020
|
this.database.db.query(`
|
|
8989
9021
|
INSERT INTO links (
|
|
8990
|
-
id, domain_id, slug, destination_url, title, active, expires_at, metadata,
|
|
9022
|
+
id, domain_id, slug, destination_url, title, active, expires_at, max_uses, used_count, metadata,
|
|
8991
9023
|
machine_id, synced_at, created_at, updated_at
|
|
8992
9024
|
)
|
|
8993
|
-
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
|
|
8994
|
-
`).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
|
|
9025
|
+
VALUES (?, ?, ?, ?, ?, 1, ?, ?, 0, ?, ?, NULL, ?, ?)
|
|
9026
|
+
`).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, maxUses, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
|
|
8995
9027
|
} catch (error) {
|
|
8996
9028
|
const message = error instanceof Error ? error.message : String(error);
|
|
8997
9029
|
if (message.includes("UNIQUE")) {
|
|
@@ -9001,6 +9033,19 @@ class ShortlinksStore {
|
|
|
9001
9033
|
}
|
|
9002
9034
|
return this.getLink(domain.hostname, slug);
|
|
9003
9035
|
}
|
|
9036
|
+
consumeLinkUse(link) {
|
|
9037
|
+
const timestamp = now2();
|
|
9038
|
+
const result = this.database.db.query(`
|
|
9039
|
+
UPDATE links
|
|
9040
|
+
SET used_count = used_count + 1, updated_at = ?, synced_at = NULL
|
|
9041
|
+
WHERE id = ?
|
|
9042
|
+
AND active = 1
|
|
9043
|
+
AND (max_uses IS NULL OR used_count < max_uses)
|
|
9044
|
+
`).run(timestamp, link.id);
|
|
9045
|
+
if (result.changes === 0)
|
|
9046
|
+
return null;
|
|
9047
|
+
return this.getLink(link.hostname, link.slug);
|
|
9048
|
+
}
|
|
9004
9049
|
listLinks(options = {}) {
|
|
9005
9050
|
const params = [];
|
|
9006
9051
|
let where = "WHERE 1 = 1";
|
|
@@ -9187,6 +9232,8 @@ function linkFromRow2(row) {
|
|
|
9187
9232
|
return {
|
|
9188
9233
|
...row,
|
|
9189
9234
|
active: Boolean(row.active),
|
|
9235
|
+
max_uses: row.max_uses ?? null,
|
|
9236
|
+
used_count: row.used_count ?? 0,
|
|
9190
9237
|
expires_at: nullableIso(row.expires_at),
|
|
9191
9238
|
synced_at: nullableIso(row.synced_at),
|
|
9192
9239
|
created_at: toIsoString(row.created_at),
|
|
@@ -9215,6 +9262,13 @@ function isoOrNull2(input) {
|
|
|
9215
9262
|
throw new Error(`Invalid date: ${input}`);
|
|
9216
9263
|
return date.toISOString();
|
|
9217
9264
|
}
|
|
9265
|
+
function normalizeMaxUses2(value) {
|
|
9266
|
+
if (value === null || value === undefined)
|
|
9267
|
+
return null;
|
|
9268
|
+
if (!Number.isInteger(value) || value <= 0)
|
|
9269
|
+
throw new Error("maxUses must be a positive integer.");
|
|
9270
|
+
return value;
|
|
9271
|
+
}
|
|
9218
9272
|
function clickFromRow2(row) {
|
|
9219
9273
|
return {
|
|
9220
9274
|
...row,
|
|
@@ -9302,15 +9356,16 @@ class PgShortlinksStore {
|
|
|
9302
9356
|
const timestamp = now2();
|
|
9303
9357
|
const machineId = getMachineId();
|
|
9304
9358
|
const expiresAt = isoOrNull2(input.expiresAt);
|
|
9359
|
+
const maxUses = normalizeMaxUses2(input.maxUses);
|
|
9305
9360
|
const slug = input.slug ? normalizeSlug(input.slug) : await this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
|
|
9306
9361
|
try {
|
|
9307
9362
|
await this.pg.run(`
|
|
9308
9363
|
INSERT INTO links (
|
|
9309
|
-
id, domain_id, slug, destination_url, title, active, expires_at, metadata,
|
|
9364
|
+
id, domain_id, slug, destination_url, title, active, expires_at, max_uses, used_count, metadata,
|
|
9310
9365
|
machine_id, synced_at, created_at, updated_at
|
|
9311
9366
|
)
|
|
9312
|
-
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
|
|
9313
|
-
`, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
|
|
9367
|
+
VALUES (?, ?, ?, ?, ?, 1, ?, ?, 0, ?, ?, NULL, ?, ?)
|
|
9368
|
+
`, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, maxUses, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
|
|
9314
9369
|
} catch (error) {
|
|
9315
9370
|
const message = error instanceof Error ? error.message : String(error);
|
|
9316
9371
|
if (message.includes("unique") || message.includes("duplicate")) {
|
|
@@ -9320,6 +9375,22 @@ class PgShortlinksStore {
|
|
|
9320
9375
|
}
|
|
9321
9376
|
return await this.getLink(domain.hostname, slug);
|
|
9322
9377
|
}
|
|
9378
|
+
async consumeLinkUse(link) {
|
|
9379
|
+
const timestamp = now2();
|
|
9380
|
+
await this.pg.run(`
|
|
9381
|
+
UPDATE links
|
|
9382
|
+
SET used_count = used_count + 1, updated_at = ?, synced_at = NULL
|
|
9383
|
+
WHERE id = ?
|
|
9384
|
+
AND active = 1
|
|
9385
|
+
AND (max_uses IS NULL OR used_count < max_uses)
|
|
9386
|
+
`, timestamp, link.id);
|
|
9387
|
+
const refreshed = await this.getLink(link.hostname, link.slug);
|
|
9388
|
+
if (!refreshed)
|
|
9389
|
+
return null;
|
|
9390
|
+
if (link.max_uses !== null && refreshed.used_count === link.used_count)
|
|
9391
|
+
return null;
|
|
9392
|
+
return refreshed;
|
|
9393
|
+
}
|
|
9323
9394
|
async listLinks(options = {}) {
|
|
9324
9395
|
const params = [];
|
|
9325
9396
|
let where = "WHERE 1 = 1";
|
|
@@ -9473,6 +9544,157 @@ class PgShortlinksStore {
|
|
|
9473
9544
|
// src/cli/index.ts
|
|
9474
9545
|
init_config();
|
|
9475
9546
|
|
|
9547
|
+
// src/api-client.ts
|
|
9548
|
+
init_config();
|
|
9549
|
+
function normalizeBaseUrl(value) {
|
|
9550
|
+
return value.replace(/\/+$/, "");
|
|
9551
|
+
}
|
|
9552
|
+
function requireBaseUrl(options) {
|
|
9553
|
+
const baseUrl = options.baseUrl || getApiBaseUrl(loadConfig());
|
|
9554
|
+
if (!baseUrl) {
|
|
9555
|
+
throw new Error("Shortlinks API URL is not configured. Run `shortlinks config set api-url <url>` or set SHORTLINKS_API_URL.");
|
|
9556
|
+
}
|
|
9557
|
+
return normalizeBaseUrl(baseUrl);
|
|
9558
|
+
}
|
|
9559
|
+
function requireToken(options) {
|
|
9560
|
+
const token = options.token || getApiToken(loadConfig());
|
|
9561
|
+
if (!token) {
|
|
9562
|
+
throw new Error("Shortlinks API token is not configured. Set SHORTLINKS_API_TOKEN or run `shortlinks config set api-token-env <name>`.");
|
|
9563
|
+
}
|
|
9564
|
+
return token;
|
|
9565
|
+
}
|
|
9566
|
+
async function readJson(response) {
|
|
9567
|
+
const text = await response.text();
|
|
9568
|
+
let parsed = {};
|
|
9569
|
+
if (text) {
|
|
9570
|
+
try {
|
|
9571
|
+
parsed = JSON.parse(text);
|
|
9572
|
+
} catch {
|
|
9573
|
+
parsed = { error: text };
|
|
9574
|
+
}
|
|
9575
|
+
}
|
|
9576
|
+
if (!response.ok) {
|
|
9577
|
+
const message = parsed && typeof parsed === "object" && "error" in parsed ? String(parsed.error) : `HTTP ${response.status}`;
|
|
9578
|
+
throw new Error(message);
|
|
9579
|
+
}
|
|
9580
|
+
return parsed;
|
|
9581
|
+
}
|
|
9582
|
+
|
|
9583
|
+
class ShortlinksApiClient {
|
|
9584
|
+
options;
|
|
9585
|
+
constructor(options = {}) {
|
|
9586
|
+
this.options = options;
|
|
9587
|
+
}
|
|
9588
|
+
url(path, query) {
|
|
9589
|
+
const url = new URL(`${requireBaseUrl(this.options)}${path}`);
|
|
9590
|
+
for (const [key, value] of Object.entries(query ?? {})) {
|
|
9591
|
+
if (value !== undefined && value !== "")
|
|
9592
|
+
url.searchParams.set(key, String(value));
|
|
9593
|
+
}
|
|
9594
|
+
return url.toString();
|
|
9595
|
+
}
|
|
9596
|
+
headers(extra) {
|
|
9597
|
+
return {
|
|
9598
|
+
authorization: `Bearer ${requireToken(this.options)}`,
|
|
9599
|
+
...extra ?? {}
|
|
9600
|
+
};
|
|
9601
|
+
}
|
|
9602
|
+
async totalStats() {
|
|
9603
|
+
const response = await fetch(this.url("/stats"), { headers: this.headers() });
|
|
9604
|
+
return readJson(response);
|
|
9605
|
+
}
|
|
9606
|
+
async createLink(input) {
|
|
9607
|
+
const response = await fetch(this.url("/links"), {
|
|
9608
|
+
method: "POST",
|
|
9609
|
+
headers: this.headers({ "content-type": "application/json" }),
|
|
9610
|
+
body: JSON.stringify({
|
|
9611
|
+
destination_url: input.destinationUrl,
|
|
9612
|
+
domain: input.domain,
|
|
9613
|
+
slug: input.slug,
|
|
9614
|
+
title: input.title,
|
|
9615
|
+
expires_at: input.expiresAt,
|
|
9616
|
+
max_uses: input.maxUses,
|
|
9617
|
+
length: input.slugLength
|
|
9618
|
+
})
|
|
9619
|
+
});
|
|
9620
|
+
return readJson(response);
|
|
9621
|
+
}
|
|
9622
|
+
async listLinks(options = {}) {
|
|
9623
|
+
const response = await fetch(this.url("/links", {
|
|
9624
|
+
domain: options.domain,
|
|
9625
|
+
active: options.activeOnly,
|
|
9626
|
+
limit: options.limit
|
|
9627
|
+
}), { headers: this.headers() });
|
|
9628
|
+
return readJson(response);
|
|
9629
|
+
}
|
|
9630
|
+
async getLink(domainOrSlug, maybeSlug) {
|
|
9631
|
+
const slug = maybeSlug ?? domainOrSlug;
|
|
9632
|
+
const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}`, {
|
|
9633
|
+
domain: maybeSlug ? domainOrSlug : undefined
|
|
9634
|
+
}), { headers: this.headers() });
|
|
9635
|
+
if (response.status === 404)
|
|
9636
|
+
return null;
|
|
9637
|
+
return readJson(response);
|
|
9638
|
+
}
|
|
9639
|
+
async setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
|
|
9640
|
+
const hasDomain = typeof maybeSlugOrActive === "string";
|
|
9641
|
+
const slug = hasDomain ? maybeSlugOrActive : domainOrSlug;
|
|
9642
|
+
const active = hasDomain ? Boolean(maybeActive) : Boolean(maybeSlugOrActive);
|
|
9643
|
+
const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}/active`, {
|
|
9644
|
+
domain: hasDomain ? domainOrSlug : undefined
|
|
9645
|
+
}), {
|
|
9646
|
+
method: "POST",
|
|
9647
|
+
headers: this.headers({ "content-type": "application/json" }),
|
|
9648
|
+
body: JSON.stringify({ active })
|
|
9649
|
+
});
|
|
9650
|
+
return readJson(response);
|
|
9651
|
+
}
|
|
9652
|
+
async deleteLink(domainOrSlug, maybeSlug) {
|
|
9653
|
+
const slug = maybeSlug ?? domainOrSlug;
|
|
9654
|
+
const response = await fetch(this.url(`/links/${encodeURIComponent(slug)}`, {
|
|
9655
|
+
domain: maybeSlug ? domainOrSlug : undefined
|
|
9656
|
+
}), {
|
|
9657
|
+
method: "DELETE",
|
|
9658
|
+
headers: this.headers()
|
|
9659
|
+
});
|
|
9660
|
+
return readJson(response);
|
|
9661
|
+
}
|
|
9662
|
+
async getStats(domainOrSlug, maybeSlug) {
|
|
9663
|
+
const slug = maybeSlug ?? domainOrSlug;
|
|
9664
|
+
const response = await fetch(this.url(`/stats/${encodeURIComponent(slug)}`, {
|
|
9665
|
+
domain: maybeSlug ? domainOrSlug : undefined
|
|
9666
|
+
}), { headers: this.headers() });
|
|
9667
|
+
return readJson(response);
|
|
9668
|
+
}
|
|
9669
|
+
async addDomain(input) {
|
|
9670
|
+
const response = await fetch(this.url("/domains"), {
|
|
9671
|
+
method: "POST",
|
|
9672
|
+
headers: this.headers({ "content-type": "application/json" }),
|
|
9673
|
+
body: JSON.stringify({
|
|
9674
|
+
hostname: input.hostname,
|
|
9675
|
+
provider: input.provider,
|
|
9676
|
+
default_domain: input.defaultDomain,
|
|
9677
|
+
origin_url: input.originUrl,
|
|
9678
|
+
notes: input.notes
|
|
9679
|
+
})
|
|
9680
|
+
});
|
|
9681
|
+
return readJson(response);
|
|
9682
|
+
}
|
|
9683
|
+
async listDomains() {
|
|
9684
|
+
const response = await fetch(this.url("/domains"), { headers: this.headers() });
|
|
9685
|
+
return readJson(response);
|
|
9686
|
+
}
|
|
9687
|
+
async getDomain(hostname2) {
|
|
9688
|
+
const response = await fetch(this.url(`/domains/${encodeURIComponent(hostname2)}`), { headers: this.headers() });
|
|
9689
|
+
if (response.status === 404)
|
|
9690
|
+
return null;
|
|
9691
|
+
return readJson(response);
|
|
9692
|
+
}
|
|
9693
|
+
async close() {
|
|
9694
|
+
return;
|
|
9695
|
+
}
|
|
9696
|
+
}
|
|
9697
|
+
|
|
9476
9698
|
// src/server.ts
|
|
9477
9699
|
function json(data, status = 200) {
|
|
9478
9700
|
return new Response(JSON.stringify(data, null, 2), {
|
|
@@ -9480,6 +9702,121 @@ function json(data, status = 200) {
|
|
|
9480
9702
|
headers: { "content-type": "application/json; charset=utf-8" }
|
|
9481
9703
|
});
|
|
9482
9704
|
}
|
|
9705
|
+
function apiToken(options) {
|
|
9706
|
+
return options.apiToken || process.env.SHORTLINKS_API_TOKEN || process.env.HASNA_SHORTLINKS_API_TOKEN || null;
|
|
9707
|
+
}
|
|
9708
|
+
function requestToken(request) {
|
|
9709
|
+
const auth = request.headers.get("authorization") || "";
|
|
9710
|
+
const bearer = auth.toLowerCase().startsWith("bearer ") ? auth.slice(7).trim() : "";
|
|
9711
|
+
return bearer || request.headers.get("x-shortlinks-token") || request.headers.get("x-api-key");
|
|
9712
|
+
}
|
|
9713
|
+
function isAuthorized(request, options) {
|
|
9714
|
+
const token = apiToken(options);
|
|
9715
|
+
if (!token)
|
|
9716
|
+
return true;
|
|
9717
|
+
return requestToken(request) === token;
|
|
9718
|
+
}
|
|
9719
|
+
async function readJsonBody(request) {
|
|
9720
|
+
try {
|
|
9721
|
+
const parsed = await request.json();
|
|
9722
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
9723
|
+
} catch {
|
|
9724
|
+
return {};
|
|
9725
|
+
}
|
|
9726
|
+
}
|
|
9727
|
+
function requireMethod(store, method) {
|
|
9728
|
+
const fn = store[method];
|
|
9729
|
+
if (typeof fn !== "function")
|
|
9730
|
+
throw new Error(`Store does not support ${String(method)}.`);
|
|
9731
|
+
return fn.bind(store);
|
|
9732
|
+
}
|
|
9733
|
+
async function handleApi(request, apiPath, store, options) {
|
|
9734
|
+
if (apiPath === "/health") {
|
|
9735
|
+
return json({ ok: true, service: "shortlinks", api_auth_required: Boolean(apiToken(options)), stats: await store.totalStats() });
|
|
9736
|
+
}
|
|
9737
|
+
if (!isAuthorized(request, options))
|
|
9738
|
+
return json({ error: "Unauthorized" }, 401);
|
|
9739
|
+
const url = new URL(request.url);
|
|
9740
|
+
const segments = apiPath.replace(/^\/+|\/+$/g, "").split("/").filter(Boolean);
|
|
9741
|
+
try {
|
|
9742
|
+
if (apiPath === "/links" && request.method === "GET") {
|
|
9743
|
+
const listLinks = requireMethod(store, "listLinks");
|
|
9744
|
+
return json(await listLinks({
|
|
9745
|
+
domain: url.searchParams.get("domain") || undefined,
|
|
9746
|
+
activeOnly: url.searchParams.get("active") === "true",
|
|
9747
|
+
limit: Number(url.searchParams.get("limit") || "100")
|
|
9748
|
+
}));
|
|
9749
|
+
}
|
|
9750
|
+
if (apiPath === "/links" && request.method === "POST") {
|
|
9751
|
+
const body = await readJsonBody(request);
|
|
9752
|
+
const destinationUrl = String(body.destination_url || body.url || "");
|
|
9753
|
+
if (!destinationUrl)
|
|
9754
|
+
return json({ error: "destination_url is required" }, 400);
|
|
9755
|
+
const createLink = requireMethod(store, "createLink");
|
|
9756
|
+
return json(await createLink({
|
|
9757
|
+
destinationUrl,
|
|
9758
|
+
domain: typeof body.domain === "string" ? body.domain : undefined,
|
|
9759
|
+
slug: typeof body.slug === "string" ? body.slug : undefined,
|
|
9760
|
+
title: typeof body.title === "string" ? body.title : undefined,
|
|
9761
|
+
expiresAt: typeof body.expires_at === "string" ? body.expires_at : typeof body.expires === "string" ? body.expires : undefined,
|
|
9762
|
+
maxUses: typeof body.max_uses === "number" ? body.max_uses : typeof body.maxUses === "number" ? body.maxUses : undefined,
|
|
9763
|
+
slugLength: typeof body.length === "number" ? body.length : undefined
|
|
9764
|
+
}), 201);
|
|
9765
|
+
}
|
|
9766
|
+
if (segments[0] === "links" && segments[1] && request.method === "GET") {
|
|
9767
|
+
const getLink = requireMethod(store, "getLink");
|
|
9768
|
+
const domain = url.searchParams.get("domain") || undefined;
|
|
9769
|
+
const link = domain ? await getLink(domain, segments[1]) : await getLink(segments[1]);
|
|
9770
|
+
return link ? json(link) : json({ error: "Link not found." }, 404);
|
|
9771
|
+
}
|
|
9772
|
+
if (segments[0] === "links" && segments[1] && request.method === "DELETE") {
|
|
9773
|
+
const deleteLink = requireMethod(store, "deleteLink");
|
|
9774
|
+
const domain = url.searchParams.get("domain") || undefined;
|
|
9775
|
+
return json(domain ? await deleteLink(domain, segments[1]) : await deleteLink(segments[1]));
|
|
9776
|
+
}
|
|
9777
|
+
if (segments[0] === "links" && segments[1] && segments[2] === "active" && request.method === "POST") {
|
|
9778
|
+
const body = await readJsonBody(request);
|
|
9779
|
+
const setLinkActive = requireMethod(store, "setLinkActive");
|
|
9780
|
+
const domain = url.searchParams.get("domain") || undefined;
|
|
9781
|
+
const active = Boolean(body.active);
|
|
9782
|
+
return json(domain ? await setLinkActive(domain, segments[1], active) : await setLinkActive(segments[1], active));
|
|
9783
|
+
}
|
|
9784
|
+
if (apiPath === "/stats" && request.method === "GET") {
|
|
9785
|
+
return json(await store.totalStats());
|
|
9786
|
+
}
|
|
9787
|
+
if (segments[0] === "stats" && segments[1] && request.method === "GET") {
|
|
9788
|
+
const getStats = requireMethod(store, "getStats");
|
|
9789
|
+
const domain = url.searchParams.get("domain") || undefined;
|
|
9790
|
+
return json(domain ? await getStats(domain, segments[1]) : await getStats(segments[1]));
|
|
9791
|
+
}
|
|
9792
|
+
if (apiPath === "/domains" && request.method === "GET") {
|
|
9793
|
+
const listDomains = requireMethod(store, "listDomains");
|
|
9794
|
+
return json(await listDomains());
|
|
9795
|
+
}
|
|
9796
|
+
if (apiPath === "/domains" && request.method === "POST") {
|
|
9797
|
+
const body = await readJsonBody(request);
|
|
9798
|
+
const hostname2 = String(body.hostname || "");
|
|
9799
|
+
if (!hostname2)
|
|
9800
|
+
return json({ error: "hostname is required" }, 400);
|
|
9801
|
+
const addDomain = requireMethod(store, "addDomain");
|
|
9802
|
+
return json(await addDomain({
|
|
9803
|
+
hostname: hostname2,
|
|
9804
|
+
provider: typeof body.provider === "string" ? body.provider : "manual",
|
|
9805
|
+
defaultDomain: Boolean(body.default_domain || body.default),
|
|
9806
|
+
originUrl: typeof body.origin_url === "string" ? body.origin_url : undefined,
|
|
9807
|
+
notes: typeof body.notes === "string" ? body.notes : undefined
|
|
9808
|
+
}), 201);
|
|
9809
|
+
}
|
|
9810
|
+
if (segments[0] === "domains" && segments[1] && request.method === "GET") {
|
|
9811
|
+
const getDomain = requireMethod(store, "getDomain");
|
|
9812
|
+
const domain = await getDomain(segments[1]);
|
|
9813
|
+
return domain ? json(domain) : json({ error: "Domain not found." }, 404);
|
|
9814
|
+
}
|
|
9815
|
+
} catch (error) {
|
|
9816
|
+
return json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
9817
|
+
}
|
|
9818
|
+
return json({ error: "Not found." }, 404);
|
|
9819
|
+
}
|
|
9483
9820
|
function getHost(request, fallback) {
|
|
9484
9821
|
const forwarded = request.headers.get("x-forwarded-host");
|
|
9485
9822
|
const host = forwarded || request.headers.get("host") || fallback || "";
|
|
@@ -9498,8 +9835,13 @@ function createShortlinksHandler(options = {}) {
|
|
|
9498
9835
|
const store = options.store || new ShortlinksStore(options.dbPath);
|
|
9499
9836
|
const redirectStatus = options.redirectStatus || 302;
|
|
9500
9837
|
const reservedPathPrefixes = new Set((options.reservedPathPrefixes ?? ["a"]).map((prefix) => prefix.toLowerCase()));
|
|
9838
|
+
const apiPathPrefix = (options.apiPathPrefix || process.env.SHORTLINKS_API_PATH_PREFIX || "/api").replace(/\/+$/, "") || "/api";
|
|
9501
9839
|
return async (request) => {
|
|
9502
9840
|
const url = new URL(request.url);
|
|
9841
|
+
if (url.pathname === apiPathPrefix || url.pathname.startsWith(`${apiPathPrefix}/`)) {
|
|
9842
|
+
const apiPath = url.pathname.slice(apiPathPrefix.length) || "/";
|
|
9843
|
+
return handleApi(request, apiPath, store, options);
|
|
9844
|
+
}
|
|
9503
9845
|
if (url.pathname === "/healthz") {
|
|
9504
9846
|
return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
|
|
9505
9847
|
}
|
|
@@ -9532,7 +9874,14 @@ function createShortlinksHandler(options = {}) {
|
|
|
9532
9874
|
return json({ error: "Shortlink is disabled.", slug, host }, 410);
|
|
9533
9875
|
if (isExpired(link))
|
|
9534
9876
|
return json({ error: "Shortlink is expired.", slug, host }, 410);
|
|
9535
|
-
|
|
9877
|
+
if (link.max_uses !== null && link.used_count >= link.max_uses) {
|
|
9878
|
+
return json({ error: "Shortlink max uses reached.", slug, host }, 410);
|
|
9879
|
+
}
|
|
9880
|
+
const consumed = store.consumeLinkUse ? await store.consumeLinkUse(link) : link;
|
|
9881
|
+
if (!consumed) {
|
|
9882
|
+
return json({ error: "Shortlink max uses reached.", slug, host }, 410);
|
|
9883
|
+
}
|
|
9884
|
+
await store.recordClick(consumed, {
|
|
9536
9885
|
ip: getClientIp(request),
|
|
9537
9886
|
userAgent: request.headers.get("user-agent"),
|
|
9538
9887
|
referer: request.headers.get("referer"),
|
|
@@ -9635,9 +9984,9 @@ SHORTLINKS_RESERVED_PATH_PREFIXES = "a"
|
|
|
9635
9984
|
return { workerPath, wranglerPath };
|
|
9636
9985
|
}
|
|
9637
9986
|
function cloudflareAuthHeaders(token) {
|
|
9638
|
-
const
|
|
9639
|
-
if (
|
|
9640
|
-
return { authorization: `Bearer ${
|
|
9987
|
+
const apiToken2 = token || process.env.CLOUDFLARE_API_TOKEN;
|
|
9988
|
+
if (apiToken2)
|
|
9989
|
+
return { authorization: `Bearer ${apiToken2}` };
|
|
9641
9990
|
const apiKey = process.env.CLOUDFLARE_API_KEY;
|
|
9642
9991
|
const email = process.env.CLOUDFLARE_EMAIL;
|
|
9643
9992
|
if (apiKey && email) {
|
|
@@ -9823,13 +10172,19 @@ function withStore(fn) {
|
|
|
9823
10172
|
}
|
|
9824
10173
|
function storeMode() {
|
|
9825
10174
|
const opts = program2.opts();
|
|
9826
|
-
const
|
|
9827
|
-
|
|
10175
|
+
const config = loadConfig();
|
|
10176
|
+
const value = String(opts.remote ? "remote" : opts.store || process.env.SHORTLINKS_STORE || config.mode || "local").toLowerCase();
|
|
10177
|
+
if (value !== "local" && value !== "remote" && value !== "api")
|
|
9828
10178
|
throw new Error(`Unknown store mode: ${value}`);
|
|
9829
10179
|
return value;
|
|
9830
10180
|
}
|
|
9831
10181
|
async function withRuntimeStore(fn) {
|
|
9832
|
-
|
|
10182
|
+
const mode = storeMode();
|
|
10183
|
+
if (mode === "api") {
|
|
10184
|
+
const store2 = new ShortlinksApiClient({ baseUrl: program2.opts().apiUrl });
|
|
10185
|
+
return await fn(store2);
|
|
10186
|
+
}
|
|
10187
|
+
if (mode === "remote") {
|
|
9833
10188
|
const store2 = await PgShortlinksStore.fromStorage("shortlinks");
|
|
9834
10189
|
try {
|
|
9835
10190
|
return await fn(store2);
|
|
@@ -9851,7 +10206,16 @@ function commandExists(command) {
|
|
|
9851
10206
|
const result = spawnSync3("which", [command], { encoding: "utf-8" });
|
|
9852
10207
|
return result.status === 0;
|
|
9853
10208
|
}
|
|
9854
|
-
|
|
10209
|
+
function parseMaxUses(opts) {
|
|
10210
|
+
const raw = opts.maxUses ?? opts.maxClicks;
|
|
10211
|
+
if (!raw)
|
|
10212
|
+
return;
|
|
10213
|
+
const parsed = Number(raw);
|
|
10214
|
+
if (!Number.isInteger(parsed) || parsed <= 0)
|
|
10215
|
+
throw new Error("--max-uses must be a positive integer");
|
|
10216
|
+
return parsed;
|
|
10217
|
+
}
|
|
10218
|
+
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
10219
|
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
10220
|
try {
|
|
9857
10221
|
const result = await withRuntimeStore(async (store) => {
|
|
@@ -9891,19 +10255,42 @@ program2.command("init").description("Initialize local shortlinks storage").opti
|
|
|
9891
10255
|
});
|
|
9892
10256
|
var configCmd = program2.command("config").description("View and update local config");
|
|
9893
10257
|
configCmd.command("show").description("Show local config").option("-j, --json", "Output JSON").action((opts) => {
|
|
9894
|
-
const
|
|
10258
|
+
const config = loadConfig();
|
|
10259
|
+
const data = {
|
|
10260
|
+
path: getConfigPath(),
|
|
10261
|
+
config: {
|
|
10262
|
+
...config,
|
|
10263
|
+
api: config.api ? { ...config.api, token: config.api.token ? "****" : "" } : undefined
|
|
10264
|
+
}
|
|
10265
|
+
};
|
|
9895
10266
|
print2(data, opts, () => console.log(JSON.stringify(data, null, 2)));
|
|
9896
10267
|
});
|
|
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) => {
|
|
10268
|
+
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
10269
|
try {
|
|
9899
10270
|
let config = loadConfig();
|
|
9900
10271
|
switch (key) {
|
|
10272
|
+
case "mode": {
|
|
10273
|
+
const mode = value.toLowerCase();
|
|
10274
|
+
if (mode !== "local" && mode !== "remote" && mode !== "api")
|
|
10275
|
+
throw new Error("mode must be local, remote, or api");
|
|
10276
|
+
config = updateConfig({ mode });
|
|
10277
|
+
break;
|
|
10278
|
+
}
|
|
9901
10279
|
case "default-domain":
|
|
9902
10280
|
config = updateConfig({ defaultDomain: value, publicBaseUrl: config.publicBaseUrl || `https://${value}` });
|
|
9903
10281
|
break;
|
|
9904
10282
|
case "public-base-url":
|
|
9905
10283
|
config = updateConfig({ publicBaseUrl: value });
|
|
9906
10284
|
break;
|
|
10285
|
+
case "api-url":
|
|
10286
|
+
config = updateConfig({ api: { baseUrl: value.replace(/\/+$/, "") } });
|
|
10287
|
+
break;
|
|
10288
|
+
case "api-token":
|
|
10289
|
+
config = updateConfig({ api: { token: value } });
|
|
10290
|
+
break;
|
|
10291
|
+
case "api-token-env":
|
|
10292
|
+
config = updateConfig({ api: { tokenEnv: value } });
|
|
10293
|
+
break;
|
|
9907
10294
|
case "cloudflare-account-id":
|
|
9908
10295
|
config = updateConfig({ cloudflare: { accountId: value } });
|
|
9909
10296
|
break;
|
|
@@ -9916,7 +10303,11 @@ configCmd.command("set <key> <value>").description("Set config value: default-do
|
|
|
9916
10303
|
default:
|
|
9917
10304
|
throw new Error(`Unknown config key: ${key}`);
|
|
9918
10305
|
}
|
|
9919
|
-
|
|
10306
|
+
const masked = {
|
|
10307
|
+
...config,
|
|
10308
|
+
api: config.api ? { ...config.api, token: config.api.token ? "****" : "" } : undefined
|
|
10309
|
+
};
|
|
10310
|
+
print2({ path: getConfigPath(), config: masked }, opts, () => console.log(source_default.green(`Set ${key}.`)));
|
|
9920
10311
|
} catch (error) {
|
|
9921
10312
|
handleError(error);
|
|
9922
10313
|
}
|
|
@@ -10027,6 +10418,7 @@ async function createLinkAction(url, opts) {
|
|
|
10027
10418
|
slug: opts.slug,
|
|
10028
10419
|
title: opts.title,
|
|
10029
10420
|
expiresAt: opts.expires,
|
|
10421
|
+
maxUses: parseMaxUses(opts),
|
|
10030
10422
|
slugLength: opts.length ? Number(opts.length) : undefined
|
|
10031
10423
|
}));
|
|
10032
10424
|
print2(link, opts, () => console.log(formatLink(link)));
|
|
@@ -10034,8 +10426,8 @@ async function createLinkAction(url, opts) {
|
|
|
10034
10426
|
handleError(error);
|
|
10035
10427
|
}
|
|
10036
10428
|
}
|
|
10037
|
-
linkCmd.command("create <url>").description("Create a shortlink").option("--domain <hostname>", "Domain to use").option("--slug <slug>", "Custom slug").option("--title <title>", "Human title").option("--expires <date>", "Expiration date").option("--length <n>", "Generated slug length", "7").option("-j, --json", "Output JSON").action(createLinkAction);
|
|
10038
|
-
program2.command("create <url>").description("Create a shortlink").option("--domain <hostname>", "Domain to use").option("--slug <slug>", "Custom slug").option("--title <title>", "Human title").option("--expires <date>", "Expiration date").option("--length <n>", "Generated slug length", "7").option("-j, --json", "Output JSON").action(createLinkAction);
|
|
10429
|
+
linkCmd.command("create <url>").description("Create a shortlink").option("--domain <hostname>", "Domain to use").option("--slug <slug>", "Custom slug").option("--title <title>", "Human title").option("--expires <date>", "Expiration date").option("--max-uses <count>", "Maximum successful redirects").option("--max-clicks <count>", "Alias for --max-uses").option("--length <n>", "Generated slug length", "7").option("-j, --json", "Output JSON").action(createLinkAction);
|
|
10430
|
+
program2.command("create <url>").description("Create a shortlink").option("--domain <hostname>", "Domain to use").option("--slug <slug>", "Custom slug").option("--title <title>", "Human title").option("--expires <date>", "Expiration date").option("--max-uses <count>", "Maximum successful redirects").option("--max-clicks <count>", "Alias for --max-uses").option("--length <n>", "Generated slug length", "7").option("-j, --json", "Output JSON").action(createLinkAction);
|
|
10039
10431
|
linkCmd.command("list").description("List shortlinks").option("--domain <hostname>", "Filter by domain").option("--active", "Only active links").option("--limit <n>", "Maximum rows", "100").option("-j, --json", "Output JSON").action(async (opts) => {
|
|
10040
10432
|
try {
|
|
10041
10433
|
const links = await withRuntimeStore((store) => store.listLinks({
|
|
@@ -10111,7 +10503,7 @@ program2.command("stats [slug]").description("Show overall stats or stats for a
|
|
|
10111
10503
|
handleError(error);
|
|
10112
10504
|
}
|
|
10113
10505
|
});
|
|
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) => {
|
|
10506
|
+
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
10507
|
try {
|
|
10116
10508
|
const store = opts.remote || opts.cloud || storeMode() === "remote" ? await PgShortlinksStore.fromStorage("shortlinks") : undefined;
|
|
10117
10509
|
const server = serveShortlinks({
|
|
@@ -10119,7 +10511,8 @@ program2.command("serve").description("Run the redirect server that records clic
|
|
|
10119
10511
|
dbPath: program2.opts().db,
|
|
10120
10512
|
host: opts.host,
|
|
10121
10513
|
port: Number(opts.port),
|
|
10122
|
-
defaultHost: opts.defaultHost
|
|
10514
|
+
defaultHost: opts.defaultHost,
|
|
10515
|
+
apiPathPrefix: opts.apiPathPrefix
|
|
10123
10516
|
});
|
|
10124
10517
|
const mode = store ? "remote" : "local";
|
|
10125
10518
|
console.log(source_default.green(`shortlinks redirect server listening on http://${server.hostname}:${server.port} (${mode})`));
|