@hasna/shortlinks 0.1.15 → 0.1.17

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 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,20 @@ 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
- await store.recordClick(link, {
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
+ if (request.method.toUpperCase() === "HEAD") {
9881
+ return new Response(null, {
9882
+ status: redirectStatus,
9883
+ headers: { location: link.destination_url }
9884
+ });
9885
+ }
9886
+ const consumed = store.consumeLinkUse ? await store.consumeLinkUse(link) : link;
9887
+ if (!consumed) {
9888
+ return json({ error: "Shortlink max uses reached.", slug, host }, 410);
9889
+ }
9890
+ await store.recordClick(consumed, {
9819
9891
  ip: getClientIp(request),
9820
9892
  userAgent: request.headers.get("user-agent"),
9821
9893
  referer: request.headers.get("referer"),
@@ -10140,6 +10212,15 @@ function commandExists(command) {
10140
10212
  const result = spawnSync3("which", [command], { encoding: "utf-8" });
10141
10213
  return result.status === 0;
10142
10214
  }
10215
+ function parseMaxUses(opts) {
10216
+ const raw = opts.maxUses ?? opts.maxClicks;
10217
+ if (!raw)
10218
+ return;
10219
+ const parsed = Number(raw);
10220
+ if (!Number.isInteger(parsed) || parsed <= 0)
10221
+ throw new Error("--max-uses must be a positive integer");
10222
+ return parsed;
10223
+ }
10143
10224
  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
10225
  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
10226
  try {
@@ -10343,6 +10424,7 @@ async function createLinkAction(url, opts) {
10343
10424
  slug: opts.slug,
10344
10425
  title: opts.title,
10345
10426
  expiresAt: opts.expires,
10427
+ maxUses: parseMaxUses(opts),
10346
10428
  slugLength: opts.length ? Number(opts.length) : undefined
10347
10429
  }));
10348
10430
  print2(link, opts, () => console.log(formatLink(link)));
@@ -10350,8 +10432,8 @@ async function createLinkAction(url, opts) {
10350
10432
  handleError(error);
10351
10433
  }
10352
10434
  }
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);
10435
+ 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);
10436
+ 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
10437
  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
10438
  try {
10357
10439
  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,20 @@ 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
- await store.recordClick(link, {
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
+ if (request.method.toUpperCase() === "HEAD") {
6462
+ return new Response(null, {
6463
+ status: redirectStatus,
6464
+ headers: { location: link.destination_url }
6465
+ });
6466
+ }
6467
+ const consumed = store.consumeLinkUse ? await store.consumeLinkUse(link) : link;
6468
+ if (!consumed) {
6469
+ return json({ error: "Shortlink max uses reached.", slug, host }, 410);
6470
+ }
6471
+ await store.recordClick(consumed, {
6401
6472
  ip: getClientIp(request),
6402
6473
  userAgent: request.headers.get("user-agent"),
6403
6474
  referer: request.headers.get("referer"),
@@ -6484,6 +6555,7 @@ class ShortlinksApiClient {
6484
6555
  slug: input.slug,
6485
6556
  title: input.title,
6486
6557
  expires_at: input.expiresAt,
6558
+ max_uses: input.maxUses,
6487
6559
  length: input.slugLength
6488
6560
  })
6489
6561
  });
@@ -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,20 @@ 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
- await store.recordClick(link, {
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
+ if (request.method.toUpperCase() === "HEAD") {
749
+ return new Response(null, {
750
+ status: redirectStatus,
751
+ headers: { location: link.destination_url }
752
+ });
753
+ }
754
+ const consumed = store.consumeLinkUse ? await store.consumeLinkUse(link) : link;
755
+ if (!consumed) {
756
+ return json({ error: "Shortlink max uses reached.", slug, host }, 410);
757
+ }
758
+ await store.recordClick(consumed, {
718
759
  ip: getClientIp(request),
719
760
  userAgent: request.headers.get("user-agent"),
720
761
  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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/shortlinks",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
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",