@hasna/shortlinks 0.1.15 → 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/cli/index.js +85 -9
- package/dist/index.js +73 -7
- package/dist/pg-store.d.ts +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +39 -4
- package/dist/storage.js +8 -0
- package/dist/store.d.ts +1 -0
- package/dist/types.d.ts +3 -0
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -2287,6 +2287,10 @@ var init_database = __esm(() => {
|
|
|
2287
2287
|
CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
|
|
2288
2288
|
CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
|
|
2289
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;
|
|
2290
2294
|
`
|
|
2291
2295
|
];
|
|
2292
2296
|
});
|
|
@@ -7399,6 +7403,10 @@ var init_pg_migrations = __esm(() => {
|
|
|
7399
7403
|
CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
|
|
7400
7404
|
CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
|
|
7401
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;
|
|
7402
7410
|
`
|
|
7403
7411
|
];
|
|
7404
7412
|
});
|
|
@@ -8892,6 +8900,8 @@ function linkFromRow(row) {
|
|
|
8892
8900
|
return {
|
|
8893
8901
|
...row,
|
|
8894
8902
|
active: Boolean(row.active),
|
|
8903
|
+
max_uses: row.max_uses ?? null,
|
|
8904
|
+
used_count: row.used_count ?? 0,
|
|
8895
8905
|
metadata: parseJsonObject2(row.metadata),
|
|
8896
8906
|
short_url: formatShortUrl(row.hostname, row.slug, publicBaseUrl)
|
|
8897
8907
|
};
|
|
@@ -8922,6 +8932,13 @@ function isoOrNull(input) {
|
|
|
8922
8932
|
throw new Error(`Invalid date: ${input}`);
|
|
8923
8933
|
return date.toISOString();
|
|
8924
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
|
+
}
|
|
8925
8942
|
|
|
8926
8943
|
class ShortlinksStore {
|
|
8927
8944
|
database;
|
|
@@ -8997,15 +9014,16 @@ class ShortlinksStore {
|
|
|
8997
9014
|
const timestamp = now2();
|
|
8998
9015
|
const machineId = getMachineId();
|
|
8999
9016
|
const expiresAt = isoOrNull(input.expiresAt);
|
|
9017
|
+
const maxUses = normalizeMaxUses(input.maxUses);
|
|
9000
9018
|
const slug = input.slug ? normalizeSlug(input.slug) : this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
|
|
9001
9019
|
try {
|
|
9002
9020
|
this.database.db.query(`
|
|
9003
9021
|
INSERT INTO links (
|
|
9004
|
-
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,
|
|
9005
9023
|
machine_id, synced_at, created_at, updated_at
|
|
9006
9024
|
)
|
|
9007
|
-
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
|
|
9008
|
-
`).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);
|
|
9009
9027
|
} catch (error) {
|
|
9010
9028
|
const message = error instanceof Error ? error.message : String(error);
|
|
9011
9029
|
if (message.includes("UNIQUE")) {
|
|
@@ -9015,6 +9033,19 @@ class ShortlinksStore {
|
|
|
9015
9033
|
}
|
|
9016
9034
|
return this.getLink(domain.hostname, slug);
|
|
9017
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
|
+
}
|
|
9018
9049
|
listLinks(options = {}) {
|
|
9019
9050
|
const params = [];
|
|
9020
9051
|
let where = "WHERE 1 = 1";
|
|
@@ -9201,6 +9232,8 @@ function linkFromRow2(row) {
|
|
|
9201
9232
|
return {
|
|
9202
9233
|
...row,
|
|
9203
9234
|
active: Boolean(row.active),
|
|
9235
|
+
max_uses: row.max_uses ?? null,
|
|
9236
|
+
used_count: row.used_count ?? 0,
|
|
9204
9237
|
expires_at: nullableIso(row.expires_at),
|
|
9205
9238
|
synced_at: nullableIso(row.synced_at),
|
|
9206
9239
|
created_at: toIsoString(row.created_at),
|
|
@@ -9229,6 +9262,13 @@ function isoOrNull2(input) {
|
|
|
9229
9262
|
throw new Error(`Invalid date: ${input}`);
|
|
9230
9263
|
return date.toISOString();
|
|
9231
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
|
+
}
|
|
9232
9272
|
function clickFromRow2(row) {
|
|
9233
9273
|
return {
|
|
9234
9274
|
...row,
|
|
@@ -9316,15 +9356,16 @@ class PgShortlinksStore {
|
|
|
9316
9356
|
const timestamp = now2();
|
|
9317
9357
|
const machineId = getMachineId();
|
|
9318
9358
|
const expiresAt = isoOrNull2(input.expiresAt);
|
|
9359
|
+
const maxUses = normalizeMaxUses2(input.maxUses);
|
|
9319
9360
|
const slug = input.slug ? normalizeSlug(input.slug) : await this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
|
|
9320
9361
|
try {
|
|
9321
9362
|
await this.pg.run(`
|
|
9322
9363
|
INSERT INTO links (
|
|
9323
|
-
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,
|
|
9324
9365
|
machine_id, synced_at, created_at, updated_at
|
|
9325
9366
|
)
|
|
9326
|
-
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
|
|
9327
|
-
`, 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);
|
|
9328
9369
|
} catch (error) {
|
|
9329
9370
|
const message = error instanceof Error ? error.message : String(error);
|
|
9330
9371
|
if (message.includes("unique") || message.includes("duplicate")) {
|
|
@@ -9334,6 +9375,22 @@ class PgShortlinksStore {
|
|
|
9334
9375
|
}
|
|
9335
9376
|
return await this.getLink(domain.hostname, slug);
|
|
9336
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
|
+
}
|
|
9337
9394
|
async listLinks(options = {}) {
|
|
9338
9395
|
const params = [];
|
|
9339
9396
|
let where = "WHERE 1 = 1";
|
|
@@ -9556,6 +9613,7 @@ class ShortlinksApiClient {
|
|
|
9556
9613
|
slug: input.slug,
|
|
9557
9614
|
title: input.title,
|
|
9558
9615
|
expires_at: input.expiresAt,
|
|
9616
|
+
max_uses: input.maxUses,
|
|
9559
9617
|
length: input.slugLength
|
|
9560
9618
|
})
|
|
9561
9619
|
});
|
|
@@ -9701,6 +9759,7 @@ async function handleApi(request, apiPath, store, options) {
|
|
|
9701
9759
|
slug: typeof body.slug === "string" ? body.slug : undefined,
|
|
9702
9760
|
title: typeof body.title === "string" ? body.title : undefined,
|
|
9703
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,
|
|
9704
9763
|
slugLength: typeof body.length === "number" ? body.length : undefined
|
|
9705
9764
|
}), 201);
|
|
9706
9765
|
}
|
|
@@ -9815,7 +9874,14 @@ function createShortlinksHandler(options = {}) {
|
|
|
9815
9874
|
return json({ error: "Shortlink is disabled.", slug, host }, 410);
|
|
9816
9875
|
if (isExpired(link))
|
|
9817
9876
|
return json({ error: "Shortlink is expired.", slug, host }, 410);
|
|
9818
|
-
|
|
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, {
|
|
9819
9885
|
ip: getClientIp(request),
|
|
9820
9886
|
userAgent: request.headers.get("user-agent"),
|
|
9821
9887
|
referer: request.headers.get("referer"),
|
|
@@ -10140,6 +10206,15 @@ function commandExists(command) {
|
|
|
10140
10206
|
const result = spawnSync3("which", [command], { encoding: "utf-8" });
|
|
10141
10207
|
return result.status === 0;
|
|
10142
10208
|
}
|
|
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
|
+
}
|
|
10143
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");
|
|
10144
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) => {
|
|
10145
10220
|
try {
|
|
@@ -10343,6 +10418,7 @@ async function createLinkAction(url, opts) {
|
|
|
10343
10418
|
slug: opts.slug,
|
|
10344
10419
|
title: opts.title,
|
|
10345
10420
|
expiresAt: opts.expires,
|
|
10421
|
+
maxUses: parseMaxUses(opts),
|
|
10346
10422
|
slugLength: opts.length ? Number(opts.length) : undefined
|
|
10347
10423
|
}));
|
|
10348
10424
|
print2(link, opts, () => console.log(formatLink(link)));
|
|
@@ -10350,8 +10426,8 @@ async function createLinkAction(url, opts) {
|
|
|
10350
10426
|
handleError(error);
|
|
10351
10427
|
}
|
|
10352
10428
|
}
|
|
10353
|
-
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);
|
|
10354
|
-
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);
|
|
10355
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) => {
|
|
10356
10432
|
try {
|
|
10357
10433
|
const links = await withRuntimeStore((store) => store.listLinks({
|
package/dist/index.js
CHANGED
|
@@ -5257,6 +5257,10 @@ var SQLITE_MIGRATIONS = [
|
|
|
5257
5257
|
CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
|
|
5258
5258
|
CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
|
|
5259
5259
|
CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
|
|
5260
|
+
`,
|
|
5261
|
+
`
|
|
5262
|
+
ALTER TABLE links ADD COLUMN max_uses INTEGER;
|
|
5263
|
+
ALTER TABLE links ADD COLUMN used_count INTEGER NOT NULL DEFAULT 0;
|
|
5260
5264
|
`
|
|
5261
5265
|
];
|
|
5262
5266
|
|
|
@@ -5365,6 +5369,8 @@ function linkFromRow(row) {
|
|
|
5365
5369
|
return {
|
|
5366
5370
|
...row,
|
|
5367
5371
|
active: Boolean(row.active),
|
|
5372
|
+
max_uses: row.max_uses ?? null,
|
|
5373
|
+
used_count: row.used_count ?? 0,
|
|
5368
5374
|
metadata: parseJsonObject(row.metadata),
|
|
5369
5375
|
short_url: formatShortUrl(row.hostname, row.slug, publicBaseUrl)
|
|
5370
5376
|
};
|
|
@@ -5395,6 +5401,13 @@ function isoOrNull(input) {
|
|
|
5395
5401
|
throw new Error(`Invalid date: ${input}`);
|
|
5396
5402
|
return date.toISOString();
|
|
5397
5403
|
}
|
|
5404
|
+
function normalizeMaxUses(value) {
|
|
5405
|
+
if (value === null || value === undefined)
|
|
5406
|
+
return null;
|
|
5407
|
+
if (!Number.isInteger(value) || value <= 0)
|
|
5408
|
+
throw new Error("maxUses must be a positive integer.");
|
|
5409
|
+
return value;
|
|
5410
|
+
}
|
|
5398
5411
|
|
|
5399
5412
|
class ShortlinksStore {
|
|
5400
5413
|
database;
|
|
@@ -5470,15 +5483,16 @@ class ShortlinksStore {
|
|
|
5470
5483
|
const timestamp = now();
|
|
5471
5484
|
const machineId = getMachineId();
|
|
5472
5485
|
const expiresAt = isoOrNull(input.expiresAt);
|
|
5486
|
+
const maxUses = normalizeMaxUses(input.maxUses);
|
|
5473
5487
|
const slug = input.slug ? normalizeSlug(input.slug) : this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
|
|
5474
5488
|
try {
|
|
5475
5489
|
this.database.db.query(`
|
|
5476
5490
|
INSERT INTO links (
|
|
5477
|
-
id, domain_id, slug, destination_url, title, active, expires_at, metadata,
|
|
5491
|
+
id, domain_id, slug, destination_url, title, active, expires_at, max_uses, used_count, metadata,
|
|
5478
5492
|
machine_id, synced_at, created_at, updated_at
|
|
5479
5493
|
)
|
|
5480
|
-
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
|
|
5481
|
-
`).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
|
|
5494
|
+
VALUES (?, ?, ?, ?, ?, 1, ?, ?, 0, ?, ?, NULL, ?, ?)
|
|
5495
|
+
`).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, maxUses, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
|
|
5482
5496
|
} catch (error) {
|
|
5483
5497
|
const message = error instanceof Error ? error.message : String(error);
|
|
5484
5498
|
if (message.includes("UNIQUE")) {
|
|
@@ -5488,6 +5502,19 @@ class ShortlinksStore {
|
|
|
5488
5502
|
}
|
|
5489
5503
|
return this.getLink(domain.hostname, slug);
|
|
5490
5504
|
}
|
|
5505
|
+
consumeLinkUse(link) {
|
|
5506
|
+
const timestamp = now();
|
|
5507
|
+
const result = this.database.db.query(`
|
|
5508
|
+
UPDATE links
|
|
5509
|
+
SET used_count = used_count + 1, updated_at = ?, synced_at = NULL
|
|
5510
|
+
WHERE id = ?
|
|
5511
|
+
AND active = 1
|
|
5512
|
+
AND (max_uses IS NULL OR used_count < max_uses)
|
|
5513
|
+
`).run(timestamp, link.id);
|
|
5514
|
+
if (result.changes === 0)
|
|
5515
|
+
return null;
|
|
5516
|
+
return this.getLink(link.hostname, link.slug);
|
|
5517
|
+
}
|
|
5491
5518
|
listLinks(options = {}) {
|
|
5492
5519
|
const params = [];
|
|
5493
5520
|
let where = "WHERE 1 = 1";
|
|
@@ -5671,6 +5698,8 @@ function linkFromRow2(row) {
|
|
|
5671
5698
|
return {
|
|
5672
5699
|
...row,
|
|
5673
5700
|
active: Boolean(row.active),
|
|
5701
|
+
max_uses: row.max_uses ?? null,
|
|
5702
|
+
used_count: row.used_count ?? 0,
|
|
5674
5703
|
expires_at: nullableIso(row.expires_at),
|
|
5675
5704
|
synced_at: nullableIso(row.synced_at),
|
|
5676
5705
|
created_at: toIsoString(row.created_at),
|
|
@@ -5699,6 +5728,13 @@ function isoOrNull2(input) {
|
|
|
5699
5728
|
throw new Error(`Invalid date: ${input}`);
|
|
5700
5729
|
return date.toISOString();
|
|
5701
5730
|
}
|
|
5731
|
+
function normalizeMaxUses2(value) {
|
|
5732
|
+
if (value === null || value === undefined)
|
|
5733
|
+
return null;
|
|
5734
|
+
if (!Number.isInteger(value) || value <= 0)
|
|
5735
|
+
throw new Error("maxUses must be a positive integer.");
|
|
5736
|
+
return value;
|
|
5737
|
+
}
|
|
5702
5738
|
function clickFromRow2(row) {
|
|
5703
5739
|
return {
|
|
5704
5740
|
...row,
|
|
@@ -5786,15 +5822,16 @@ class PgShortlinksStore {
|
|
|
5786
5822
|
const timestamp = now();
|
|
5787
5823
|
const machineId = getMachineId();
|
|
5788
5824
|
const expiresAt = isoOrNull2(input.expiresAt);
|
|
5825
|
+
const maxUses = normalizeMaxUses2(input.maxUses);
|
|
5789
5826
|
const slug = input.slug ? normalizeSlug(input.slug) : await this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
|
|
5790
5827
|
try {
|
|
5791
5828
|
await this.pg.run(`
|
|
5792
5829
|
INSERT INTO links (
|
|
5793
|
-
id, domain_id, slug, destination_url, title, active, expires_at, metadata,
|
|
5830
|
+
id, domain_id, slug, destination_url, title, active, expires_at, max_uses, used_count, metadata,
|
|
5794
5831
|
machine_id, synced_at, created_at, updated_at
|
|
5795
5832
|
)
|
|
5796
|
-
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
|
|
5797
|
-
`, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
|
|
5833
|
+
VALUES (?, ?, ?, ?, ?, 1, ?, ?, 0, ?, ?, NULL, ?, ?)
|
|
5834
|
+
`, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, maxUses, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
|
|
5798
5835
|
} catch (error) {
|
|
5799
5836
|
const message = error instanceof Error ? error.message : String(error);
|
|
5800
5837
|
if (message.includes("unique") || message.includes("duplicate")) {
|
|
@@ -5804,6 +5841,22 @@ class PgShortlinksStore {
|
|
|
5804
5841
|
}
|
|
5805
5842
|
return await this.getLink(domain.hostname, slug);
|
|
5806
5843
|
}
|
|
5844
|
+
async consumeLinkUse(link) {
|
|
5845
|
+
const timestamp = now();
|
|
5846
|
+
await this.pg.run(`
|
|
5847
|
+
UPDATE links
|
|
5848
|
+
SET used_count = used_count + 1, updated_at = ?, synced_at = NULL
|
|
5849
|
+
WHERE id = ?
|
|
5850
|
+
AND active = 1
|
|
5851
|
+
AND (max_uses IS NULL OR used_count < max_uses)
|
|
5852
|
+
`, timestamp, link.id);
|
|
5853
|
+
const refreshed = await this.getLink(link.hostname, link.slug);
|
|
5854
|
+
if (!refreshed)
|
|
5855
|
+
return null;
|
|
5856
|
+
if (link.max_uses !== null && refreshed.used_count === link.used_count)
|
|
5857
|
+
return null;
|
|
5858
|
+
return refreshed;
|
|
5859
|
+
}
|
|
5807
5860
|
async listLinks(options = {}) {
|
|
5808
5861
|
const params = [];
|
|
5809
5862
|
let where = "WHERE 1 = 1";
|
|
@@ -6024,6 +6077,10 @@ var PG_MIGRATIONS = [
|
|
|
6024
6077
|
CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
|
|
6025
6078
|
CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
|
|
6026
6079
|
CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
|
|
6080
|
+
`,
|
|
6081
|
+
`
|
|
6082
|
+
ALTER TABLE links ADD COLUMN IF NOT EXISTS max_uses INTEGER;
|
|
6083
|
+
ALTER TABLE links ADD COLUMN IF NOT EXISTS used_count INTEGER NOT NULL DEFAULT 0;
|
|
6027
6084
|
`
|
|
6028
6085
|
];
|
|
6029
6086
|
|
|
@@ -6283,6 +6340,7 @@ async function handleApi(request, apiPath, store, options) {
|
|
|
6283
6340
|
slug: typeof body.slug === "string" ? body.slug : undefined,
|
|
6284
6341
|
title: typeof body.title === "string" ? body.title : undefined,
|
|
6285
6342
|
expiresAt: typeof body.expires_at === "string" ? body.expires_at : typeof body.expires === "string" ? body.expires : undefined,
|
|
6343
|
+
maxUses: typeof body.max_uses === "number" ? body.max_uses : typeof body.maxUses === "number" ? body.maxUses : undefined,
|
|
6286
6344
|
slugLength: typeof body.length === "number" ? body.length : undefined
|
|
6287
6345
|
}), 201);
|
|
6288
6346
|
}
|
|
@@ -6397,7 +6455,14 @@ function createShortlinksHandler(options = {}) {
|
|
|
6397
6455
|
return json({ error: "Shortlink is disabled.", slug, host }, 410);
|
|
6398
6456
|
if (isExpired(link))
|
|
6399
6457
|
return json({ error: "Shortlink is expired.", slug, host }, 410);
|
|
6400
|
-
|
|
6458
|
+
if (link.max_uses !== null && link.used_count >= link.max_uses) {
|
|
6459
|
+
return json({ error: "Shortlink max uses reached.", slug, host }, 410);
|
|
6460
|
+
}
|
|
6461
|
+
const consumed = store.consumeLinkUse ? await store.consumeLinkUse(link) : link;
|
|
6462
|
+
if (!consumed) {
|
|
6463
|
+
return json({ error: "Shortlink max uses reached.", slug, host }, 410);
|
|
6464
|
+
}
|
|
6465
|
+
await store.recordClick(consumed, {
|
|
6401
6466
|
ip: getClientIp(request),
|
|
6402
6467
|
userAgent: request.headers.get("user-agent"),
|
|
6403
6468
|
referer: request.headers.get("referer"),
|
|
@@ -6484,6 +6549,7 @@ class ShortlinksApiClient {
|
|
|
6484
6549
|
slug: input.slug,
|
|
6485
6550
|
title: input.title,
|
|
6486
6551
|
expires_at: input.expiresAt,
|
|
6552
|
+
max_uses: input.maxUses,
|
|
6487
6553
|
length: input.slugLength
|
|
6488
6554
|
})
|
|
6489
6555
|
});
|
package/dist/pg-store.d.ts
CHANGED
|
@@ -17,6 +17,7 @@ export declare class PgShortlinksStore {
|
|
|
17
17
|
getDomain(hostnameOrId: string): Promise<Domain | null>;
|
|
18
18
|
getDefaultDomain(): Promise<Domain | null>;
|
|
19
19
|
createLink(input: CreateLinkInput): Promise<Link>;
|
|
20
|
+
consumeLinkUse(link: Link): Promise<Link | null>;
|
|
20
21
|
listLinks(options?: {
|
|
21
22
|
domain?: string;
|
|
22
23
|
activeOnly?: boolean;
|
package/dist/server.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ 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
|
+
consumeLinkUse?(link: Link): Link | null | Promise<Link | null>;
|
|
14
15
|
createLink?(input: CreateLinkInput): Link | Promise<Link>;
|
|
15
16
|
listLinks?(input?: {
|
|
16
17
|
domain?: string;
|
package/dist/server.js
CHANGED
|
@@ -161,6 +161,10 @@ var SQLITE_MIGRATIONS = [
|
|
|
161
161
|
CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
|
|
162
162
|
CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
|
|
163
163
|
CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
|
|
164
|
+
`,
|
|
165
|
+
`
|
|
166
|
+
ALTER TABLE links ADD COLUMN max_uses INTEGER;
|
|
167
|
+
ALTER TABLE links ADD COLUMN used_count INTEGER NOT NULL DEFAULT 0;
|
|
164
168
|
`
|
|
165
169
|
];
|
|
166
170
|
|
|
@@ -267,6 +271,8 @@ function linkFromRow(row) {
|
|
|
267
271
|
return {
|
|
268
272
|
...row,
|
|
269
273
|
active: Boolean(row.active),
|
|
274
|
+
max_uses: row.max_uses ?? null,
|
|
275
|
+
used_count: row.used_count ?? 0,
|
|
270
276
|
metadata: parseJsonObject(row.metadata),
|
|
271
277
|
short_url: formatShortUrl(row.hostname, row.slug, publicBaseUrl)
|
|
272
278
|
};
|
|
@@ -297,6 +303,13 @@ function isoOrNull(input) {
|
|
|
297
303
|
throw new Error(`Invalid date: ${input}`);
|
|
298
304
|
return date.toISOString();
|
|
299
305
|
}
|
|
306
|
+
function normalizeMaxUses(value) {
|
|
307
|
+
if (value === null || value === undefined)
|
|
308
|
+
return null;
|
|
309
|
+
if (!Number.isInteger(value) || value <= 0)
|
|
310
|
+
throw new Error("maxUses must be a positive integer.");
|
|
311
|
+
return value;
|
|
312
|
+
}
|
|
300
313
|
|
|
301
314
|
class ShortlinksStore {
|
|
302
315
|
database;
|
|
@@ -372,15 +385,16 @@ class ShortlinksStore {
|
|
|
372
385
|
const timestamp = now();
|
|
373
386
|
const machineId = getMachineId();
|
|
374
387
|
const expiresAt = isoOrNull(input.expiresAt);
|
|
388
|
+
const maxUses = normalizeMaxUses(input.maxUses);
|
|
375
389
|
const slug = input.slug ? normalizeSlug(input.slug) : this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
|
|
376
390
|
try {
|
|
377
391
|
this.database.db.query(`
|
|
378
392
|
INSERT INTO links (
|
|
379
|
-
id, domain_id, slug, destination_url, title, active, expires_at, metadata,
|
|
393
|
+
id, domain_id, slug, destination_url, title, active, expires_at, max_uses, used_count, metadata,
|
|
380
394
|
machine_id, synced_at, created_at, updated_at
|
|
381
395
|
)
|
|
382
|
-
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
|
|
383
|
-
`).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
|
|
396
|
+
VALUES (?, ?, ?, ?, ?, 1, ?, ?, 0, ?, ?, NULL, ?, ?)
|
|
397
|
+
`).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, maxUses, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
|
|
384
398
|
} catch (error) {
|
|
385
399
|
const message = error instanceof Error ? error.message : String(error);
|
|
386
400
|
if (message.includes("UNIQUE")) {
|
|
@@ -390,6 +404,19 @@ class ShortlinksStore {
|
|
|
390
404
|
}
|
|
391
405
|
return this.getLink(domain.hostname, slug);
|
|
392
406
|
}
|
|
407
|
+
consumeLinkUse(link) {
|
|
408
|
+
const timestamp = now();
|
|
409
|
+
const result = this.database.db.query(`
|
|
410
|
+
UPDATE links
|
|
411
|
+
SET used_count = used_count + 1, updated_at = ?, synced_at = NULL
|
|
412
|
+
WHERE id = ?
|
|
413
|
+
AND active = 1
|
|
414
|
+
AND (max_uses IS NULL OR used_count < max_uses)
|
|
415
|
+
`).run(timestamp, link.id);
|
|
416
|
+
if (result.changes === 0)
|
|
417
|
+
return null;
|
|
418
|
+
return this.getLink(link.hostname, link.slug);
|
|
419
|
+
}
|
|
393
420
|
listLinks(options = {}) {
|
|
394
421
|
const params = [];
|
|
395
422
|
let where = "WHERE 1 = 1";
|
|
@@ -600,6 +627,7 @@ async function handleApi(request, apiPath, store, options) {
|
|
|
600
627
|
slug: typeof body.slug === "string" ? body.slug : undefined,
|
|
601
628
|
title: typeof body.title === "string" ? body.title : undefined,
|
|
602
629
|
expiresAt: typeof body.expires_at === "string" ? body.expires_at : typeof body.expires === "string" ? body.expires : undefined,
|
|
630
|
+
maxUses: typeof body.max_uses === "number" ? body.max_uses : typeof body.maxUses === "number" ? body.maxUses : undefined,
|
|
603
631
|
slugLength: typeof body.length === "number" ? body.length : undefined
|
|
604
632
|
}), 201);
|
|
605
633
|
}
|
|
@@ -714,7 +742,14 @@ function createShortlinksHandler(options = {}) {
|
|
|
714
742
|
return json({ error: "Shortlink is disabled.", slug, host }, 410);
|
|
715
743
|
if (isExpired(link))
|
|
716
744
|
return json({ error: "Shortlink is expired.", slug, host }, 410);
|
|
717
|
-
|
|
745
|
+
if (link.max_uses !== null && link.used_count >= link.max_uses) {
|
|
746
|
+
return json({ error: "Shortlink max uses reached.", slug, host }, 410);
|
|
747
|
+
}
|
|
748
|
+
const consumed = store.consumeLinkUse ? await store.consumeLinkUse(link) : link;
|
|
749
|
+
if (!consumed) {
|
|
750
|
+
return json({ error: "Shortlink max uses reached.", slug, host }, 410);
|
|
751
|
+
}
|
|
752
|
+
await store.recordClick(consumed, {
|
|
718
753
|
ip: getClientIp(request),
|
|
719
754
|
userAgent: request.headers.get("user-agent"),
|
|
720
755
|
referer: request.headers.get("referer"),
|
package/dist/storage.js
CHANGED
|
@@ -5260,6 +5260,10 @@ var SQLITE_MIGRATIONS = [
|
|
|
5260
5260
|
CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
|
|
5261
5261
|
CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
|
|
5262
5262
|
CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
|
|
5263
|
+
`,
|
|
5264
|
+
`
|
|
5265
|
+
ALTER TABLE links ADD COLUMN max_uses INTEGER;
|
|
5266
|
+
ALTER TABLE links ADD COLUMN used_count INTEGER NOT NULL DEFAULT 0;
|
|
5263
5267
|
`
|
|
5264
5268
|
];
|
|
5265
5269
|
|
|
@@ -5365,6 +5369,10 @@ var PG_MIGRATIONS = [
|
|
|
5365
5369
|
CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
|
|
5366
5370
|
CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
|
|
5367
5371
|
CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
|
|
5372
|
+
`,
|
|
5373
|
+
`
|
|
5374
|
+
ALTER TABLE links ADD COLUMN IF NOT EXISTS max_uses INTEGER;
|
|
5375
|
+
ALTER TABLE links ADD COLUMN IF NOT EXISTS used_count INTEGER NOT NULL DEFAULT 0;
|
|
5368
5376
|
`
|
|
5369
5377
|
];
|
|
5370
5378
|
|
package/dist/store.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export declare class ShortlinksStore {
|
|
|
9
9
|
getDomain(hostnameOrId: string): Domain | null;
|
|
10
10
|
getDefaultDomain(): Domain | null;
|
|
11
11
|
createLink(input: CreateLinkInput): Link;
|
|
12
|
+
consumeLinkUse(link: Link): Link | null;
|
|
12
13
|
listLinks(options?: {
|
|
13
14
|
domain?: string;
|
|
14
15
|
activeOnly?: boolean;
|
package/dist/types.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface Link {
|
|
|
23
23
|
title: string | null;
|
|
24
24
|
active: boolean;
|
|
25
25
|
expires_at: string | null;
|
|
26
|
+
max_uses: number | null;
|
|
27
|
+
used_count: number;
|
|
26
28
|
metadata: Record<string, unknown>;
|
|
27
29
|
machine_id: string | null;
|
|
28
30
|
synced_at: string | null;
|
|
@@ -66,6 +68,7 @@ export interface CreateLinkInput {
|
|
|
66
68
|
slug?: string;
|
|
67
69
|
title?: string;
|
|
68
70
|
expiresAt?: string;
|
|
71
|
+
maxUses?: number | null;
|
|
69
72
|
metadata?: Record<string, unknown>;
|
|
70
73
|
slugLength?: number;
|
|
71
74
|
}
|
package/package.json
CHANGED