@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 +10 -3
- package/dist/cli/index.js +391 -39
- package/dist/index.d.ts +1 -0
- package/dist/index.js +352 -4
- package/dist/pg-store.d.ts +38 -0
- package/dist/server.d.ts +15 -2
- package/dist/server.js +14 -4
- 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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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,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
|
-
|
|
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
|
-
|
|
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 '/
|
|
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",
|