@hasna/shortlinks 0.1.21 → 0.1.23
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 +14 -19
- package/dist/cli/index.js +392 -78
- package/dist/config.d.ts +2 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +332 -16
- package/dist/pg-store.d.ts +10 -2
- package/dist/pg-store.js +598 -0
- package/dist/runtime.d.ts +65 -0
- package/dist/runtime.js +181 -0
- package/dist/server.js +53 -5
- package/infra/aws-ec2-user-data.sh +32 -31
- package/package.json +15 -6
package/dist/pg-store.js
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/pg-store.ts
|
|
3
|
+
import { createHash } from "crypto";
|
|
4
|
+
|
|
5
|
+
// src/config.ts
|
|
6
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
7
|
+
import { randomBytes } from "crypto";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { dirname, join, resolve } from "path";
|
|
10
|
+
var SERVICE_NAME = "shortlinks";
|
|
11
|
+
var DEFAULT_DATA_DIR = join(homedir(), ".hasna", SERVICE_NAME);
|
|
12
|
+
function getDataDir() {
|
|
13
|
+
return resolve(process.env.SHORTLINKS_HOME || DEFAULT_DATA_DIR);
|
|
14
|
+
}
|
|
15
|
+
function ensureDataDir() {
|
|
16
|
+
const dir = getDataDir();
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
function getClickSaltPath() {
|
|
21
|
+
return join(ensureDataDir(), "click-salt");
|
|
22
|
+
}
|
|
23
|
+
function readClickSaltFile(path) {
|
|
24
|
+
try {
|
|
25
|
+
const saved = readFileSync(path, "utf-8").trim();
|
|
26
|
+
return saved || null;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function clickSaltError(path, error) {
|
|
32
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
33
|
+
return new Error(`Could not initialize click salt at ${path}. Set SHORTLINKS_CLICK_SALT or fix data directory permissions. ${detail}`);
|
|
34
|
+
}
|
|
35
|
+
function getClickSalt() {
|
|
36
|
+
const explicit = process.env.SHORTLINKS_CLICK_SALT?.trim();
|
|
37
|
+
if (explicit)
|
|
38
|
+
return explicit;
|
|
39
|
+
const path = getClickSaltPath();
|
|
40
|
+
const saved = readClickSaltFile(path);
|
|
41
|
+
if (saved)
|
|
42
|
+
return saved;
|
|
43
|
+
const generated = randomBytes(32).toString("hex");
|
|
44
|
+
const tempPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
45
|
+
try {
|
|
46
|
+
writeFileSync(tempPath, `${generated}
|
|
47
|
+
`, { flag: "wx", mode: 384 });
|
|
48
|
+
try {
|
|
49
|
+
linkSync(tempPath, path);
|
|
50
|
+
return generated;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
const winner = readClickSaltFile(path);
|
|
53
|
+
if (winner)
|
|
54
|
+
return winner;
|
|
55
|
+
throw clickSaltError(path, error);
|
|
56
|
+
} finally {
|
|
57
|
+
try {
|
|
58
|
+
unlinkSync(tempPath);
|
|
59
|
+
} catch {}
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
const winner = readClickSaltFile(path);
|
|
63
|
+
if (winner)
|
|
64
|
+
return winner;
|
|
65
|
+
throw clickSaltError(path, error);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function normalizeHostname(input) {
|
|
69
|
+
const raw = input.trim().toLowerCase();
|
|
70
|
+
if (!raw)
|
|
71
|
+
throw new Error("Domain is required.");
|
|
72
|
+
const withProtocol = raw.includes("://") ? raw : `https://${raw}`;
|
|
73
|
+
let hostname;
|
|
74
|
+
try {
|
|
75
|
+
hostname = new URL(withProtocol).hostname;
|
|
76
|
+
} catch {
|
|
77
|
+
throw new Error(`Invalid domain: ${input}`);
|
|
78
|
+
}
|
|
79
|
+
hostname = hostname.replace(/\.$/, "");
|
|
80
|
+
const labels = hostname.split(".");
|
|
81
|
+
const labelsAreValid = labels.every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9-]+$/.test(label) && !label.startsWith("-") && !label.endsWith("-"));
|
|
82
|
+
if (hostname.length > 253 || !labelsAreValid) {
|
|
83
|
+
throw new Error(`Invalid domain: ${input}`);
|
|
84
|
+
}
|
|
85
|
+
return hostname;
|
|
86
|
+
}
|
|
87
|
+
function formatShortUrl(hostname, slug, publicBaseUrl) {
|
|
88
|
+
if (publicBaseUrl) {
|
|
89
|
+
const base = publicBaseUrl.endsWith("/") ? publicBaseUrl : `${publicBaseUrl}/`;
|
|
90
|
+
return new URL(slug, base).toString();
|
|
91
|
+
}
|
|
92
|
+
return `https://${hostname}/${slug}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/database.ts
|
|
96
|
+
function now() {
|
|
97
|
+
return new Date().toISOString();
|
|
98
|
+
}
|
|
99
|
+
function makeId(prefix) {
|
|
100
|
+
const bytes = crypto.getRandomValues(new Uint8Array(12));
|
|
101
|
+
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
102
|
+
return `${prefix}_${hex}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/machine.ts
|
|
106
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
107
|
+
import { hostname } from "os";
|
|
108
|
+
import { join as join2 } from "path";
|
|
109
|
+
|
|
110
|
+
// src/slug.ts
|
|
111
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
112
|
+
var SLUG_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
113
|
+
var DEFAULT_SLUG_LENGTH = 7;
|
|
114
|
+
function randomToken(length = DEFAULT_SLUG_LENGTH) {
|
|
115
|
+
if (length < 1 || length > 128)
|
|
116
|
+
throw new Error("Token length must be between 1 and 128.");
|
|
117
|
+
const bytes = randomBytes2(length);
|
|
118
|
+
let out = "";
|
|
119
|
+
for (let i = 0;i < length; i += 1) {
|
|
120
|
+
out += SLUG_ALPHABET[bytes[i] % SLUG_ALPHABET.length];
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
function normalizeSlug(slug) {
|
|
125
|
+
const normalized = slug.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
|
126
|
+
if (!normalized)
|
|
127
|
+
throw new Error("Slug is required.");
|
|
128
|
+
if (!/^[A-Za-z0-9_-]{1,96}$/.test(normalized)) {
|
|
129
|
+
throw new Error("Slug can only contain letters, numbers, underscores, and dashes.");
|
|
130
|
+
}
|
|
131
|
+
return normalized;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/machine.ts
|
|
135
|
+
function getMachineId() {
|
|
136
|
+
const path = join2(ensureDataDir(), "machine-id");
|
|
137
|
+
if (existsSync2(path)) {
|
|
138
|
+
const existing = readFileSync2(path, "utf-8").trim();
|
|
139
|
+
if (existing)
|
|
140
|
+
return existing;
|
|
141
|
+
}
|
|
142
|
+
const safeHost = hostname().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
143
|
+
const id = `${safeHost || "machine"}-${randomToken(8).toLowerCase()}`;
|
|
144
|
+
writeFileSync2(path, `${id}
|
|
145
|
+
`);
|
|
146
|
+
return id;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/runtime.ts
|
|
150
|
+
var SHORTLINKS_RUNTIME_ENV = {
|
|
151
|
+
store: "HASNA_SHORTLINKS_STORE",
|
|
152
|
+
databaseUrl: "HASNA_SHORTLINKS_DATABASE_URL",
|
|
153
|
+
databaseSsl: "HASNA_SHORTLINKS_DATABASE_SSL"
|
|
154
|
+
};
|
|
155
|
+
var SHORTLINKS_RUNTIME_FALLBACK_ENV = {
|
|
156
|
+
store: "SHORTLINKS_STORE",
|
|
157
|
+
databaseUrl: "SHORTLINKS_DATABASE_URL",
|
|
158
|
+
databaseSsl: "SHORTLINKS_DATABASE_SSL"
|
|
159
|
+
};
|
|
160
|
+
function getShortlinksDatabaseUrl(env = process.env) {
|
|
161
|
+
return readRuntimeEnv(env, "databaseUrl").value;
|
|
162
|
+
}
|
|
163
|
+
function getShortlinksDatabaseSsl(env = process.env) {
|
|
164
|
+
return parseBoolean(readRuntimeEnv(env, "databaseSsl").value, true);
|
|
165
|
+
}
|
|
166
|
+
function readRuntimeEnv(env, key) {
|
|
167
|
+
const primary = SHORTLINKS_RUNTIME_ENV[key];
|
|
168
|
+
const primaryValue = clean(env[primary]);
|
|
169
|
+
if (primaryValue)
|
|
170
|
+
return { name: primary, value: primaryValue };
|
|
171
|
+
const fallback = SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
|
|
172
|
+
return { name: fallback, value: clean(env[fallback]) };
|
|
173
|
+
}
|
|
174
|
+
function parseBoolean(value, fallback) {
|
|
175
|
+
const normalized = clean(value)?.toLowerCase();
|
|
176
|
+
if (!normalized)
|
|
177
|
+
return fallback;
|
|
178
|
+
if (["1", "true", "yes", "on"].includes(normalized))
|
|
179
|
+
return true;
|
|
180
|
+
if (["0", "false", "no", "off"].includes(normalized))
|
|
181
|
+
return false;
|
|
182
|
+
throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseSsl} must be true or false`);
|
|
183
|
+
}
|
|
184
|
+
function clean(value) {
|
|
185
|
+
const trimmed = value?.trim();
|
|
186
|
+
return trimmed ? trimmed : undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/pg-store.ts
|
|
190
|
+
function parseJsonObject(value) {
|
|
191
|
+
if (!value)
|
|
192
|
+
return {};
|
|
193
|
+
if (typeof value === "object" && !Array.isArray(value))
|
|
194
|
+
return value;
|
|
195
|
+
try {
|
|
196
|
+
const parsed = JSON.parse(String(value));
|
|
197
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
198
|
+
} catch {
|
|
199
|
+
return {};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async function loadPgPool() {
|
|
203
|
+
const importer = new Function("specifier", "return import(specifier)");
|
|
204
|
+
const module = await importer("pg");
|
|
205
|
+
return module.Pool;
|
|
206
|
+
}
|
|
207
|
+
function toPostgresSql(sql) {
|
|
208
|
+
let index = 0;
|
|
209
|
+
return sql.replace(/\?/g, () => `$${++index}`);
|
|
210
|
+
}
|
|
211
|
+
function createPgPoolConfig(connectionString, options = {}) {
|
|
212
|
+
const ssl = options.ssl ?? true;
|
|
213
|
+
return {
|
|
214
|
+
connectionString,
|
|
215
|
+
...ssl ? { ssl: { rejectUnauthorized: true } } : { ssl: false }
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
class PgPoolAdapter {
|
|
220
|
+
pool;
|
|
221
|
+
constructor(pool) {
|
|
222
|
+
this.pool = pool;
|
|
223
|
+
}
|
|
224
|
+
static async create(connectionString, options = {}) {
|
|
225
|
+
const Pool = await loadPgPool();
|
|
226
|
+
return new PgPoolAdapter(new Pool(createPgPoolConfig(connectionString, options)));
|
|
227
|
+
}
|
|
228
|
+
async get(sql, ...params) {
|
|
229
|
+
const result = await this.pool.query(toPostgresSql(sql), params);
|
|
230
|
+
return result.rows[0] ?? null;
|
|
231
|
+
}
|
|
232
|
+
async all(sql, ...params) {
|
|
233
|
+
const result = await this.pool.query(toPostgresSql(sql), params);
|
|
234
|
+
return result.rows;
|
|
235
|
+
}
|
|
236
|
+
async run(sql, ...params) {
|
|
237
|
+
return this.pool.query(toPostgresSql(sql), params);
|
|
238
|
+
}
|
|
239
|
+
async close() {
|
|
240
|
+
await this.pool.end();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function toIsoString(value) {
|
|
244
|
+
if (value instanceof Date)
|
|
245
|
+
return value.toISOString();
|
|
246
|
+
return String(value);
|
|
247
|
+
}
|
|
248
|
+
function nullableIso(value) {
|
|
249
|
+
if (value === null || value === undefined)
|
|
250
|
+
return null;
|
|
251
|
+
return toIsoString(value);
|
|
252
|
+
}
|
|
253
|
+
function domainFromRow(row) {
|
|
254
|
+
return {
|
|
255
|
+
...row,
|
|
256
|
+
default_domain: Boolean(row.default_domain),
|
|
257
|
+
synced_at: nullableIso(row.synced_at),
|
|
258
|
+
created_at: toIsoString(row.created_at),
|
|
259
|
+
updated_at: toIsoString(row.updated_at),
|
|
260
|
+
metadata: parseJsonObject(row.metadata)
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function linkFromRow(row) {
|
|
264
|
+
return {
|
|
265
|
+
...row,
|
|
266
|
+
active: Boolean(row.active),
|
|
267
|
+
expires_at: nullableIso(row.expires_at),
|
|
268
|
+
synced_at: nullableIso(row.synced_at),
|
|
269
|
+
created_at: toIsoString(row.created_at),
|
|
270
|
+
updated_at: toIsoString(row.updated_at),
|
|
271
|
+
metadata: parseJsonObject(row.metadata),
|
|
272
|
+
short_url: formatShortUrl(row.hostname, row.slug)
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
function validateDestinationUrl(url) {
|
|
276
|
+
let parsed;
|
|
277
|
+
try {
|
|
278
|
+
parsed = new URL(url);
|
|
279
|
+
} catch {
|
|
280
|
+
throw new Error(`Invalid destination URL: ${url}`);
|
|
281
|
+
}
|
|
282
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
283
|
+
throw new Error("Destination URL must start with http:// or https://.");
|
|
284
|
+
}
|
|
285
|
+
return parsed.toString();
|
|
286
|
+
}
|
|
287
|
+
function isoOrNull(input) {
|
|
288
|
+
if (!input)
|
|
289
|
+
return null;
|
|
290
|
+
const date = new Date(input);
|
|
291
|
+
if (Number.isNaN(date.getTime()))
|
|
292
|
+
throw new Error(`Invalid date: ${input}`);
|
|
293
|
+
return date.toISOString();
|
|
294
|
+
}
|
|
295
|
+
function clickFromRow(row) {
|
|
296
|
+
return {
|
|
297
|
+
...row,
|
|
298
|
+
clicked_at: toIsoString(row.clicked_at),
|
|
299
|
+
synced_at: nullableIso(row.synced_at),
|
|
300
|
+
created_at: toIsoString(row.created_at),
|
|
301
|
+
updated_at: toIsoString(row.updated_at),
|
|
302
|
+
metadata: parseJsonObject(row.metadata)
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
class PgShortlinksStore {
|
|
307
|
+
pg;
|
|
308
|
+
constructor(pg) {
|
|
309
|
+
this.pg = pg;
|
|
310
|
+
}
|
|
311
|
+
static async fromConnectionString(connectionString, options = {}) {
|
|
312
|
+
return new PgShortlinksStore(await PgPoolAdapter.create(connectionString, options));
|
|
313
|
+
}
|
|
314
|
+
static async fromEnv(env = process.env) {
|
|
315
|
+
const connectionString = getShortlinksDatabaseUrl(env);
|
|
316
|
+
if (!connectionString) {
|
|
317
|
+
throw new Error("HASNA_SHORTLINKS_DATABASE_URL is required when shortlinks uses the postgres store");
|
|
318
|
+
}
|
|
319
|
+
return PgShortlinksStore.fromConnectionString(connectionString, { ssl: getShortlinksDatabaseSsl(env) });
|
|
320
|
+
}
|
|
321
|
+
async close() {
|
|
322
|
+
await this.pg.close?.();
|
|
323
|
+
}
|
|
324
|
+
async addDomain(input) {
|
|
325
|
+
const hostname2 = normalizeHostname(input.hostname);
|
|
326
|
+
const timestamp = now();
|
|
327
|
+
const machineId = getMachineId();
|
|
328
|
+
const existing = await this.getDomain(hostname2);
|
|
329
|
+
const id = existing?.id || makeId("dom");
|
|
330
|
+
if (input.defaultDomain) {
|
|
331
|
+
await this.pg.run("UPDATE domains SET default_domain = 0, updated_at = ? WHERE default_domain = 1", timestamp);
|
|
332
|
+
}
|
|
333
|
+
await this.pg.run(`
|
|
334
|
+
INSERT INTO domains (
|
|
335
|
+
id, hostname, provider, default_domain, cloudflare_zone_id, cloudflare_account_id,
|
|
336
|
+
cloudflare_worker_name, origin_url, notes, metadata, machine_id, synced_at, created_at, updated_at
|
|
337
|
+
)
|
|
338
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
|
|
339
|
+
ON CONFLICT(hostname) DO UPDATE SET
|
|
340
|
+
provider = excluded.provider,
|
|
341
|
+
default_domain = excluded.default_domain,
|
|
342
|
+
cloudflare_zone_id = COALESCE(excluded.cloudflare_zone_id, domains.cloudflare_zone_id),
|
|
343
|
+
cloudflare_account_id = COALESCE(excluded.cloudflare_account_id, domains.cloudflare_account_id),
|
|
344
|
+
cloudflare_worker_name = COALESCE(excluded.cloudflare_worker_name, domains.cloudflare_worker_name),
|
|
345
|
+
origin_url = COALESCE(excluded.origin_url, domains.origin_url),
|
|
346
|
+
notes = COALESCE(excluded.notes, domains.notes),
|
|
347
|
+
metadata = excluded.metadata,
|
|
348
|
+
machine_id = excluded.machine_id,
|
|
349
|
+
synced_at = NULL,
|
|
350
|
+
updated_at = excluded.updated_at
|
|
351
|
+
`, 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);
|
|
352
|
+
return await this.getDomain(hostname2);
|
|
353
|
+
}
|
|
354
|
+
async listDomains() {
|
|
355
|
+
const rows = await this.pg.all(`
|
|
356
|
+
SELECT * FROM domains
|
|
357
|
+
ORDER BY default_domain DESC, hostname ASC
|
|
358
|
+
`);
|
|
359
|
+
return rows.map(domainFromRow);
|
|
360
|
+
}
|
|
361
|
+
async getDomain(hostnameOrId) {
|
|
362
|
+
const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
|
|
363
|
+
const row = await this.pg.get(`
|
|
364
|
+
SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
|
|
365
|
+
`, normalized, hostnameOrId);
|
|
366
|
+
return row ? domainFromRow(row) : null;
|
|
367
|
+
}
|
|
368
|
+
async getDefaultDomain() {
|
|
369
|
+
const row = await this.pg.get(`
|
|
370
|
+
SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
|
|
371
|
+
`);
|
|
372
|
+
return row ? domainFromRow(row) : null;
|
|
373
|
+
}
|
|
374
|
+
async createLink(input) {
|
|
375
|
+
const domain = input.domain ? await this.getDomain(input.domain) : await this.getDefaultDomain();
|
|
376
|
+
if (!domain) {
|
|
377
|
+
throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
|
|
378
|
+
}
|
|
379
|
+
const destinationUrl = validateDestinationUrl(input.destinationUrl);
|
|
380
|
+
const timestamp = now();
|
|
381
|
+
const machineId = getMachineId();
|
|
382
|
+
const expiresAt = isoOrNull(input.expiresAt);
|
|
383
|
+
const slug = input.slug ? normalizeSlug(input.slug) : await this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
|
|
384
|
+
try {
|
|
385
|
+
await this.pg.run(`
|
|
386
|
+
INSERT INTO links (
|
|
387
|
+
id, domain_id, slug, destination_url, title, active, expires_at, metadata,
|
|
388
|
+
machine_id, synced_at, created_at, updated_at
|
|
389
|
+
)
|
|
390
|
+
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
|
|
391
|
+
`, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
|
|
392
|
+
} catch (error) {
|
|
393
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
394
|
+
if (message.includes("unique") || message.includes("duplicate")) {
|
|
395
|
+
throw new Error(`Slug already exists for ${domain.hostname}: ${slug}`);
|
|
396
|
+
}
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
return await this.getLink(domain.hostname, slug);
|
|
400
|
+
}
|
|
401
|
+
async listLinks(options = {}) {
|
|
402
|
+
const params = [];
|
|
403
|
+
let where = "WHERE 1 = 1";
|
|
404
|
+
if (options.domain) {
|
|
405
|
+
where += " AND d.hostname = ?";
|
|
406
|
+
params.push(normalizeHostname(options.domain));
|
|
407
|
+
}
|
|
408
|
+
if (options.activeOnly)
|
|
409
|
+
where += " AND l.active = 1";
|
|
410
|
+
params.push(options.limit || 100);
|
|
411
|
+
const rows = await this.pg.all(`
|
|
412
|
+
SELECT l.*, d.hostname
|
|
413
|
+
FROM links l
|
|
414
|
+
JOIN domains d ON d.id = l.domain_id
|
|
415
|
+
${where}
|
|
416
|
+
ORDER BY l.created_at DESC
|
|
417
|
+
LIMIT ?
|
|
418
|
+
`, ...params);
|
|
419
|
+
return rows.map(linkFromRow);
|
|
420
|
+
}
|
|
421
|
+
async getLink(domainOrSlug, maybeSlug) {
|
|
422
|
+
const slug = normalizeSlug(maybeSlug || domainOrSlug);
|
|
423
|
+
const params = [slug];
|
|
424
|
+
let domainClause = "";
|
|
425
|
+
if (maybeSlug) {
|
|
426
|
+
domainClause = "AND d.hostname = ?";
|
|
427
|
+
params.push(normalizeHostname(domainOrSlug));
|
|
428
|
+
}
|
|
429
|
+
const row = await this.pg.get(`
|
|
430
|
+
SELECT l.*, d.hostname
|
|
431
|
+
FROM links l
|
|
432
|
+
JOIN domains d ON d.id = l.domain_id
|
|
433
|
+
WHERE l.slug = ? ${domainClause}
|
|
434
|
+
ORDER BY d.default_domain DESC, l.created_at ASC
|
|
435
|
+
LIMIT 1
|
|
436
|
+
`, ...params);
|
|
437
|
+
return row ? linkFromRow(row) : null;
|
|
438
|
+
}
|
|
439
|
+
async totalStats() {
|
|
440
|
+
const row = await this.pg.get(`
|
|
441
|
+
SELECT
|
|
442
|
+
(SELECT COUNT(*)::int FROM domains) AS domains,
|
|
443
|
+
(SELECT COUNT(*)::int FROM links) AS links,
|
|
444
|
+
(SELECT COUNT(*)::int FROM clicks) AS clicks
|
|
445
|
+
`);
|
|
446
|
+
return row;
|
|
447
|
+
}
|
|
448
|
+
async resolve(hostname2, slug) {
|
|
449
|
+
const normalizedHost = normalizeHostname(hostname2);
|
|
450
|
+
const normalizedSlug = normalizeSlug(slug);
|
|
451
|
+
const row = await this.pg.get(`
|
|
452
|
+
SELECT l.*, d.hostname
|
|
453
|
+
FROM links l
|
|
454
|
+
JOIN domains d ON d.id = l.domain_id
|
|
455
|
+
WHERE d.hostname = ? AND l.slug = ?
|
|
456
|
+
LIMIT 1
|
|
457
|
+
`, normalizedHost, normalizedSlug);
|
|
458
|
+
if (row)
|
|
459
|
+
return linkFromRow(row);
|
|
460
|
+
const fallback = await this.pg.get(`
|
|
461
|
+
SELECT l.*, d.hostname
|
|
462
|
+
FROM links l
|
|
463
|
+
JOIN domains d ON d.id = l.domain_id
|
|
464
|
+
WHERE d.default_domain = 1 AND l.slug = ?
|
|
465
|
+
ORDER BY d.created_at ASC
|
|
466
|
+
LIMIT 1
|
|
467
|
+
`, normalizedSlug);
|
|
468
|
+
return fallback ? linkFromRow(fallback) : null;
|
|
469
|
+
}
|
|
470
|
+
async setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
|
|
471
|
+
const active = typeof maybeSlugOrActive === "boolean" ? maybeSlugOrActive : Boolean(maybeActive);
|
|
472
|
+
const link = typeof maybeSlugOrActive === "boolean" ? await this.getLink(domainOrSlug) : await this.getLink(domainOrSlug, maybeSlugOrActive);
|
|
473
|
+
if (!link)
|
|
474
|
+
throw new Error("Link not found.");
|
|
475
|
+
const timestamp = now();
|
|
476
|
+
await this.pg.run(`
|
|
477
|
+
UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
|
|
478
|
+
`, active ? 1 : 0, timestamp, link.id);
|
|
479
|
+
return await this.getLink(link.hostname, link.slug);
|
|
480
|
+
}
|
|
481
|
+
async deleteLink(domainOrSlug, maybeSlug) {
|
|
482
|
+
const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
|
|
483
|
+
if (!link)
|
|
484
|
+
throw new Error("Link not found.");
|
|
485
|
+
await this.pg.run("DELETE FROM links WHERE id = ?", link.id);
|
|
486
|
+
return link;
|
|
487
|
+
}
|
|
488
|
+
async recordClick(link, input = {}) {
|
|
489
|
+
const timestamp = now();
|
|
490
|
+
const machineId = getMachineId();
|
|
491
|
+
const ipHash = input.ip ? this.hashIp(input.ip) : null;
|
|
492
|
+
const id = makeId("clk");
|
|
493
|
+
await this.pg.run(`
|
|
494
|
+
INSERT INTO clicks (
|
|
495
|
+
id, link_id, domain_id, slug, clicked_at, ip_hash, user_agent, referer,
|
|
496
|
+
country, city, metadata, machine_id, synced_at, created_at, updated_at
|
|
497
|
+
)
|
|
498
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
|
|
499
|
+
`, 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);
|
|
500
|
+
const row = await this.pg.get("SELECT * FROM clicks WHERE id = ?", id);
|
|
501
|
+
return clickFromRow(row);
|
|
502
|
+
}
|
|
503
|
+
async getStats(domainOrSlug, maybeSlug) {
|
|
504
|
+
const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
|
|
505
|
+
if (!link)
|
|
506
|
+
throw new Error("Link not found.");
|
|
507
|
+
const summary = await this.pg.get(`
|
|
508
|
+
SELECT COUNT(*)::int AS clicks, MAX(clicked_at) AS last_clicked_at FROM clicks WHERE link_id = ?
|
|
509
|
+
`, link.id);
|
|
510
|
+
const topReferrers = await this.pg.all(`
|
|
511
|
+
SELECT referer, COUNT(*)::int AS clicks
|
|
512
|
+
FROM clicks
|
|
513
|
+
WHERE link_id = ?
|
|
514
|
+
GROUP BY referer
|
|
515
|
+
ORDER BY clicks DESC
|
|
516
|
+
LIMIT 10
|
|
517
|
+
`, link.id);
|
|
518
|
+
const topUserAgents = await this.pg.all(`
|
|
519
|
+
SELECT user_agent, COUNT(*)::int AS clicks
|
|
520
|
+
FROM clicks
|
|
521
|
+
WHERE link_id = ?
|
|
522
|
+
GROUP BY user_agent
|
|
523
|
+
ORDER BY clicks DESC
|
|
524
|
+
LIMIT 10
|
|
525
|
+
`, link.id);
|
|
526
|
+
return {
|
|
527
|
+
link,
|
|
528
|
+
clicks: summary.clicks,
|
|
529
|
+
last_clicked_at: nullableIso(summary.last_clicked_at),
|
|
530
|
+
top_referrers: topReferrers,
|
|
531
|
+
top_user_agents: topUserAgents
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
hashIp(ip) {
|
|
535
|
+
return createHash("sha256").update(`${getClickSalt()}:${ip}`).digest("hex");
|
|
536
|
+
}
|
|
537
|
+
async generateAvailableSlug(domainId, length) {
|
|
538
|
+
for (let attempt = 0;attempt < 32; attempt += 1) {
|
|
539
|
+
const slug = randomToken(length);
|
|
540
|
+
const exists = await this.pg.get(`
|
|
541
|
+
SELECT 1 FROM links WHERE domain_id = ? AND slug = ? LIMIT 1
|
|
542
|
+
`, domainId, slug);
|
|
543
|
+
if (!exists)
|
|
544
|
+
return slug;
|
|
545
|
+
}
|
|
546
|
+
throw new Error("Could not generate an unused slug after 32 attempts.");
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
async function applyPostgresMigrations(connectionString, migrations, options = {}) {
|
|
550
|
+
const Pool = await loadPgPool();
|
|
551
|
+
const pool = new Pool(createPgPoolConfig(connectionString, options));
|
|
552
|
+
const client = await pool.connect();
|
|
553
|
+
const run = (sql, ...params) => client.query(toPostgresSql(sql), params);
|
|
554
|
+
const get = async (sql, ...params) => {
|
|
555
|
+
const result = await run(sql, ...params);
|
|
556
|
+
return result.rows[0] ?? null;
|
|
557
|
+
};
|
|
558
|
+
const applied = [];
|
|
559
|
+
const skipped = [];
|
|
560
|
+
try {
|
|
561
|
+
await run("BEGIN");
|
|
562
|
+
await run(`
|
|
563
|
+
SELECT pg_advisory_xact_lock(hashtext(?))
|
|
564
|
+
`, "shortlinks:migrations");
|
|
565
|
+
await run(`
|
|
566
|
+
CREATE TABLE IF NOT EXISTS _shortlinks_migrations (
|
|
567
|
+
id INTEGER PRIMARY KEY,
|
|
568
|
+
service TEXT NOT NULL DEFAULT 'shortlinks',
|
|
569
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
570
|
+
)
|
|
571
|
+
`);
|
|
572
|
+
for (let i = 0;i < migrations.length; i += 1) {
|
|
573
|
+
const id = i + 1;
|
|
574
|
+
const existing = await get("SELECT id FROM _shortlinks_migrations WHERE id = ? LIMIT 1", id);
|
|
575
|
+
if (existing) {
|
|
576
|
+
skipped.push(id);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
await run(migrations[i]);
|
|
580
|
+
await run("INSERT INTO _shortlinks_migrations (id, service, applied_at) VALUES (?, ?, now())", id, "shortlinks");
|
|
581
|
+
applied.push(id);
|
|
582
|
+
}
|
|
583
|
+
await run("COMMIT");
|
|
584
|
+
return { service: "shortlinks", applied, skipped };
|
|
585
|
+
} catch (error) {
|
|
586
|
+
try {
|
|
587
|
+
await run("ROLLBACK");
|
|
588
|
+
} catch {}
|
|
589
|
+
throw error;
|
|
590
|
+
} finally {
|
|
591
|
+
client.release();
|
|
592
|
+
await pool.end();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
export {
|
|
596
|
+
applyPostgresMigrations,
|
|
597
|
+
PgShortlinksStore
|
|
598
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export type ShortlinksStoreMode = "local" | "postgres";
|
|
2
|
+
export type ShortlinksRuntimeEnv = Record<string, string | undefined>;
|
|
3
|
+
export interface ShortlinksPostgresConfig {
|
|
4
|
+
provider: "postgres";
|
|
5
|
+
url: string;
|
|
6
|
+
ssl: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface ShortlinksRuntimeConfig {
|
|
9
|
+
service: "shortlinks";
|
|
10
|
+
mode: ShortlinksStoreMode;
|
|
11
|
+
database?: ShortlinksPostgresConfig;
|
|
12
|
+
}
|
|
13
|
+
export declare const SHORTLINKS_RUNTIME_ENV: {
|
|
14
|
+
readonly store: "HASNA_SHORTLINKS_STORE";
|
|
15
|
+
readonly databaseUrl: "HASNA_SHORTLINKS_DATABASE_URL";
|
|
16
|
+
readonly databaseSsl: "HASNA_SHORTLINKS_DATABASE_SSL";
|
|
17
|
+
};
|
|
18
|
+
export declare const SHORTLINKS_RUNTIME_FALLBACK_ENV: {
|
|
19
|
+
readonly store: "SHORTLINKS_STORE";
|
|
20
|
+
readonly databaseUrl: "SHORTLINKS_DATABASE_URL";
|
|
21
|
+
readonly databaseSsl: "SHORTLINKS_DATABASE_SSL";
|
|
22
|
+
};
|
|
23
|
+
export declare const CANONICAL_SHORTLINKS_POSTGRES_CLUSTER = "hasna-xyz-infra-apps-prod-postgres";
|
|
24
|
+
export declare const CANONICAL_SHORTLINKS_POSTGRES_DATABASE = "shortlinks";
|
|
25
|
+
export declare const CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH = "hasna/xyz/opensource/shortlinks/prod/postgres";
|
|
26
|
+
export interface CanonicalShortlinksPostgresConfig {
|
|
27
|
+
cluster: typeof CANONICAL_SHORTLINKS_POSTGRES_CLUSTER;
|
|
28
|
+
database: typeof CANONICAL_SHORTLINKS_POSTGRES_DATABASE;
|
|
29
|
+
runtimeSecretPath: typeof CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH;
|
|
30
|
+
primaryEnv: typeof SHORTLINKS_RUNTIME_ENV.databaseUrl;
|
|
31
|
+
fallbackEnv: typeof SHORTLINKS_RUNTIME_FALLBACK_ENV.databaseUrl;
|
|
32
|
+
}
|
|
33
|
+
export interface RuntimeEnvStatus {
|
|
34
|
+
name: string;
|
|
35
|
+
active_name: string;
|
|
36
|
+
configured: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface ShortlinksRuntimeStatus {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
service: "shortlinks";
|
|
41
|
+
mode: ShortlinksStoreMode;
|
|
42
|
+
local_default: boolean;
|
|
43
|
+
postgres_enabled: boolean;
|
|
44
|
+
database: {
|
|
45
|
+
configured: boolean;
|
|
46
|
+
provider: "postgres" | null;
|
|
47
|
+
redacted_url: string | null;
|
|
48
|
+
ssl: boolean | null;
|
|
49
|
+
};
|
|
50
|
+
env: Record<keyof typeof SHORTLINKS_RUNTIME_ENV, RuntimeEnvStatus>;
|
|
51
|
+
canonical: CanonicalShortlinksPostgresConfig;
|
|
52
|
+
issues: string[];
|
|
53
|
+
warnings: string[];
|
|
54
|
+
no_network: true;
|
|
55
|
+
}
|
|
56
|
+
export declare function getCanonicalShortlinksPostgresConfig(): CanonicalShortlinksPostgresConfig;
|
|
57
|
+
export declare function parseShortlinksStoreMode(value: string | undefined): ShortlinksStoreMode;
|
|
58
|
+
export declare function getShortlinksStoreMode(env?: ShortlinksRuntimeEnv): ShortlinksStoreMode;
|
|
59
|
+
export declare function getShortlinksDatabaseUrl(env?: ShortlinksRuntimeEnv): string | undefined;
|
|
60
|
+
export declare function getShortlinksDatabaseSsl(env?: ShortlinksRuntimeEnv): boolean;
|
|
61
|
+
export declare function getShortlinksRuntimeEnvName(env: ShortlinksRuntimeEnv, key: keyof typeof SHORTLINKS_RUNTIME_ENV): string;
|
|
62
|
+
export declare function loadShortlinksRuntimeConfig(env?: ShortlinksRuntimeEnv): ShortlinksRuntimeConfig;
|
|
63
|
+
export declare function assertShortlinksPostgresConfig(config: ShortlinksRuntimeConfig): void;
|
|
64
|
+
export declare function getShortlinksRuntimeStatus(env?: ShortlinksRuntimeEnv): ShortlinksRuntimeStatus;
|
|
65
|
+
export declare function redactDatabaseUrl(value: string | undefined): string | null;
|