@hasna/shortlinks 0.1.7 → 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 +10 -3
- package/dist/cli/index.js +380 -38
- package/dist/index.d.ts +1 -0
- package/dist/index.js +341 -3
- package/dist/pg-store.d.ts +38 -0
- package/dist/server.d.ts +15 -2
- package/dist/server.js +3 -3
- package/infra/aws-ec2-user-data.sh +4 -36
- package/package.json +2 -2
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
|
[](https://www.npmjs.com/package/@hasna/shortlinks)
|
|
8
8
|
[](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
|
-
-
|
|
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,7 +3443,7 @@ 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 });
|
|
@@ -3141,7 +3461,7 @@ function createShortlinksHandler(options = {}) {
|
|
|
3141
3461
|
return json({ error: "Missing Host header." }, 400);
|
|
3142
3462
|
let link = null;
|
|
3143
3463
|
try {
|
|
3144
|
-
link = store.resolve(host, slug);
|
|
3464
|
+
link = await store.resolve(host, slug);
|
|
3145
3465
|
} catch {
|
|
3146
3466
|
return json({ error: "Shortlink not found.", slug, host }, 404);
|
|
3147
3467
|
}
|
|
@@ -3151,7 +3471,7 @@ function createShortlinksHandler(options = {}) {
|
|
|
3151
3471
|
return json({ error: "Shortlink is disabled.", slug, host }, 410);
|
|
3152
3472
|
if (isExpired(link))
|
|
3153
3473
|
return json({ error: "Shortlink is expired.", slug, host }, 410);
|
|
3154
|
-
store.recordClick(link, {
|
|
3474
|
+
await store.recordClick(link, {
|
|
3155
3475
|
ip: getClientIp(request),
|
|
3156
3476
|
userAgent: request.headers.get("user-agent"),
|
|
3157
3477
|
referer: request.headers.get("referer"),
|
|
@@ -3486,7 +3806,22 @@ function withStore(fn) {
|
|
|
3486
3806
|
store.close();
|
|
3487
3807
|
}
|
|
3488
3808
|
}
|
|
3489
|
-
|
|
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
|
+
}
|
|
3490
3825
|
const store = new ShortlinksStore(program2.opts().db);
|
|
3491
3826
|
try {
|
|
3492
3827
|
return await fn(store);
|
|
@@ -3501,15 +3836,15 @@ function commandExists(command) {
|
|
|
3501
3836
|
const result = spawnSync3("which", [command], { encoding: "utf-8" });
|
|
3502
3837
|
return result.status === 0;
|
|
3503
3838
|
}
|
|
3504
|
-
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");
|
|
3505
|
-
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) => {
|
|
3506
3841
|
try {
|
|
3507
|
-
const result =
|
|
3842
|
+
const result = await withRuntimeStore(async (store) => {
|
|
3508
3843
|
const config = loadConfig();
|
|
3509
3844
|
if (opts.publicBaseUrl)
|
|
3510
3845
|
config.publicBaseUrl = opts.publicBaseUrl;
|
|
3511
3846
|
if (opts.domain) {
|
|
3512
|
-
const domain = store.addDomain({
|
|
3847
|
+
const domain = await store.addDomain({
|
|
3513
3848
|
hostname: opts.domain,
|
|
3514
3849
|
provider: "manual",
|
|
3515
3850
|
defaultDomain: true
|
|
@@ -3517,13 +3852,15 @@ program2.command("init").description("Initialize local shortlinks storage").opti
|
|
|
3517
3852
|
config.defaultDomain = domain.hostname;
|
|
3518
3853
|
config.publicBaseUrl = opts.publicBaseUrl || `https://${domain.hostname}`;
|
|
3519
3854
|
}
|
|
3520
|
-
|
|
3855
|
+
if (storeMode() === "local")
|
|
3856
|
+
saveConfig(config);
|
|
3521
3857
|
return {
|
|
3522
3858
|
data_dir: getDataDir(),
|
|
3523
3859
|
config_path: getConfigPath(),
|
|
3524
3860
|
db_path: getDatabasePath(program2.opts().db),
|
|
3861
|
+
store: storeMode(),
|
|
3525
3862
|
config,
|
|
3526
|
-
stats: store.totalStats()
|
|
3863
|
+
stats: await store.totalStats()
|
|
3527
3864
|
};
|
|
3528
3865
|
});
|
|
3529
3866
|
print(result, opts, () => {
|
|
@@ -3570,9 +3907,9 @@ configCmd.command("set <key> <value>").description("Set config value: default-do
|
|
|
3570
3907
|
}
|
|
3571
3908
|
});
|
|
3572
3909
|
var domainCmd = program2.command("domain").alias("domains").description("Manage custom shortlink domains");
|
|
3573
|
-
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) => {
|
|
3574
3911
|
try {
|
|
3575
|
-
const domain =
|
|
3912
|
+
const domain = await withRuntimeStore((store) => store.addDomain({
|
|
3576
3913
|
hostname: hostname2,
|
|
3577
3914
|
provider: opts.provider,
|
|
3578
3915
|
defaultDomain: opts.default,
|
|
@@ -3591,9 +3928,9 @@ domainCmd.command("add <hostname>").description("Add or update a custom domain")
|
|
|
3591
3928
|
handleError(error);
|
|
3592
3929
|
}
|
|
3593
3930
|
});
|
|
3594
|
-
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) => {
|
|
3595
3932
|
try {
|
|
3596
|
-
const domains =
|
|
3933
|
+
const domains = await withRuntimeStore((store) => store.listDomains());
|
|
3597
3934
|
print(domains, opts, () => {
|
|
3598
3935
|
if (domains.length === 0) {
|
|
3599
3936
|
console.log(source_default.dim("No domains configured."));
|
|
@@ -3608,9 +3945,9 @@ domainCmd.command("list").description("List configured domains").option("-j, --j
|
|
|
3608
3945
|
handleError(error);
|
|
3609
3946
|
}
|
|
3610
3947
|
});
|
|
3611
|
-
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) => {
|
|
3612
3949
|
try {
|
|
3613
|
-
const domain =
|
|
3950
|
+
const domain = await withRuntimeStore((store) => store.getDomain(hostname2));
|
|
3614
3951
|
if (!domain)
|
|
3615
3952
|
throw new Error("Domain not found.");
|
|
3616
3953
|
print(domain, opts, () => console.log(JSON.stringify(domain, null, 2)));
|
|
@@ -3620,8 +3957,8 @@ domainCmd.command("get <hostname>").description("Show a configured domain").opti
|
|
|
3620
3957
|
});
|
|
3621
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) => {
|
|
3622
3959
|
try {
|
|
3623
|
-
const result = await
|
|
3624
|
-
const domain = store.addDomain({
|
|
3960
|
+
const result = await withRuntimeStore(async (store) => {
|
|
3961
|
+
const domain = await store.addDomain({
|
|
3625
3962
|
hostname: hostname2,
|
|
3626
3963
|
provider: opts.cloudflare ? "cloudflare" : "manual",
|
|
3627
3964
|
defaultDomain: opts.default,
|
|
@@ -3667,9 +4004,9 @@ domainCmd.command("buy <hostname>").description("Buy a domain through @hasna/dom
|
|
|
3667
4004
|
});
|
|
3668
4005
|
});
|
|
3669
4006
|
var linkCmd = program2.command("link").alias("links").description("Manage shortlinks");
|
|
3670
|
-
function createLinkAction(url, opts) {
|
|
4007
|
+
async function createLinkAction(url, opts) {
|
|
3671
4008
|
try {
|
|
3672
|
-
const link =
|
|
4009
|
+
const link = await withRuntimeStore((store) => store.createLink({
|
|
3673
4010
|
destinationUrl: url,
|
|
3674
4011
|
domain: opts.domain,
|
|
3675
4012
|
slug: opts.slug,
|
|
@@ -3684,9 +4021,9 @@ function createLinkAction(url, opts) {
|
|
|
3684
4021
|
}
|
|
3685
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);
|
|
3686
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);
|
|
3687
|
-
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) => {
|
|
3688
4025
|
try {
|
|
3689
|
-
const links =
|
|
4026
|
+
const links = await withRuntimeStore((store) => store.listLinks({
|
|
3690
4027
|
domain: opts.domain,
|
|
3691
4028
|
activeOnly: opts.active,
|
|
3692
4029
|
limit: Number(opts.limit)
|
|
@@ -3703,9 +4040,9 @@ linkCmd.command("list").description("List shortlinks").option("--domain <hostnam
|
|
|
3703
4040
|
handleError(error);
|
|
3704
4041
|
}
|
|
3705
4042
|
});
|
|
3706
|
-
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) => {
|
|
3707
4044
|
try {
|
|
3708
|
-
const link =
|
|
4045
|
+
const link = await withRuntimeStore((store) => opts.domain ? store.getLink(opts.domain, slug) : store.getLink(slug));
|
|
3709
4046
|
if (!link)
|
|
3710
4047
|
throw new Error("Link not found.");
|
|
3711
4048
|
print(link, opts, () => console.log(JSON.stringify(link, null, 2)));
|
|
@@ -3713,33 +4050,33 @@ linkCmd.command("get <slug>").description("Show a shortlink").option("--domain <
|
|
|
3713
4050
|
handleError(error);
|
|
3714
4051
|
}
|
|
3715
4052
|
});
|
|
3716
|
-
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) => {
|
|
3717
4054
|
try {
|
|
3718
|
-
const link =
|
|
4055
|
+
const link = await withRuntimeStore((store) => opts.domain ? store.setLinkActive(opts.domain, slug, false) : store.setLinkActive(slug, false));
|
|
3719
4056
|
print(link, opts, () => console.log(source_default.green(`Disabled ${link.short_url}`)));
|
|
3720
4057
|
} catch (error) {
|
|
3721
4058
|
handleError(error);
|
|
3722
4059
|
}
|
|
3723
4060
|
});
|
|
3724
|
-
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) => {
|
|
3725
4062
|
try {
|
|
3726
|
-
const link =
|
|
4063
|
+
const link = await withRuntimeStore((store) => opts.domain ? store.setLinkActive(opts.domain, slug, true) : store.setLinkActive(slug, true));
|
|
3727
4064
|
print(link, opts, () => console.log(source_default.green(`Enabled ${link.short_url}`)));
|
|
3728
4065
|
} catch (error) {
|
|
3729
4066
|
handleError(error);
|
|
3730
4067
|
}
|
|
3731
4068
|
});
|
|
3732
|
-
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) => {
|
|
3733
4070
|
try {
|
|
3734
|
-
const link =
|
|
4071
|
+
const link = await withRuntimeStore((store) => opts.domain ? store.deleteLink(opts.domain, slug) : store.deleteLink(slug));
|
|
3735
4072
|
print(link, opts, () => console.log(source_default.green(`Deleted ${link.short_url}`)));
|
|
3736
4073
|
} catch (error) {
|
|
3737
4074
|
handleError(error);
|
|
3738
4075
|
}
|
|
3739
4076
|
});
|
|
3740
|
-
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) => {
|
|
3741
4078
|
try {
|
|
3742
|
-
const link =
|
|
4079
|
+
const link = await withRuntimeStore((store) => opts.domain ? store.getLink(opts.domain, slug) : store.getLink(slug));
|
|
3743
4080
|
if (!link)
|
|
3744
4081
|
throw new Error("Link not found.");
|
|
3745
4082
|
print(link, opts, () => console.log(link.destination_url));
|
|
@@ -3747,9 +4084,9 @@ program2.command("resolve <slug>").description("Resolve a slug to its destinatio
|
|
|
3747
4084
|
handleError(error);
|
|
3748
4085
|
}
|
|
3749
4086
|
});
|
|
3750
|
-
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) => {
|
|
3751
4088
|
try {
|
|
3752
|
-
const result =
|
|
4089
|
+
const result = await withRuntimeStore((store) => {
|
|
3753
4090
|
if (slug)
|
|
3754
4091
|
return opts.domain ? store.getStats(opts.domain, slug) : store.getStats(slug);
|
|
3755
4092
|
return store.totalStats();
|
|
@@ -3759,15 +4096,18 @@ program2.command("stats [slug]").description("Show overall stats or stats for a
|
|
|
3759
4096
|
handleError(error);
|
|
3760
4097
|
}
|
|
3761
4098
|
});
|
|
3762
|
-
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) => {
|
|
3763
4100
|
try {
|
|
4101
|
+
const store = opts.cloud || storeMode() === "cloud" ? await PgShortlinksStore.fromCloud("shortlinks") : undefined;
|
|
3764
4102
|
const server = serveShortlinks({
|
|
4103
|
+
store,
|
|
3765
4104
|
dbPath: program2.opts().db,
|
|
3766
4105
|
host: opts.host,
|
|
3767
4106
|
port: Number(opts.port),
|
|
3768
4107
|
defaultHost: opts.defaultHost
|
|
3769
4108
|
});
|
|
3770
|
-
|
|
4109
|
+
const mode = store ? "cloud" : "local";
|
|
4110
|
+
console.log(source_default.green(`shortlinks redirect server listening on http://${server.hostname}:${server.port} (${mode})`));
|
|
3771
4111
|
} catch (error) {
|
|
3772
4112
|
handleError(error);
|
|
3773
4113
|
}
|
|
@@ -3913,11 +4253,13 @@ localCmd.command("setup <domain>").description("Record local domain mapping with
|
|
|
3913
4253
|
handleError(error);
|
|
3914
4254
|
}
|
|
3915
4255
|
});
|
|
3916
|
-
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) => {
|
|
3917
4257
|
try {
|
|
3918
|
-
const
|
|
4258
|
+
const mode = storeMode();
|
|
4259
|
+
const stats = await withRuntimeStore((store) => store.totalStats());
|
|
3919
4260
|
const data = {
|
|
3920
4261
|
service: "shortlinks",
|
|
4262
|
+
store: mode,
|
|
3921
4263
|
data_dir: getDataDir(),
|
|
3922
4264
|
config_path: getConfigPath(),
|
|
3923
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,7 +893,7 @@ 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 });
|
|
@@ -574,7 +911,7 @@ function createShortlinksHandler(options = {}) {
|
|
|
574
911
|
return json({ error: "Missing Host header." }, 400);
|
|
575
912
|
let link = null;
|
|
576
913
|
try {
|
|
577
|
-
link = store.resolve(host, slug);
|
|
914
|
+
link = await store.resolve(host, slug);
|
|
578
915
|
} catch {
|
|
579
916
|
return json({ error: "Shortlink not found.", slug, host }, 404);
|
|
580
917
|
}
|
|
@@ -584,7 +921,7 @@ function createShortlinksHandler(options = {}) {
|
|
|
584
921
|
return json({ error: "Shortlink is disabled.", slug, host }, 410);
|
|
585
922
|
if (isExpired(link))
|
|
586
923
|
return json({ error: "Shortlink is expired.", slug, host }, 410);
|
|
587
|
-
store.recordClick(link, {
|
|
924
|
+
await store.recordClick(link, {
|
|
588
925
|
ip: getClientIp(request),
|
|
589
926
|
userAgent: request.headers.get("user-agent"),
|
|
590
927
|
referer: request.headers.get("referer"),
|
|
@@ -873,5 +1210,6 @@ export {
|
|
|
873
1210
|
ShortlinksStore,
|
|
874
1211
|
ShortlinksDatabase,
|
|
875
1212
|
SQLITE_MIGRATIONS,
|
|
1213
|
+
PgShortlinksStore,
|
|
876
1214
|
PG_MIGRATIONS
|
|
877
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 {
|
|
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?:
|
|
16
|
+
store?: ShortlinksRuntimeStore;
|
|
4
17
|
dbPath?: string;
|
|
5
18
|
defaultHost?: string;
|
|
6
19
|
redirectStatus?: 301 | 302 | 307 | 308;
|
package/dist/server.js
CHANGED
|
@@ -558,7 +558,7 @@ 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 });
|
|
@@ -576,7 +576,7 @@ function createShortlinksHandler(options = {}) {
|
|
|
576
576
|
return json({ error: "Missing Host header." }, 400);
|
|
577
577
|
let link = null;
|
|
578
578
|
try {
|
|
579
|
-
link = store.resolve(host, slug);
|
|
579
|
+
link = await store.resolve(host, slug);
|
|
580
580
|
} catch {
|
|
581
581
|
return json({ error: "Shortlink not found.", slug, host }, 404);
|
|
582
582
|
}
|
|
@@ -586,7 +586,7 @@ function createShortlinksHandler(options = {}) {
|
|
|
586
586
|
return json({ error: "Shortlink is disabled.", slug, host }, 410);
|
|
587
587
|
if (isExpired(link))
|
|
588
588
|
return json({ error: "Shortlink is expired.", slug, host }, 410);
|
|
589
|
-
store.recordClick(link, {
|
|
589
|
+
await store.recordClick(link, {
|
|
590
590
|
ip: getClientIp(request),
|
|
591
591
|
userAgent: request.headers.get("user-agent"),
|
|
592
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 '/
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|