@hasna/shortlinks 0.1.6 → 0.1.8

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  CLI-only shortlink management for custom domains.
4
4
 
5
- `shortlinks` creates Bitly-style short URLs, supports multiple domains, records click analytics, can run a tiny redirect server, and includes helper commands for Cloudflare DNS/Workers, `@hasna/domains`, and `@hasna/cloud` sync.
5
+ `shortlinks` creates Bitly-style short URLs, supports multiple domains, records click analytics, can run a tiny redirect server, and includes helper commands for Cloudflare DNS/Workers, `@hasna/domains`, and `@hasna/cloud` sync. Production serving can run directly against the shared RDS database with `--cloud`; local SQLite is only for explicit local/offline use.
6
6
 
7
7
  [![npm](https://img.shields.io/npm/v/@hasna/shortlinks)](https://www.npmjs.com/package/@hasna/shortlinks)
8
8
  [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
@@ -64,6 +64,7 @@ shortlinks link enable home --domain has.na
64
64
  shortlinks stats home --domain has.na
65
65
 
66
66
  shortlinks serve --port 8787
67
+ shortlinks serve --cloud --port 8787
67
68
  shortlinks doctor
68
69
  ```
69
70
 
@@ -137,15 +138,21 @@ shortlinks cloud sync
137
138
  ```
138
139
 
139
140
  The cloud database service name is `shortlinks`.
141
+ Use direct RDS mode for production and live management:
142
+
143
+ ```bash
144
+ shortlinks --cloud create https://example.com
145
+ shortlinks --cloud link list
146
+ shortlinks serve --cloud --host 127.0.0.1 --port 8787
147
+ ```
140
148
 
141
149
  ## AWS Origin
142
150
 
143
151
  For an apex domain that needs stable A records, `infra/aws-ec2-user-data.sh` bootstraps a small EC2 redirect origin with:
144
152
 
145
153
  - `@hasna/shortlinks` installed through Bun
146
- - local SQLite data synced with the `shortlinks` RDS database through `@hasna/cloud`
154
+ - direct reads and click writes against the `shortlinks` RDS database through `@hasna/cloud`
147
155
  - Caddy terminating HTTPS and proxying to `shortlinks serve`
148
- - a systemd timer that syncs links and clicks every minute
149
156
 
150
157
  The script reads the RDS password from AWS Secrets Manager through the instance role; it does not contain secret values.
151
158
 
package/dist/cli/index.js CHANGED
@@ -3096,6 +3096,326 @@ class ShortlinksStore {
3096
3096
  }
3097
3097
  }
3098
3098
 
3099
+ // src/pg-store.ts
3100
+ import { createHash as createHash2 } from "crypto";
3101
+ function parseJsonObject2(value) {
3102
+ if (!value)
3103
+ return {};
3104
+ if (typeof value === "object" && !Array.isArray(value))
3105
+ return value;
3106
+ try {
3107
+ const parsed = JSON.parse(String(value));
3108
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3109
+ } catch {
3110
+ return {};
3111
+ }
3112
+ }
3113
+ function toIsoString(value) {
3114
+ if (value instanceof Date)
3115
+ return value.toISOString();
3116
+ return String(value);
3117
+ }
3118
+ function nullableIso(value) {
3119
+ if (value === null || value === undefined)
3120
+ return null;
3121
+ return toIsoString(value);
3122
+ }
3123
+ function domainFromRow2(row) {
3124
+ return {
3125
+ ...row,
3126
+ default_domain: Boolean(row.default_domain),
3127
+ synced_at: nullableIso(row.synced_at),
3128
+ created_at: toIsoString(row.created_at),
3129
+ updated_at: toIsoString(row.updated_at),
3130
+ metadata: parseJsonObject2(row.metadata)
3131
+ };
3132
+ }
3133
+ function linkFromRow2(row) {
3134
+ return {
3135
+ ...row,
3136
+ active: Boolean(row.active),
3137
+ expires_at: nullableIso(row.expires_at),
3138
+ synced_at: nullableIso(row.synced_at),
3139
+ created_at: toIsoString(row.created_at),
3140
+ updated_at: toIsoString(row.updated_at),
3141
+ metadata: parseJsonObject2(row.metadata),
3142
+ short_url: formatShortUrl(row.hostname, row.slug)
3143
+ };
3144
+ }
3145
+ function validateDestinationUrl2(url) {
3146
+ let parsed;
3147
+ try {
3148
+ parsed = new URL(url);
3149
+ } catch {
3150
+ throw new Error(`Invalid destination URL: ${url}`);
3151
+ }
3152
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
3153
+ throw new Error("Destination URL must start with http:// or https://.");
3154
+ }
3155
+ return parsed.toString();
3156
+ }
3157
+ function isoOrNull2(input) {
3158
+ if (!input)
3159
+ return null;
3160
+ const date = new Date(input);
3161
+ if (Number.isNaN(date.getTime()))
3162
+ throw new Error(`Invalid date: ${input}`);
3163
+ return date.toISOString();
3164
+ }
3165
+ function clickFromRow2(row) {
3166
+ return {
3167
+ ...row,
3168
+ clicked_at: toIsoString(row.clicked_at),
3169
+ synced_at: nullableIso(row.synced_at),
3170
+ created_at: toIsoString(row.created_at),
3171
+ updated_at: toIsoString(row.updated_at),
3172
+ metadata: parseJsonObject2(row.metadata)
3173
+ };
3174
+ }
3175
+
3176
+ class PgShortlinksStore {
3177
+ pg;
3178
+ constructor(pg) {
3179
+ this.pg = pg;
3180
+ }
3181
+ static async fromConnectionString(connectionString) {
3182
+ const { PgAdapterAsync } = await import("@hasna/cloud");
3183
+ return new PgShortlinksStore(new PgAdapterAsync(connectionString));
3184
+ }
3185
+ static async fromCloud(service = "shortlinks") {
3186
+ const { getConnectionString } = await import("@hasna/cloud");
3187
+ return PgShortlinksStore.fromConnectionString(getConnectionString(service));
3188
+ }
3189
+ async close() {
3190
+ await this.pg.close?.();
3191
+ }
3192
+ async addDomain(input) {
3193
+ const hostname2 = normalizeHostname(input.hostname);
3194
+ const timestamp = now();
3195
+ const machineId = getMachineId();
3196
+ const existing = await this.getDomain(hostname2);
3197
+ const id = existing?.id || makeId("dom");
3198
+ if (input.defaultDomain) {
3199
+ await this.pg.run("UPDATE domains SET default_domain = 0, updated_at = ? WHERE default_domain = 1", timestamp);
3200
+ }
3201
+ await this.pg.run(`
3202
+ INSERT INTO domains (
3203
+ id, hostname, provider, default_domain, cloudflare_zone_id, cloudflare_account_id,
3204
+ cloudflare_worker_name, origin_url, notes, metadata, machine_id, synced_at, created_at, updated_at
3205
+ )
3206
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
3207
+ ON CONFLICT(hostname) DO UPDATE SET
3208
+ provider = excluded.provider,
3209
+ default_domain = excluded.default_domain,
3210
+ cloudflare_zone_id = COALESCE(excluded.cloudflare_zone_id, domains.cloudflare_zone_id),
3211
+ cloudflare_account_id = COALESCE(excluded.cloudflare_account_id, domains.cloudflare_account_id),
3212
+ cloudflare_worker_name = COALESCE(excluded.cloudflare_worker_name, domains.cloudflare_worker_name),
3213
+ origin_url = COALESCE(excluded.origin_url, domains.origin_url),
3214
+ notes = COALESCE(excluded.notes, domains.notes),
3215
+ metadata = excluded.metadata,
3216
+ machine_id = excluded.machine_id,
3217
+ synced_at = NULL,
3218
+ updated_at = excluded.updated_at
3219
+ `, id, hostname2, input.provider || existing?.provider || "manual", input.defaultDomain ?? existing?.default_domain ? 1 : 0, input.cloudflareZoneId || existing?.cloudflare_zone_id || null, input.cloudflareAccountId || existing?.cloudflare_account_id || null, input.cloudflareWorkerName || existing?.cloudflare_worker_name || null, input.originUrl || existing?.origin_url || null, input.notes || existing?.notes || null, JSON.stringify(input.metadata || existing?.metadata || {}), machineId, existing?.created_at || timestamp, timestamp);
3220
+ return await this.getDomain(hostname2);
3221
+ }
3222
+ async listDomains() {
3223
+ const rows = await this.pg.all(`
3224
+ SELECT * FROM domains
3225
+ ORDER BY default_domain DESC, hostname ASC
3226
+ `);
3227
+ return rows.map(domainFromRow2);
3228
+ }
3229
+ async getDomain(hostnameOrId) {
3230
+ const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
3231
+ const row = await this.pg.get(`
3232
+ SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
3233
+ `, normalized, hostnameOrId);
3234
+ return row ? domainFromRow2(row) : null;
3235
+ }
3236
+ async getDefaultDomain() {
3237
+ const row = await this.pg.get(`
3238
+ SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
3239
+ `);
3240
+ return row ? domainFromRow2(row) : null;
3241
+ }
3242
+ async createLink(input) {
3243
+ const domain = input.domain ? await this.getDomain(input.domain) : await this.getDefaultDomain();
3244
+ if (!domain) {
3245
+ throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
3246
+ }
3247
+ const destinationUrl = validateDestinationUrl2(input.destinationUrl);
3248
+ const timestamp = now();
3249
+ const machineId = getMachineId();
3250
+ const expiresAt = isoOrNull2(input.expiresAt);
3251
+ const slug = input.slug ? normalizeSlug(input.slug) : await this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
3252
+ try {
3253
+ await this.pg.run(`
3254
+ INSERT INTO links (
3255
+ id, domain_id, slug, destination_url, title, active, expires_at, metadata,
3256
+ machine_id, synced_at, created_at, updated_at
3257
+ )
3258
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
3259
+ `, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
3260
+ } catch (error) {
3261
+ const message = error instanceof Error ? error.message : String(error);
3262
+ if (message.includes("unique") || message.includes("duplicate")) {
3263
+ throw new Error(`Slug already exists for ${domain.hostname}: ${slug}`);
3264
+ }
3265
+ throw error;
3266
+ }
3267
+ return await this.getLink(domain.hostname, slug);
3268
+ }
3269
+ async listLinks(options = {}) {
3270
+ const params = [];
3271
+ let where = "WHERE 1 = 1";
3272
+ if (options.domain) {
3273
+ where += " AND d.hostname = ?";
3274
+ params.push(normalizeHostname(options.domain));
3275
+ }
3276
+ if (options.activeOnly)
3277
+ where += " AND l.active = 1";
3278
+ params.push(options.limit || 100);
3279
+ const rows = await this.pg.all(`
3280
+ SELECT l.*, d.hostname
3281
+ FROM links l
3282
+ JOIN domains d ON d.id = l.domain_id
3283
+ ${where}
3284
+ ORDER BY l.created_at DESC
3285
+ LIMIT ?
3286
+ `, ...params);
3287
+ return rows.map(linkFromRow2);
3288
+ }
3289
+ async getLink(domainOrSlug, maybeSlug) {
3290
+ const slug = normalizeSlug(maybeSlug || domainOrSlug);
3291
+ const params = [slug];
3292
+ let domainClause = "";
3293
+ if (maybeSlug) {
3294
+ domainClause = "AND d.hostname = ?";
3295
+ params.push(normalizeHostname(domainOrSlug));
3296
+ }
3297
+ const row = await this.pg.get(`
3298
+ SELECT l.*, d.hostname
3299
+ FROM links l
3300
+ JOIN domains d ON d.id = l.domain_id
3301
+ WHERE l.slug = ? ${domainClause}
3302
+ ORDER BY d.default_domain DESC, l.created_at ASC
3303
+ LIMIT 1
3304
+ `, ...params);
3305
+ return row ? linkFromRow2(row) : null;
3306
+ }
3307
+ async totalStats() {
3308
+ const row = await this.pg.get(`
3309
+ SELECT
3310
+ (SELECT COUNT(*)::int FROM domains) AS domains,
3311
+ (SELECT COUNT(*)::int FROM links) AS links,
3312
+ (SELECT COUNT(*)::int FROM clicks) AS clicks
3313
+ `);
3314
+ return row;
3315
+ }
3316
+ async resolve(hostname2, slug) {
3317
+ const normalizedHost = normalizeHostname(hostname2);
3318
+ const normalizedSlug = normalizeSlug(slug);
3319
+ const row = await this.pg.get(`
3320
+ SELECT l.*, d.hostname
3321
+ FROM links l
3322
+ JOIN domains d ON d.id = l.domain_id
3323
+ WHERE d.hostname = ? AND l.slug = ?
3324
+ LIMIT 1
3325
+ `, normalizedHost, normalizedSlug);
3326
+ if (row)
3327
+ return linkFromRow2(row);
3328
+ const fallback = await this.pg.get(`
3329
+ SELECT l.*, d.hostname
3330
+ FROM links l
3331
+ JOIN domains d ON d.id = l.domain_id
3332
+ WHERE d.default_domain = 1 AND l.slug = ?
3333
+ ORDER BY d.created_at ASC
3334
+ LIMIT 1
3335
+ `, normalizedSlug);
3336
+ return fallback ? linkFromRow2(fallback) : null;
3337
+ }
3338
+ async setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
3339
+ const active = typeof maybeSlugOrActive === "boolean" ? maybeSlugOrActive : Boolean(maybeActive);
3340
+ const link = typeof maybeSlugOrActive === "boolean" ? await this.getLink(domainOrSlug) : await this.getLink(domainOrSlug, maybeSlugOrActive);
3341
+ if (!link)
3342
+ throw new Error("Link not found.");
3343
+ const timestamp = now();
3344
+ await this.pg.run(`
3345
+ UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
3346
+ `, active ? 1 : 0, timestamp, link.id);
3347
+ return await this.getLink(link.hostname, link.slug);
3348
+ }
3349
+ async deleteLink(domainOrSlug, maybeSlug) {
3350
+ const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
3351
+ if (!link)
3352
+ throw new Error("Link not found.");
3353
+ await this.pg.run("DELETE FROM links WHERE id = ?", link.id);
3354
+ return link;
3355
+ }
3356
+ async recordClick(link, input = {}) {
3357
+ const timestamp = now();
3358
+ const machineId = getMachineId();
3359
+ const ipHash = input.ip ? this.hashIp(input.ip) : null;
3360
+ const id = makeId("clk");
3361
+ await this.pg.run(`
3362
+ INSERT INTO clicks (
3363
+ id, link_id, domain_id, slug, clicked_at, ip_hash, user_agent, referer,
3364
+ country, city, metadata, machine_id, synced_at, created_at, updated_at
3365
+ )
3366
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
3367
+ `, id, link.id, link.domain_id, link.slug, timestamp, ipHash, input.userAgent || null, input.referer || null, input.country || null, input.city || null, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
3368
+ const row = await this.pg.get("SELECT * FROM clicks WHERE id = ?", id);
3369
+ return clickFromRow2(row);
3370
+ }
3371
+ async getStats(domainOrSlug, maybeSlug) {
3372
+ const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
3373
+ if (!link)
3374
+ throw new Error("Link not found.");
3375
+ const summary = await this.pg.get(`
3376
+ SELECT COUNT(*)::int AS clicks, MAX(clicked_at) AS last_clicked_at FROM clicks WHERE link_id = ?
3377
+ `, link.id);
3378
+ const topReferrers = await this.pg.all(`
3379
+ SELECT referer, COUNT(*)::int AS clicks
3380
+ FROM clicks
3381
+ WHERE link_id = ?
3382
+ GROUP BY referer
3383
+ ORDER BY clicks DESC
3384
+ LIMIT 10
3385
+ `, link.id);
3386
+ const topUserAgents = await this.pg.all(`
3387
+ SELECT user_agent, COUNT(*)::int AS clicks
3388
+ FROM clicks
3389
+ WHERE link_id = ?
3390
+ GROUP BY user_agent
3391
+ ORDER BY clicks DESC
3392
+ LIMIT 10
3393
+ `, link.id);
3394
+ return {
3395
+ link,
3396
+ clicks: summary.clicks,
3397
+ last_clicked_at: nullableIso(summary.last_clicked_at),
3398
+ top_referrers: topReferrers,
3399
+ top_user_agents: topUserAgents
3400
+ };
3401
+ }
3402
+ hashIp(ip) {
3403
+ const salt = process.env.SHORTLINKS_CLICK_SALT || "shortlinks-cloud";
3404
+ return createHash2("sha256").update(`${salt}:${ip}`).digest("hex");
3405
+ }
3406
+ async generateAvailableSlug(domainId, length) {
3407
+ for (let attempt = 0;attempt < 32; attempt += 1) {
3408
+ const slug = randomToken(length);
3409
+ const exists = await this.pg.get(`
3410
+ SELECT 1 FROM links WHERE domain_id = ? AND slug = ? LIMIT 1
3411
+ `, domainId, slug);
3412
+ if (!exists)
3413
+ return slug;
3414
+ }
3415
+ throw new Error("Could not generate an unused slug after 32 attempts.");
3416
+ }
3417
+ }
3418
+
3099
3419
  // src/server.ts
3100
3420
  function json(data, status = 200) {
3101
3421
  return new Response(JSON.stringify(data, null, 2), {
@@ -3123,25 +3443,35 @@ function createShortlinksHandler(options = {}) {
3123
3443
  return async (request) => {
3124
3444
  const url = new URL(request.url);
3125
3445
  if (url.pathname === "/healthz") {
3126
- return json({ ok: true, service: "shortlinks", stats: store.totalStats() });
3446
+ return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
3127
3447
  }
3128
3448
  if (url.pathname === "/" || url.pathname === "") {
3129
3449
  return json({ service: "shortlinks", ok: true });
3130
3450
  }
3131
- const slug = decodeURIComponent(url.pathname.replace(/^\/+/, "").split("/")[0] || "");
3451
+ let slug = "";
3452
+ try {
3453
+ slug = decodeURIComponent(url.pathname.replace(/^\/+/, "").split("/")[0] || "");
3454
+ } catch {
3455
+ return json({ error: "Invalid slug." }, 400);
3456
+ }
3132
3457
  if (!slug)
3133
3458
  return json({ error: "Missing slug." }, 404);
3134
3459
  const host = getHost(request, options.defaultHost);
3135
3460
  if (!host)
3136
3461
  return json({ error: "Missing Host header." }, 400);
3137
- const link = store.resolve(host, slug);
3462
+ let link = null;
3463
+ try {
3464
+ link = await store.resolve(host, slug);
3465
+ } catch {
3466
+ return json({ error: "Shortlink not found.", slug, host }, 404);
3467
+ }
3138
3468
  if (!link)
3139
3469
  return json({ error: "Shortlink not found.", slug, host }, 404);
3140
3470
  if (!link.active)
3141
3471
  return json({ error: "Shortlink is disabled.", slug, host }, 410);
3142
3472
  if (isExpired(link))
3143
3473
  return json({ error: "Shortlink is expired.", slug, host }, 410);
3144
- store.recordClick(link, {
3474
+ await store.recordClick(link, {
3145
3475
  ip: getClientIp(request),
3146
3476
  userAgent: request.headers.get("user-agent"),
3147
3477
  referer: request.headers.get("referer"),
@@ -3476,7 +3806,22 @@ function withStore(fn) {
3476
3806
  store.close();
3477
3807
  }
3478
3808
  }
3479
- async function withStoreAsync(fn) {
3809
+ function storeMode() {
3810
+ const opts = program2.opts();
3811
+ const value = String(opts.cloud ? "cloud" : opts.store || process.env.SHORTLINKS_STORE || "local").toLowerCase();
3812
+ if (value !== "local" && value !== "cloud")
3813
+ throw new Error(`Unknown store mode: ${value}`);
3814
+ return value;
3815
+ }
3816
+ async function withRuntimeStore(fn) {
3817
+ if (storeMode() === "cloud") {
3818
+ const store2 = await PgShortlinksStore.fromCloud("shortlinks");
3819
+ try {
3820
+ return await fn(store2);
3821
+ } finally {
3822
+ await store2.close();
3823
+ }
3824
+ }
3480
3825
  const store = new ShortlinksStore(program2.opts().db);
3481
3826
  try {
3482
3827
  return await fn(store);
@@ -3491,15 +3836,15 @@ function commandExists(command) {
3491
3836
  const result = spawnSync3("which", [command], { encoding: "utf-8" });
3492
3837
  return result.status === 0;
3493
3838
  }
3494
- program2.name("shortlinks").description("CLI-only shortlink manager with custom domains, click tracking, Cloudflare helpers, and cloud sync").version(getPackageVersion()).option("--db <path>", "SQLite database path").option("-j, --json", "Output JSON for agents and scripts");
3495
- 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((opts) => {
3839
+ program2.name("shortlinks").description("CLI-only shortlink manager with custom domains, click tracking, Cloudflare helpers, and cloud sync").version(getPackageVersion()).option("--db <path>", "SQLite database path").option("--store <mode>", "Data store mode: cloud or local", process.env.SHORTLINKS_STORE || "local").option("--cloud", "Use the shortlinks PostgreSQL database directly").option("-j, --json", "Output JSON for agents and scripts");
3840
+ 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) => {
3496
3841
  try {
3497
- const result = withStore((store) => {
3842
+ const result = await withRuntimeStore(async (store) => {
3498
3843
  const config = loadConfig();
3499
3844
  if (opts.publicBaseUrl)
3500
3845
  config.publicBaseUrl = opts.publicBaseUrl;
3501
3846
  if (opts.domain) {
3502
- const domain = store.addDomain({
3847
+ const domain = await store.addDomain({
3503
3848
  hostname: opts.domain,
3504
3849
  provider: "manual",
3505
3850
  defaultDomain: true
@@ -3507,13 +3852,15 @@ program2.command("init").description("Initialize local shortlinks storage").opti
3507
3852
  config.defaultDomain = domain.hostname;
3508
3853
  config.publicBaseUrl = opts.publicBaseUrl || `https://${domain.hostname}`;
3509
3854
  }
3510
- saveConfig(config);
3855
+ if (storeMode() === "local")
3856
+ saveConfig(config);
3511
3857
  return {
3512
3858
  data_dir: getDataDir(),
3513
3859
  config_path: getConfigPath(),
3514
3860
  db_path: getDatabasePath(program2.opts().db),
3861
+ store: storeMode(),
3515
3862
  config,
3516
- stats: store.totalStats()
3863
+ stats: await store.totalStats()
3517
3864
  };
3518
3865
  });
3519
3866
  print(result, opts, () => {
@@ -3560,9 +3907,9 @@ configCmd.command("set <key> <value>").description("Set config value: default-do
3560
3907
  }
3561
3908
  });
3562
3909
  var domainCmd = program2.command("domain").alias("domains").description("Manage custom shortlink domains");
3563
- domainCmd.command("add <hostname>").description("Add or update a custom domain").option("--provider <provider>", "Provider label", "manual").option("--default", "Make this the default domain").option("--cloudflare-zone-id <id>", "Cloudflare zone ID").option("--cloudflare-account-id <id>", "Cloudflare account ID").option("--cloudflare-worker-name <name>", "Cloudflare Worker name").option("--origin <url>", "Origin redirect server URL").option("--notes <text>", "Notes").option("-j, --json", "Output JSON").action((hostname2, opts) => {
3910
+ domainCmd.command("add <hostname>").description("Add or update a custom domain").option("--provider <provider>", "Provider label", "manual").option("--default", "Make this the default domain").option("--cloudflare-zone-id <id>", "Cloudflare zone ID").option("--cloudflare-account-id <id>", "Cloudflare account ID").option("--cloudflare-worker-name <name>", "Cloudflare Worker name").option("--origin <url>", "Origin redirect server URL").option("--notes <text>", "Notes").option("-j, --json", "Output JSON").action(async (hostname2, opts) => {
3564
3911
  try {
3565
- const domain = withStore((store) => store.addDomain({
3912
+ const domain = await withRuntimeStore((store) => store.addDomain({
3566
3913
  hostname: hostname2,
3567
3914
  provider: opts.provider,
3568
3915
  defaultDomain: opts.default,
@@ -3581,9 +3928,9 @@ domainCmd.command("add <hostname>").description("Add or update a custom domain")
3581
3928
  handleError(error);
3582
3929
  }
3583
3930
  });
3584
- domainCmd.command("list").description("List configured domains").option("-j, --json", "Output JSON").action((opts) => {
3931
+ domainCmd.command("list").description("List configured domains").option("-j, --json", "Output JSON").action(async (opts) => {
3585
3932
  try {
3586
- const domains = withStore((store) => store.listDomains());
3933
+ const domains = await withRuntimeStore((store) => store.listDomains());
3587
3934
  print(domains, opts, () => {
3588
3935
  if (domains.length === 0) {
3589
3936
  console.log(source_default.dim("No domains configured."));
@@ -3598,9 +3945,9 @@ domainCmd.command("list").description("List configured domains").option("-j, --j
3598
3945
  handleError(error);
3599
3946
  }
3600
3947
  });
3601
- domainCmd.command("get <hostname>").description("Show a configured domain").option("-j, --json", "Output JSON").action((hostname2, opts) => {
3948
+ domainCmd.command("get <hostname>").description("Show a configured domain").option("-j, --json", "Output JSON").action(async (hostname2, opts) => {
3602
3949
  try {
3603
- const domain = withStore((store) => store.getDomain(hostname2));
3950
+ const domain = await withRuntimeStore((store) => store.getDomain(hostname2));
3604
3951
  if (!domain)
3605
3952
  throw new Error("Domain not found.");
3606
3953
  print(domain, opts, () => console.log(JSON.stringify(domain, null, 2)));
@@ -3610,8 +3957,8 @@ domainCmd.command("get <hostname>").description("Show a configured domain").opti
3610
3957
  });
3611
3958
  domainCmd.command("setup <hostname>").description("Add a domain locally and optionally prepare Cloudflare DNS").option("--default", "Make this the default domain").option("--origin <url>", "Origin redirect server URL").option("--cloudflare", "Upsert Cloudflare CNAME record").option("--target <hostname>", "CNAME target for Cloudflare DNS").option("--zone-id <id>", "Cloudflare zone ID").option("--dry-run", "Show the Cloudflare plan without changing DNS").option("-j, --json", "Output JSON").action(async (hostname2, opts) => {
3612
3959
  try {
3613
- const result = await withStoreAsync(async (store) => {
3614
- const domain = store.addDomain({
3960
+ const result = await withRuntimeStore(async (store) => {
3961
+ const domain = await store.addDomain({
3615
3962
  hostname: hostname2,
3616
3963
  provider: opts.cloudflare ? "cloudflare" : "manual",
3617
3964
  defaultDomain: opts.default,
@@ -3657,9 +4004,9 @@ domainCmd.command("buy <hostname>").description("Buy a domain through @hasna/dom
3657
4004
  });
3658
4005
  });
3659
4006
  var linkCmd = program2.command("link").alias("links").description("Manage shortlinks");
3660
- function createLinkAction(url, opts) {
4007
+ async function createLinkAction(url, opts) {
3661
4008
  try {
3662
- const link = withStore((store) => store.createLink({
4009
+ const link = await withRuntimeStore((store) => store.createLink({
3663
4010
  destinationUrl: url,
3664
4011
  domain: opts.domain,
3665
4012
  slug: opts.slug,
@@ -3674,9 +4021,9 @@ function createLinkAction(url, opts) {
3674
4021
  }
3675
4022
  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);
3676
4023
  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);
3677
- 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((opts) => {
4024
+ 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) => {
3678
4025
  try {
3679
- const links = withStore((store) => store.listLinks({
4026
+ const links = await withRuntimeStore((store) => store.listLinks({
3680
4027
  domain: opts.domain,
3681
4028
  activeOnly: opts.active,
3682
4029
  limit: Number(opts.limit)
@@ -3693,9 +4040,9 @@ linkCmd.command("list").description("List shortlinks").option("--domain <hostnam
3693
4040
  handleError(error);
3694
4041
  }
3695
4042
  });
3696
- linkCmd.command("get <slug>").description("Show a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action((slug, opts) => {
4043
+ linkCmd.command("get <slug>").description("Show a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action(async (slug, opts) => {
3697
4044
  try {
3698
- const link = withStore((store) => opts.domain ? store.getLink(opts.domain, slug) : store.getLink(slug));
4045
+ const link = await withRuntimeStore((store) => opts.domain ? store.getLink(opts.domain, slug) : store.getLink(slug));
3699
4046
  if (!link)
3700
4047
  throw new Error("Link not found.");
3701
4048
  print(link, opts, () => console.log(JSON.stringify(link, null, 2)));
@@ -3703,33 +4050,33 @@ linkCmd.command("get <slug>").description("Show a shortlink").option("--domain <
3703
4050
  handleError(error);
3704
4051
  }
3705
4052
  });
3706
- linkCmd.command("disable <slug>").description("Disable a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action((slug, opts) => {
4053
+ linkCmd.command("disable <slug>").description("Disable a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action(async (slug, opts) => {
3707
4054
  try {
3708
- const link = withStore((store) => opts.domain ? store.setLinkActive(opts.domain, slug, false) : store.setLinkActive(slug, false));
4055
+ const link = await withRuntimeStore((store) => opts.domain ? store.setLinkActive(opts.domain, slug, false) : store.setLinkActive(slug, false));
3709
4056
  print(link, opts, () => console.log(source_default.green(`Disabled ${link.short_url}`)));
3710
4057
  } catch (error) {
3711
4058
  handleError(error);
3712
4059
  }
3713
4060
  });
3714
- linkCmd.command("enable <slug>").description("Enable a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action((slug, opts) => {
4061
+ linkCmd.command("enable <slug>").description("Enable a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action(async (slug, opts) => {
3715
4062
  try {
3716
- const link = withStore((store) => opts.domain ? store.setLinkActive(opts.domain, slug, true) : store.setLinkActive(slug, true));
4063
+ const link = await withRuntimeStore((store) => opts.domain ? store.setLinkActive(opts.domain, slug, true) : store.setLinkActive(slug, true));
3717
4064
  print(link, opts, () => console.log(source_default.green(`Enabled ${link.short_url}`)));
3718
4065
  } catch (error) {
3719
4066
  handleError(error);
3720
4067
  }
3721
4068
  });
3722
- linkCmd.command("delete <slug>").description("Delete a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action((slug, opts) => {
4069
+ linkCmd.command("delete <slug>").description("Delete a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action(async (slug, opts) => {
3723
4070
  try {
3724
- const link = withStore((store) => opts.domain ? store.deleteLink(opts.domain, slug) : store.deleteLink(slug));
4071
+ const link = await withRuntimeStore((store) => opts.domain ? store.deleteLink(opts.domain, slug) : store.deleteLink(slug));
3725
4072
  print(link, opts, () => console.log(source_default.green(`Deleted ${link.short_url}`)));
3726
4073
  } catch (error) {
3727
4074
  handleError(error);
3728
4075
  }
3729
4076
  });
3730
- program2.command("resolve <slug>").description("Resolve a slug to its destination without recording a click").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action((slug, opts) => {
4077
+ program2.command("resolve <slug>").description("Resolve a slug to its destination without recording a click").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action(async (slug, opts) => {
3731
4078
  try {
3732
- const link = withStore((store) => opts.domain ? store.getLink(opts.domain, slug) : store.getLink(slug));
4079
+ const link = await withRuntimeStore((store) => opts.domain ? store.getLink(opts.domain, slug) : store.getLink(slug));
3733
4080
  if (!link)
3734
4081
  throw new Error("Link not found.");
3735
4082
  print(link, opts, () => console.log(link.destination_url));
@@ -3737,9 +4084,9 @@ program2.command("resolve <slug>").description("Resolve a slug to its destinatio
3737
4084
  handleError(error);
3738
4085
  }
3739
4086
  });
3740
- program2.command("stats [slug]").description("Show overall stats or stats for a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action((slug, opts) => {
4087
+ program2.command("stats [slug]").description("Show overall stats or stats for a shortlink").option("--domain <hostname>", "Domain to use").option("-j, --json", "Output JSON").action(async (slug, opts) => {
3741
4088
  try {
3742
- const result = withStore((store) => {
4089
+ const result = await withRuntimeStore((store) => {
3743
4090
  if (slug)
3744
4091
  return opts.domain ? store.getStats(opts.domain, slug) : store.getStats(slug);
3745
4092
  return store.totalStats();
@@ -3749,15 +4096,18 @@ program2.command("stats [slug]").description("Show overall stats or stats for a
3749
4096
  handleError(error);
3750
4097
  }
3751
4098
  });
3752
- 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").action((opts) => {
4099
+ 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("--cloud", "Serve directly from the shortlinks PostgreSQL database").action(async (opts) => {
3753
4100
  try {
4101
+ const store = opts.cloud || storeMode() === "cloud" ? await PgShortlinksStore.fromCloud("shortlinks") : undefined;
3754
4102
  const server = serveShortlinks({
4103
+ store,
3755
4104
  dbPath: program2.opts().db,
3756
4105
  host: opts.host,
3757
4106
  port: Number(opts.port),
3758
4107
  defaultHost: opts.defaultHost
3759
4108
  });
3760
- console.log(source_default.green(`shortlinks redirect server listening on http://${server.hostname}:${server.port}`));
4109
+ const mode = store ? "cloud" : "local";
4110
+ console.log(source_default.green(`shortlinks redirect server listening on http://${server.hostname}:${server.port} (${mode})`));
3761
4111
  } catch (error) {
3762
4112
  handleError(error);
3763
4113
  }
@@ -3903,11 +4253,13 @@ localCmd.command("setup <domain>").description("Record local domain mapping with
3903
4253
  handleError(error);
3904
4254
  }
3905
4255
  });
3906
- program2.command("doctor").description("Check local shortlinks tooling and integration readiness").option("-j, --json", "Output JSON").action((opts) => {
4256
+ program2.command("doctor").description("Check local shortlinks tooling and integration readiness").option("-j, --json", "Output JSON").action(async (opts) => {
3907
4257
  try {
3908
- const stats = withStore((store) => store.totalStats());
4258
+ const mode = storeMode();
4259
+ const stats = await withRuntimeStore((store) => store.totalStats());
3909
4260
  const data = {
3910
4261
  service: "shortlinks",
4262
+ store: mode,
3911
4263
  data_dir: getDataDir(),
3912
4264
  config_path: getConfigPath(),
3913
4265
  db_path: getDatabasePath(program2.opts().db),
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { ShortlinksDatabase, SQLITE_MIGRATIONS, makeId, now } from "./database.js";
2
2
  export { ShortlinksStore } from "./store.js";
3
+ export { PgShortlinksStore } from "./pg-store.js";
3
4
  export { createShortlinksHandler, serveShortlinks } from "./server.js";
4
5
  export { createCloudflarePlan, generateWorkerScript, writeWorkerFiles, upsertCloudflareDnsRecord } from "./cloudflare.js";
5
6
  export { createLocalSetupPlan, registerMachinesDns } from "./local.js";
package/dist/index.js CHANGED
@@ -1,4 +1,22 @@
1
1
  // @bun
2
+ var __create = Object.create;
3
+ var __getProtoOf = Object.getPrototypeOf;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __toESM = (mod, isNodeMode, target) => {
8
+ target = mod != null ? __create(__getProtoOf(mod)) : {};
9
+ const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
10
+ for (let key of __getOwnPropNames(mod))
11
+ if (!__hasOwnProp.call(to, key))
12
+ __defProp(to, key, {
13
+ get: () => mod[key],
14
+ enumerable: true
15
+ });
16
+ return to;
17
+ };
18
+ var __require = import.meta.require;
19
+
2
20
  // src/database.ts
3
21
  import { Database } from "bun:sqlite";
4
22
  import { mkdirSync as mkdirSync2 } from "fs";
@@ -529,6 +547,325 @@ class ShortlinksStore {
529
547
  return createHash("sha256").update(`${salt}:${ip}`).digest("hex");
530
548
  }
531
549
  }
550
+ // src/pg-store.ts
551
+ import { createHash as createHash2 } from "crypto";
552
+ function parseJsonObject2(value) {
553
+ if (!value)
554
+ return {};
555
+ if (typeof value === "object" && !Array.isArray(value))
556
+ return value;
557
+ try {
558
+ const parsed = JSON.parse(String(value));
559
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
560
+ } catch {
561
+ return {};
562
+ }
563
+ }
564
+ function toIsoString(value) {
565
+ if (value instanceof Date)
566
+ return value.toISOString();
567
+ return String(value);
568
+ }
569
+ function nullableIso(value) {
570
+ if (value === null || value === undefined)
571
+ return null;
572
+ return toIsoString(value);
573
+ }
574
+ function domainFromRow2(row) {
575
+ return {
576
+ ...row,
577
+ default_domain: Boolean(row.default_domain),
578
+ synced_at: nullableIso(row.synced_at),
579
+ created_at: toIsoString(row.created_at),
580
+ updated_at: toIsoString(row.updated_at),
581
+ metadata: parseJsonObject2(row.metadata)
582
+ };
583
+ }
584
+ function linkFromRow2(row) {
585
+ return {
586
+ ...row,
587
+ active: Boolean(row.active),
588
+ expires_at: nullableIso(row.expires_at),
589
+ synced_at: nullableIso(row.synced_at),
590
+ created_at: toIsoString(row.created_at),
591
+ updated_at: toIsoString(row.updated_at),
592
+ metadata: parseJsonObject2(row.metadata),
593
+ short_url: formatShortUrl(row.hostname, row.slug)
594
+ };
595
+ }
596
+ function validateDestinationUrl2(url) {
597
+ let parsed;
598
+ try {
599
+ parsed = new URL(url);
600
+ } catch {
601
+ throw new Error(`Invalid destination URL: ${url}`);
602
+ }
603
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
604
+ throw new Error("Destination URL must start with http:// or https://.");
605
+ }
606
+ return parsed.toString();
607
+ }
608
+ function isoOrNull2(input) {
609
+ if (!input)
610
+ return null;
611
+ const date = new Date(input);
612
+ if (Number.isNaN(date.getTime()))
613
+ throw new Error(`Invalid date: ${input}`);
614
+ return date.toISOString();
615
+ }
616
+ function clickFromRow2(row) {
617
+ return {
618
+ ...row,
619
+ clicked_at: toIsoString(row.clicked_at),
620
+ synced_at: nullableIso(row.synced_at),
621
+ created_at: toIsoString(row.created_at),
622
+ updated_at: toIsoString(row.updated_at),
623
+ metadata: parseJsonObject2(row.metadata)
624
+ };
625
+ }
626
+
627
+ class PgShortlinksStore {
628
+ pg;
629
+ constructor(pg) {
630
+ this.pg = pg;
631
+ }
632
+ static async fromConnectionString(connectionString) {
633
+ const { PgAdapterAsync } = await import("@hasna/cloud");
634
+ return new PgShortlinksStore(new PgAdapterAsync(connectionString));
635
+ }
636
+ static async fromCloud(service = "shortlinks") {
637
+ const { getConnectionString } = await import("@hasna/cloud");
638
+ return PgShortlinksStore.fromConnectionString(getConnectionString(service));
639
+ }
640
+ async close() {
641
+ await this.pg.close?.();
642
+ }
643
+ async addDomain(input) {
644
+ const hostname2 = normalizeHostname(input.hostname);
645
+ const timestamp = now();
646
+ const machineId = getMachineId();
647
+ const existing = await this.getDomain(hostname2);
648
+ const id = existing?.id || makeId("dom");
649
+ if (input.defaultDomain) {
650
+ await this.pg.run("UPDATE domains SET default_domain = 0, updated_at = ? WHERE default_domain = 1", timestamp);
651
+ }
652
+ await this.pg.run(`
653
+ INSERT INTO domains (
654
+ id, hostname, provider, default_domain, cloudflare_zone_id, cloudflare_account_id,
655
+ cloudflare_worker_name, origin_url, notes, metadata, machine_id, synced_at, created_at, updated_at
656
+ )
657
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
658
+ ON CONFLICT(hostname) DO UPDATE SET
659
+ provider = excluded.provider,
660
+ default_domain = excluded.default_domain,
661
+ cloudflare_zone_id = COALESCE(excluded.cloudflare_zone_id, domains.cloudflare_zone_id),
662
+ cloudflare_account_id = COALESCE(excluded.cloudflare_account_id, domains.cloudflare_account_id),
663
+ cloudflare_worker_name = COALESCE(excluded.cloudflare_worker_name, domains.cloudflare_worker_name),
664
+ origin_url = COALESCE(excluded.origin_url, domains.origin_url),
665
+ notes = COALESCE(excluded.notes, domains.notes),
666
+ metadata = excluded.metadata,
667
+ machine_id = excluded.machine_id,
668
+ synced_at = NULL,
669
+ updated_at = excluded.updated_at
670
+ `, id, hostname2, input.provider || existing?.provider || "manual", input.defaultDomain ?? existing?.default_domain ? 1 : 0, input.cloudflareZoneId || existing?.cloudflare_zone_id || null, input.cloudflareAccountId || existing?.cloudflare_account_id || null, input.cloudflareWorkerName || existing?.cloudflare_worker_name || null, input.originUrl || existing?.origin_url || null, input.notes || existing?.notes || null, JSON.stringify(input.metadata || existing?.metadata || {}), machineId, existing?.created_at || timestamp, timestamp);
671
+ return await this.getDomain(hostname2);
672
+ }
673
+ async listDomains() {
674
+ const rows = await this.pg.all(`
675
+ SELECT * FROM domains
676
+ ORDER BY default_domain DESC, hostname ASC
677
+ `);
678
+ return rows.map(domainFromRow2);
679
+ }
680
+ async getDomain(hostnameOrId) {
681
+ const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
682
+ const row = await this.pg.get(`
683
+ SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
684
+ `, normalized, hostnameOrId);
685
+ return row ? domainFromRow2(row) : null;
686
+ }
687
+ async getDefaultDomain() {
688
+ const row = await this.pg.get(`
689
+ SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
690
+ `);
691
+ return row ? domainFromRow2(row) : null;
692
+ }
693
+ async createLink(input) {
694
+ const domain = input.domain ? await this.getDomain(input.domain) : await this.getDefaultDomain();
695
+ if (!domain) {
696
+ throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
697
+ }
698
+ const destinationUrl = validateDestinationUrl2(input.destinationUrl);
699
+ const timestamp = now();
700
+ const machineId = getMachineId();
701
+ const expiresAt = isoOrNull2(input.expiresAt);
702
+ const slug = input.slug ? normalizeSlug(input.slug) : await this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
703
+ try {
704
+ await this.pg.run(`
705
+ INSERT INTO links (
706
+ id, domain_id, slug, destination_url, title, active, expires_at, metadata,
707
+ machine_id, synced_at, created_at, updated_at
708
+ )
709
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
710
+ `, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
711
+ } catch (error) {
712
+ const message = error instanceof Error ? error.message : String(error);
713
+ if (message.includes("unique") || message.includes("duplicate")) {
714
+ throw new Error(`Slug already exists for ${domain.hostname}: ${slug}`);
715
+ }
716
+ throw error;
717
+ }
718
+ return await this.getLink(domain.hostname, slug);
719
+ }
720
+ async listLinks(options = {}) {
721
+ const params = [];
722
+ let where = "WHERE 1 = 1";
723
+ if (options.domain) {
724
+ where += " AND d.hostname = ?";
725
+ params.push(normalizeHostname(options.domain));
726
+ }
727
+ if (options.activeOnly)
728
+ where += " AND l.active = 1";
729
+ params.push(options.limit || 100);
730
+ const rows = await this.pg.all(`
731
+ SELECT l.*, d.hostname
732
+ FROM links l
733
+ JOIN domains d ON d.id = l.domain_id
734
+ ${where}
735
+ ORDER BY l.created_at DESC
736
+ LIMIT ?
737
+ `, ...params);
738
+ return rows.map(linkFromRow2);
739
+ }
740
+ async getLink(domainOrSlug, maybeSlug) {
741
+ const slug = normalizeSlug(maybeSlug || domainOrSlug);
742
+ const params = [slug];
743
+ let domainClause = "";
744
+ if (maybeSlug) {
745
+ domainClause = "AND d.hostname = ?";
746
+ params.push(normalizeHostname(domainOrSlug));
747
+ }
748
+ const row = await this.pg.get(`
749
+ SELECT l.*, d.hostname
750
+ FROM links l
751
+ JOIN domains d ON d.id = l.domain_id
752
+ WHERE l.slug = ? ${domainClause}
753
+ ORDER BY d.default_domain DESC, l.created_at ASC
754
+ LIMIT 1
755
+ `, ...params);
756
+ return row ? linkFromRow2(row) : null;
757
+ }
758
+ async totalStats() {
759
+ const row = await this.pg.get(`
760
+ SELECT
761
+ (SELECT COUNT(*)::int FROM domains) AS domains,
762
+ (SELECT COUNT(*)::int FROM links) AS links,
763
+ (SELECT COUNT(*)::int FROM clicks) AS clicks
764
+ `);
765
+ return row;
766
+ }
767
+ async resolve(hostname2, slug) {
768
+ const normalizedHost = normalizeHostname(hostname2);
769
+ const normalizedSlug = normalizeSlug(slug);
770
+ const row = await this.pg.get(`
771
+ SELECT l.*, d.hostname
772
+ FROM links l
773
+ JOIN domains d ON d.id = l.domain_id
774
+ WHERE d.hostname = ? AND l.slug = ?
775
+ LIMIT 1
776
+ `, normalizedHost, normalizedSlug);
777
+ if (row)
778
+ return linkFromRow2(row);
779
+ const fallback = await this.pg.get(`
780
+ SELECT l.*, d.hostname
781
+ FROM links l
782
+ JOIN domains d ON d.id = l.domain_id
783
+ WHERE d.default_domain = 1 AND l.slug = ?
784
+ ORDER BY d.created_at ASC
785
+ LIMIT 1
786
+ `, normalizedSlug);
787
+ return fallback ? linkFromRow2(fallback) : null;
788
+ }
789
+ async setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
790
+ const active = typeof maybeSlugOrActive === "boolean" ? maybeSlugOrActive : Boolean(maybeActive);
791
+ const link = typeof maybeSlugOrActive === "boolean" ? await this.getLink(domainOrSlug) : await this.getLink(domainOrSlug, maybeSlugOrActive);
792
+ if (!link)
793
+ throw new Error("Link not found.");
794
+ const timestamp = now();
795
+ await this.pg.run(`
796
+ UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
797
+ `, active ? 1 : 0, timestamp, link.id);
798
+ return await this.getLink(link.hostname, link.slug);
799
+ }
800
+ async deleteLink(domainOrSlug, maybeSlug) {
801
+ const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
802
+ if (!link)
803
+ throw new Error("Link not found.");
804
+ await this.pg.run("DELETE FROM links WHERE id = ?", link.id);
805
+ return link;
806
+ }
807
+ async recordClick(link, input = {}) {
808
+ const timestamp = now();
809
+ const machineId = getMachineId();
810
+ const ipHash = input.ip ? this.hashIp(input.ip) : null;
811
+ const id = makeId("clk");
812
+ await this.pg.run(`
813
+ INSERT INTO clicks (
814
+ id, link_id, domain_id, slug, clicked_at, ip_hash, user_agent, referer,
815
+ country, city, metadata, machine_id, synced_at, created_at, updated_at
816
+ )
817
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
818
+ `, id, link.id, link.domain_id, link.slug, timestamp, ipHash, input.userAgent || null, input.referer || null, input.country || null, input.city || null, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
819
+ const row = await this.pg.get("SELECT * FROM clicks WHERE id = ?", id);
820
+ return clickFromRow2(row);
821
+ }
822
+ async getStats(domainOrSlug, maybeSlug) {
823
+ const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
824
+ if (!link)
825
+ throw new Error("Link not found.");
826
+ const summary = await this.pg.get(`
827
+ SELECT COUNT(*)::int AS clicks, MAX(clicked_at) AS last_clicked_at FROM clicks WHERE link_id = ?
828
+ `, link.id);
829
+ const topReferrers = await this.pg.all(`
830
+ SELECT referer, COUNT(*)::int AS clicks
831
+ FROM clicks
832
+ WHERE link_id = ?
833
+ GROUP BY referer
834
+ ORDER BY clicks DESC
835
+ LIMIT 10
836
+ `, link.id);
837
+ const topUserAgents = await this.pg.all(`
838
+ SELECT user_agent, COUNT(*)::int AS clicks
839
+ FROM clicks
840
+ WHERE link_id = ?
841
+ GROUP BY user_agent
842
+ ORDER BY clicks DESC
843
+ LIMIT 10
844
+ `, link.id);
845
+ return {
846
+ link,
847
+ clicks: summary.clicks,
848
+ last_clicked_at: nullableIso(summary.last_clicked_at),
849
+ top_referrers: topReferrers,
850
+ top_user_agents: topUserAgents
851
+ };
852
+ }
853
+ hashIp(ip) {
854
+ const salt = process.env.SHORTLINKS_CLICK_SALT || "shortlinks-cloud";
855
+ return createHash2("sha256").update(`${salt}:${ip}`).digest("hex");
856
+ }
857
+ async generateAvailableSlug(domainId, length) {
858
+ for (let attempt = 0;attempt < 32; attempt += 1) {
859
+ const slug = randomToken(length);
860
+ const exists = await this.pg.get(`
861
+ SELECT 1 FROM links WHERE domain_id = ? AND slug = ? LIMIT 1
862
+ `, domainId, slug);
863
+ if (!exists)
864
+ return slug;
865
+ }
866
+ throw new Error("Could not generate an unused slug after 32 attempts.");
867
+ }
868
+ }
532
869
  // src/server.ts
533
870
  function json(data, status = 200) {
534
871
  return new Response(JSON.stringify(data, null, 2), {
@@ -556,25 +893,35 @@ function createShortlinksHandler(options = {}) {
556
893
  return async (request) => {
557
894
  const url = new URL(request.url);
558
895
  if (url.pathname === "/healthz") {
559
- return json({ ok: true, service: "shortlinks", stats: store.totalStats() });
896
+ return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
560
897
  }
561
898
  if (url.pathname === "/" || url.pathname === "") {
562
899
  return json({ service: "shortlinks", ok: true });
563
900
  }
564
- const slug = decodeURIComponent(url.pathname.replace(/^\/+/, "").split("/")[0] || "");
901
+ let slug = "";
902
+ try {
903
+ slug = decodeURIComponent(url.pathname.replace(/^\/+/, "").split("/")[0] || "");
904
+ } catch {
905
+ return json({ error: "Invalid slug." }, 400);
906
+ }
565
907
  if (!slug)
566
908
  return json({ error: "Missing slug." }, 404);
567
909
  const host = getHost(request, options.defaultHost);
568
910
  if (!host)
569
911
  return json({ error: "Missing Host header." }, 400);
570
- const link = store.resolve(host, slug);
912
+ let link = null;
913
+ try {
914
+ link = await store.resolve(host, slug);
915
+ } catch {
916
+ return json({ error: "Shortlink not found.", slug, host }, 404);
917
+ }
571
918
  if (!link)
572
919
  return json({ error: "Shortlink not found.", slug, host }, 404);
573
920
  if (!link.active)
574
921
  return json({ error: "Shortlink is disabled.", slug, host }, 410);
575
922
  if (isExpired(link))
576
923
  return json({ error: "Shortlink is expired.", slug, host }, 410);
577
- store.recordClick(link, {
924
+ await store.recordClick(link, {
578
925
  ip: getClientIp(request),
579
926
  userAgent: request.headers.get("user-agent"),
580
927
  referer: request.headers.get("referer"),
@@ -863,5 +1210,6 @@ export {
863
1210
  ShortlinksStore,
864
1211
  ShortlinksDatabase,
865
1212
  SQLITE_MIGRATIONS,
1213
+ PgShortlinksStore,
866
1214
  PG_MIGRATIONS
867
1215
  };
@@ -0,0 +1,38 @@
1
+ import type { AddDomainInput, Click, ClickInput, CreateLinkInput, Domain, Link, LinkStats } from "./types.js";
2
+ type PgAdapterLike = {
3
+ get(sql: string, ...params: unknown[]): Promise<any>;
4
+ all(sql: string, ...params: unknown[]): Promise<any[]>;
5
+ run(sql: string, ...params: unknown[]): Promise<unknown>;
6
+ close?: () => Promise<void>;
7
+ };
8
+ export declare class PgShortlinksStore {
9
+ private readonly pg;
10
+ constructor(pg: PgAdapterLike);
11
+ static fromConnectionString(connectionString: string): Promise<PgShortlinksStore>;
12
+ static fromCloud(service?: string): Promise<PgShortlinksStore>;
13
+ close(): Promise<void>;
14
+ addDomain(input: AddDomainInput): Promise<Domain>;
15
+ listDomains(): Promise<Domain[]>;
16
+ getDomain(hostnameOrId: string): Promise<Domain | null>;
17
+ getDefaultDomain(): Promise<Domain | null>;
18
+ createLink(input: CreateLinkInput): Promise<Link>;
19
+ listLinks(options?: {
20
+ domain?: string;
21
+ activeOnly?: boolean;
22
+ limit?: number;
23
+ }): Promise<Link[]>;
24
+ getLink(domainOrSlug: string, maybeSlug?: string): Promise<Link | null>;
25
+ totalStats(): Promise<{
26
+ domains: number;
27
+ links: number;
28
+ clicks: number;
29
+ }>;
30
+ resolve(hostname: string, slug: string): Promise<Link | null>;
31
+ setLinkActive(domainOrSlug: string, maybeSlugOrActive: string | boolean, maybeActive?: boolean): Promise<Link>;
32
+ deleteLink(domainOrSlug: string, maybeSlug?: string): Promise<Link>;
33
+ recordClick(link: Link, input?: ClickInput): Promise<Click>;
34
+ getStats(domainOrSlug: string, maybeSlug?: string): Promise<LinkStats>;
35
+ private hashIp;
36
+ private generateAvailableSlug;
37
+ }
38
+ export {};
package/dist/server.d.ts CHANGED
@@ -1,6 +1,19 @@
1
- import { ShortlinksStore } from "./store.js";
1
+ import type { ClickInput, Link } from "./types.js";
2
+ export interface ShortlinksRuntimeStore {
3
+ totalStats(): {
4
+ domains: number;
5
+ links: number;
6
+ clicks: number;
7
+ } | Promise<{
8
+ domains: number;
9
+ links: number;
10
+ clicks: number;
11
+ }>;
12
+ resolve(hostname: string, slug: string): Link | null | Promise<Link | null>;
13
+ recordClick(link: Link, input?: ClickInput): unknown | Promise<unknown>;
14
+ }
2
15
  export interface ShortlinksHandlerOptions {
3
- store?: ShortlinksStore;
16
+ store?: ShortlinksRuntimeStore;
4
17
  dbPath?: string;
5
18
  defaultHost?: string;
6
19
  redirectStatus?: 301 | 302 | 307 | 308;
package/dist/server.js CHANGED
@@ -558,25 +558,35 @@ function createShortlinksHandler(options = {}) {
558
558
  return async (request) => {
559
559
  const url = new URL(request.url);
560
560
  if (url.pathname === "/healthz") {
561
- return json({ ok: true, service: "shortlinks", stats: store.totalStats() });
561
+ return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
562
562
  }
563
563
  if (url.pathname === "/" || url.pathname === "") {
564
564
  return json({ service: "shortlinks", ok: true });
565
565
  }
566
- const slug = decodeURIComponent(url.pathname.replace(/^\/+/, "").split("/")[0] || "");
566
+ let slug = "";
567
+ try {
568
+ slug = decodeURIComponent(url.pathname.replace(/^\/+/, "").split("/")[0] || "");
569
+ } catch {
570
+ return json({ error: "Invalid slug." }, 400);
571
+ }
567
572
  if (!slug)
568
573
  return json({ error: "Missing slug." }, 404);
569
574
  const host = getHost(request, options.defaultHost);
570
575
  if (!host)
571
576
  return json({ error: "Missing Host header." }, 400);
572
- const link = store.resolve(host, slug);
577
+ let link = null;
578
+ try {
579
+ link = await store.resolve(host, slug);
580
+ } catch {
581
+ return json({ error: "Shortlink not found.", slug, host }, 404);
582
+ }
573
583
  if (!link)
574
584
  return json({ error: "Shortlink not found.", slug, host }, 404);
575
585
  if (!link.active)
576
586
  return json({ error: "Shortlink is disabled.", slug, host }, 410);
577
587
  if (isExpired(link))
578
588
  return json({ error: "Shortlink is expired.", slug, host }, 410);
579
- store.recordClick(link, {
589
+ await store.recordClick(link, {
580
590
  ip: getClientIp(request),
581
591
  userAgent: request.headers.get("user-agent"),
582
592
  referer: request.headers.get("referer"),
@@ -64,9 +64,7 @@ RUNNER
64
64
  chmod 750 /usr/local/bin/shortlinks-env-exec
65
65
  chown root:shortlinks /usr/local/bin/shortlinks-env-exec
66
66
 
67
- su -s /bin/bash shortlinks -c '/usr/local/bin/shortlinks-env-exec shortlinks --json doctor'
68
- su -s /bin/bash shortlinks -c '/usr/local/bin/shortlinks-env-exec shortlinks --json config set default-domain has.na'
69
- su -s /bin/bash shortlinks -c '/usr/local/bin/shortlinks-env-exec shortlinks --json cloud pull --tables domains,links,clicks' || true
67
+ su -s /bin/bash shortlinks -c 'PATH=/var/lib/shortlinks/.bun/bin:$PATH shortlinks --version'
70
68
 
71
69
  caddy_version="$(curl -fsSL https://api.github.com/repos/caddyserver/caddy/releases/latest | jq -r '.tag_name // "v2.10.2"' | sed 's/^v//')"
72
70
  case "$(uname -m)" in
@@ -92,8 +90,8 @@ Group=shortlinks
92
90
  WorkingDirectory=/var/lib/shortlinks
93
91
  Environment=HOME=/var/lib/shortlinks
94
92
  Environment=PATH=/var/lib/shortlinks/.bun/bin:/usr/local/bin:/usr/bin:/bin
95
- ExecStartPre=/usr/local/bin/shortlinks-env-exec shortlinks --json cloud pull --tables domains,links
96
- ExecStart=/usr/local/bin/shortlinks-env-exec shortlinks serve --host 127.0.0.1 --port 8787 --default-host has.na
93
+ Environment=SHORTLINKS_STORE=cloud
94
+ ExecStart=/usr/local/bin/shortlinks-env-exec shortlinks serve --cloud --host 127.0.0.1 --port 8787 --default-host has.na
97
95
  Restart=always
98
96
  RestartSec=5
99
97
 
@@ -101,35 +99,6 @@ RestartSec=5
101
99
  WantedBy=multi-user.target
102
100
  SERVICE
103
101
 
104
- cat > /etc/systemd/system/shortlinks-sync.service <<'SERVICE'
105
- [Unit]
106
- Description=Sync shortlinks SQLite data with RDS
107
- After=network-online.target
108
- Wants=network-online.target
109
-
110
- [Service]
111
- Type=oneshot
112
- User=shortlinks
113
- Group=shortlinks
114
- WorkingDirectory=/var/lib/shortlinks
115
- Environment=HOME=/var/lib/shortlinks
116
- Environment=PATH=/var/lib/shortlinks/.bun/bin:/usr/local/bin:/usr/bin:/bin
117
- ExecStart=/usr/local/bin/shortlinks-env-exec shortlinks --json cloud sync --tables domains,links,clicks
118
- SERVICE
119
-
120
- cat > /etc/systemd/system/shortlinks-sync.timer <<'TIMER'
121
- [Unit]
122
- Description=Run shortlinks cloud sync every minute
123
-
124
- [Timer]
125
- OnBootSec=2min
126
- OnUnitActiveSec=1min
127
- Unit=shortlinks-sync.service
128
-
129
- [Install]
130
- WantedBy=timers.target
131
- TIMER
132
-
133
102
  cat > /etc/systemd/system/caddy.service <<'SERVICE'
134
103
  [Unit]
135
104
  Description=Caddy web server for shortlinks
@@ -162,7 +131,6 @@ has.na {
162
131
  CADDY
163
132
 
164
133
  systemctl daemon-reload
165
- systemctl enable shortlinks.service shortlinks-sync.timer caddy.service
134
+ systemctl enable shortlinks.service caddy.service
166
135
  systemctl start shortlinks.service
167
- systemctl start shortlinks-sync.timer
168
136
  systemctl start caddy.service || true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/shortlinks",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "CLI-only shortlink manager for custom domains, click tracking, Cloudflare setup, and @hasna cloud sync",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,7 +31,7 @@
31
31
  "SECURITY.md"
32
32
  ],
33
33
  "scripts": {
34
- "build": "rm -rf dist && bun build src/cli/index.ts --outdir dist/cli --target bun --external @hasna/cloud && bun build src/index.ts --outdir dist --target bun --external @hasna/cloud && bun build src/server.ts --outdir dist --target bun && bun build src/cloudflare.ts --outdir dist --target bun && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
34
+ "build": "rm -rf dist && bun build src/cli/index.ts --outdir dist/cli --target bun --external @hasna/cloud && bun build src/index.ts --outdir dist --target bun --external @hasna/cloud && bun build src/server.ts --outdir dist --target bun --external @hasna/cloud && bun build src/cloudflare.ts --outdir dist --target bun && tsc -p tsconfig.build.json --emitDeclarationOnly --outDir dist",
35
35
  "typecheck": "tsc --noEmit",
36
36
  "test": "bun test",
37
37
  "dev:cli": "bun run src/cli/index.ts",