@hasna/shortlinks 0.1.7 → 0.1.9
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/infra/aws-ec2-user-data.sh +4 -36
- package/package.json +2 -2
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.js +0 -3944
- package/dist/cloudflare.d.ts +0 -43
- package/dist/cloudflare.js +0 -168
- package/dist/config.d.ts +0 -20
- package/dist/database.d.ts +0 -11
- package/dist/domains-cli.d.ts +0 -10
- package/dist/index.d.ts +0 -9
- package/dist/index.js +0 -877
- package/dist/local.d.ts +0 -27
- package/dist/machine.d.ts +0 -1
- package/dist/pg-migrations.d.ts +0 -1
- package/dist/server.d.ts +0 -12
- package/dist/server.js +0 -611
- package/dist/slug.d.ts +0 -4
- package/dist/store.d.ts +0 -30
- package/dist/types.d.ts +0 -90
package/dist/index.js
DELETED
|
@@ -1,877 +0,0 @@
|
|
|
1
|
-
// @bun
|
|
2
|
-
// src/database.ts
|
|
3
|
-
import { Database } from "bun:sqlite";
|
|
4
|
-
import { mkdirSync as mkdirSync2 } from "fs";
|
|
5
|
-
import { dirname as dirname2 } from "path";
|
|
6
|
-
|
|
7
|
-
// src/config.ts
|
|
8
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
9
|
-
import { homedir } from "os";
|
|
10
|
-
import { dirname, join, resolve } from "path";
|
|
11
|
-
var SERVICE_NAME = "shortlinks";
|
|
12
|
-
var DEFAULT_DATA_DIR = join(homedir(), ".hasna", SERVICE_NAME);
|
|
13
|
-
function getDataDir() {
|
|
14
|
-
return resolve(process.env.SHORTLINKS_HOME || DEFAULT_DATA_DIR);
|
|
15
|
-
}
|
|
16
|
-
function ensureDataDir() {
|
|
17
|
-
const dir = getDataDir();
|
|
18
|
-
mkdirSync(dir, { recursive: true });
|
|
19
|
-
return dir;
|
|
20
|
-
}
|
|
21
|
-
function getConfigPath() {
|
|
22
|
-
return join(ensureDataDir(), "config.json");
|
|
23
|
-
}
|
|
24
|
-
function getDatabasePath(explicitPath) {
|
|
25
|
-
if (explicitPath)
|
|
26
|
-
return resolve(explicitPath);
|
|
27
|
-
if (process.env.SHORTLINKS_DB)
|
|
28
|
-
return resolve(process.env.SHORTLINKS_DB);
|
|
29
|
-
return join(ensureDataDir(), `${SERVICE_NAME}.db`);
|
|
30
|
-
}
|
|
31
|
-
function loadConfig() {
|
|
32
|
-
const path = getConfigPath();
|
|
33
|
-
if (!existsSync(path))
|
|
34
|
-
return {};
|
|
35
|
-
try {
|
|
36
|
-
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
37
|
-
return parsed && typeof parsed === "object" ? parsed : {};
|
|
38
|
-
} catch {
|
|
39
|
-
return {};
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
function saveConfig(config) {
|
|
43
|
-
const path = getConfigPath();
|
|
44
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
45
|
-
writeFileSync(path, `${JSON.stringify(config, null, 2)}
|
|
46
|
-
`);
|
|
47
|
-
}
|
|
48
|
-
function updateConfig(patch) {
|
|
49
|
-
const next = {
|
|
50
|
-
...loadConfig(),
|
|
51
|
-
...patch,
|
|
52
|
-
cloudflare: {
|
|
53
|
-
...loadConfig().cloudflare,
|
|
54
|
-
...patch.cloudflare
|
|
55
|
-
}
|
|
56
|
-
};
|
|
57
|
-
saveConfig(next);
|
|
58
|
-
return next;
|
|
59
|
-
}
|
|
60
|
-
function normalizeHostname(input) {
|
|
61
|
-
const raw = input.trim().toLowerCase();
|
|
62
|
-
if (!raw)
|
|
63
|
-
throw new Error("Domain is required.");
|
|
64
|
-
const withProtocol = raw.includes("://") ? raw : `https://${raw}`;
|
|
65
|
-
let hostname;
|
|
66
|
-
try {
|
|
67
|
-
hostname = new URL(withProtocol).hostname;
|
|
68
|
-
} catch {
|
|
69
|
-
throw new Error(`Invalid domain: ${input}`);
|
|
70
|
-
}
|
|
71
|
-
hostname = hostname.replace(/\.$/, "");
|
|
72
|
-
if (!/^[a-z0-9.-]+$/.test(hostname) || hostname.includes("..")) {
|
|
73
|
-
throw new Error(`Invalid domain: ${input}`);
|
|
74
|
-
}
|
|
75
|
-
return hostname;
|
|
76
|
-
}
|
|
77
|
-
function formatShortUrl(hostname, slug, publicBaseUrl) {
|
|
78
|
-
if (publicBaseUrl) {
|
|
79
|
-
const base = publicBaseUrl.endsWith("/") ? publicBaseUrl : `${publicBaseUrl}/`;
|
|
80
|
-
return new URL(slug, base).toString();
|
|
81
|
-
}
|
|
82
|
-
return `https://${hostname}/${slug}`;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// src/database.ts
|
|
86
|
-
function now() {
|
|
87
|
-
return new Date().toISOString();
|
|
88
|
-
}
|
|
89
|
-
function makeId(prefix) {
|
|
90
|
-
const bytes = crypto.getRandomValues(new Uint8Array(12));
|
|
91
|
-
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
92
|
-
return `${prefix}_${hex}`;
|
|
93
|
-
}
|
|
94
|
-
var SQLITE_MIGRATIONS = [
|
|
95
|
-
`
|
|
96
|
-
CREATE TABLE IF NOT EXISTS domains (
|
|
97
|
-
id TEXT PRIMARY KEY,
|
|
98
|
-
hostname TEXT NOT NULL UNIQUE,
|
|
99
|
-
provider TEXT NOT NULL DEFAULT 'manual',
|
|
100
|
-
default_domain INTEGER NOT NULL DEFAULT 0,
|
|
101
|
-
cloudflare_zone_id TEXT,
|
|
102
|
-
cloudflare_account_id TEXT,
|
|
103
|
-
cloudflare_worker_name TEXT,
|
|
104
|
-
origin_url TEXT,
|
|
105
|
-
notes TEXT,
|
|
106
|
-
metadata TEXT NOT NULL DEFAULT '{}',
|
|
107
|
-
machine_id TEXT,
|
|
108
|
-
synced_at TEXT,
|
|
109
|
-
created_at TEXT NOT NULL,
|
|
110
|
-
updated_at TEXT NOT NULL
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
CREATE TABLE IF NOT EXISTS links (
|
|
114
|
-
id TEXT PRIMARY KEY,
|
|
115
|
-
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
116
|
-
slug TEXT NOT NULL,
|
|
117
|
-
destination_url TEXT NOT NULL,
|
|
118
|
-
title TEXT,
|
|
119
|
-
active INTEGER NOT NULL DEFAULT 1,
|
|
120
|
-
expires_at TEXT,
|
|
121
|
-
metadata TEXT NOT NULL DEFAULT '{}',
|
|
122
|
-
machine_id TEXT,
|
|
123
|
-
synced_at TEXT,
|
|
124
|
-
created_at TEXT NOT NULL,
|
|
125
|
-
updated_at TEXT NOT NULL,
|
|
126
|
-
UNIQUE(domain_id, slug)
|
|
127
|
-
);
|
|
128
|
-
|
|
129
|
-
CREATE TABLE IF NOT EXISTS clicks (
|
|
130
|
-
id TEXT PRIMARY KEY,
|
|
131
|
-
link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
|
|
132
|
-
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
133
|
-
slug TEXT NOT NULL,
|
|
134
|
-
clicked_at TEXT NOT NULL,
|
|
135
|
-
ip_hash TEXT,
|
|
136
|
-
user_agent TEXT,
|
|
137
|
-
referer TEXT,
|
|
138
|
-
country TEXT,
|
|
139
|
-
city TEXT,
|
|
140
|
-
metadata TEXT NOT NULL DEFAULT '{}',
|
|
141
|
-
machine_id TEXT,
|
|
142
|
-
synced_at TEXT,
|
|
143
|
-
created_at TEXT NOT NULL,
|
|
144
|
-
updated_at TEXT NOT NULL
|
|
145
|
-
);
|
|
146
|
-
|
|
147
|
-
CREATE INDEX IF NOT EXISTS idx_domains_hostname ON domains(hostname);
|
|
148
|
-
CREATE INDEX IF NOT EXISTS idx_domains_default ON domains(default_domain);
|
|
149
|
-
CREATE INDEX IF NOT EXISTS idx_links_domain_slug ON links(domain_id, slug);
|
|
150
|
-
CREATE INDEX IF NOT EXISTS idx_links_active ON links(active);
|
|
151
|
-
CREATE INDEX IF NOT EXISTS idx_links_updated ON links(updated_at);
|
|
152
|
-
CREATE INDEX IF NOT EXISTS idx_clicks_link ON clicks(link_id);
|
|
153
|
-
CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
|
|
154
|
-
CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
|
|
155
|
-
CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
|
|
156
|
-
`
|
|
157
|
-
];
|
|
158
|
-
|
|
159
|
-
class ShortlinksDatabase {
|
|
160
|
-
db;
|
|
161
|
-
path;
|
|
162
|
-
constructor(path) {
|
|
163
|
-
this.path = getDatabasePath(path);
|
|
164
|
-
mkdirSync2(dirname2(this.path), { recursive: true });
|
|
165
|
-
this.db = new Database(this.path);
|
|
166
|
-
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
167
|
-
this.applyMigrations();
|
|
168
|
-
}
|
|
169
|
-
close() {
|
|
170
|
-
this.db.close();
|
|
171
|
-
}
|
|
172
|
-
applyMigrations() {
|
|
173
|
-
this.db.exec(`
|
|
174
|
-
CREATE TABLE IF NOT EXISTS _migrations (
|
|
175
|
-
id INTEGER PRIMARY KEY,
|
|
176
|
-
applied_at TEXT NOT NULL
|
|
177
|
-
);
|
|
178
|
-
`);
|
|
179
|
-
for (let i = 0;i < SQLITE_MIGRATIONS.length; i += 1) {
|
|
180
|
-
const id = i + 1;
|
|
181
|
-
const applied = this.db.query("SELECT id FROM _migrations WHERE id = ?").get(id);
|
|
182
|
-
if (applied)
|
|
183
|
-
continue;
|
|
184
|
-
const migration = SQLITE_MIGRATIONS[i];
|
|
185
|
-
const apply = this.db.transaction(() => {
|
|
186
|
-
this.db.exec(migration);
|
|
187
|
-
this.db.query("INSERT INTO _migrations (id, applied_at) VALUES (?, ?)").run(id, now());
|
|
188
|
-
});
|
|
189
|
-
apply();
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
// src/store.ts
|
|
194
|
-
import { createHash } from "crypto";
|
|
195
|
-
|
|
196
|
-
// src/machine.ts
|
|
197
|
-
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
198
|
-
import { hostname } from "os";
|
|
199
|
-
import { join as join2 } from "path";
|
|
200
|
-
|
|
201
|
-
// src/slug.ts
|
|
202
|
-
import { randomBytes } from "crypto";
|
|
203
|
-
var SLUG_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
204
|
-
var DEFAULT_SLUG_LENGTH = 7;
|
|
205
|
-
function randomToken(length = DEFAULT_SLUG_LENGTH) {
|
|
206
|
-
if (length < 1 || length > 128)
|
|
207
|
-
throw new Error("Token length must be between 1 and 128.");
|
|
208
|
-
const bytes = randomBytes(length);
|
|
209
|
-
let out = "";
|
|
210
|
-
for (let i = 0;i < length; i += 1) {
|
|
211
|
-
out += SLUG_ALPHABET[bytes[i] % SLUG_ALPHABET.length];
|
|
212
|
-
}
|
|
213
|
-
return out;
|
|
214
|
-
}
|
|
215
|
-
function normalizeSlug(slug) {
|
|
216
|
-
const normalized = slug.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
|
217
|
-
if (!normalized)
|
|
218
|
-
throw new Error("Slug is required.");
|
|
219
|
-
if (!/^[A-Za-z0-9_-]{1,96}$/.test(normalized)) {
|
|
220
|
-
throw new Error("Slug can only contain letters, numbers, underscores, and dashes.");
|
|
221
|
-
}
|
|
222
|
-
return normalized;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// src/machine.ts
|
|
226
|
-
function getMachineId() {
|
|
227
|
-
const path = join2(ensureDataDir(), "machine-id");
|
|
228
|
-
if (existsSync2(path)) {
|
|
229
|
-
const existing = readFileSync2(path, "utf-8").trim();
|
|
230
|
-
if (existing)
|
|
231
|
-
return existing;
|
|
232
|
-
}
|
|
233
|
-
const safeHost = hostname().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
234
|
-
const id = `${safeHost || "machine"}-${randomToken(8).toLowerCase()}`;
|
|
235
|
-
writeFileSync2(path, `${id}
|
|
236
|
-
`);
|
|
237
|
-
return id;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
// src/store.ts
|
|
241
|
-
function parseJsonObject(value) {
|
|
242
|
-
if (!value)
|
|
243
|
-
return {};
|
|
244
|
-
try {
|
|
245
|
-
const parsed = JSON.parse(value);
|
|
246
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
247
|
-
} catch {
|
|
248
|
-
return {};
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
function domainFromRow(row) {
|
|
252
|
-
return {
|
|
253
|
-
...row,
|
|
254
|
-
default_domain: Boolean(row.default_domain),
|
|
255
|
-
metadata: parseJsonObject(row.metadata)
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
function linkFromRow(row) {
|
|
259
|
-
const config = loadConfig();
|
|
260
|
-
const publicBaseUrl = config.defaultDomain === row.hostname ? config.publicBaseUrl : undefined;
|
|
261
|
-
return {
|
|
262
|
-
...row,
|
|
263
|
-
active: Boolean(row.active),
|
|
264
|
-
metadata: parseJsonObject(row.metadata),
|
|
265
|
-
short_url: formatShortUrl(row.hostname, row.slug, publicBaseUrl)
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
function clickFromRow(row) {
|
|
269
|
-
return {
|
|
270
|
-
...row,
|
|
271
|
-
metadata: parseJsonObject(row.metadata)
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
function validateDestinationUrl(url) {
|
|
275
|
-
let parsed;
|
|
276
|
-
try {
|
|
277
|
-
parsed = new URL(url);
|
|
278
|
-
} catch {
|
|
279
|
-
throw new Error(`Invalid destination URL: ${url}`);
|
|
280
|
-
}
|
|
281
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
282
|
-
throw new Error("Destination URL must start with http:// or https://.");
|
|
283
|
-
}
|
|
284
|
-
return parsed.toString();
|
|
285
|
-
}
|
|
286
|
-
function isoOrNull(input) {
|
|
287
|
-
if (!input)
|
|
288
|
-
return null;
|
|
289
|
-
const date = new Date(input);
|
|
290
|
-
if (Number.isNaN(date.getTime()))
|
|
291
|
-
throw new Error(`Invalid date: ${input}`);
|
|
292
|
-
return date.toISOString();
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
class ShortlinksStore {
|
|
296
|
-
database;
|
|
297
|
-
constructor(dbPath) {
|
|
298
|
-
this.database = new ShortlinksDatabase(dbPath);
|
|
299
|
-
}
|
|
300
|
-
close() {
|
|
301
|
-
this.database.close();
|
|
302
|
-
}
|
|
303
|
-
addDomain(input) {
|
|
304
|
-
const hostname2 = normalizeHostname(input.hostname);
|
|
305
|
-
const timestamp = now();
|
|
306
|
-
const machineId = getMachineId();
|
|
307
|
-
const existing = this.getDomain(hostname2);
|
|
308
|
-
const id = existing?.id || makeId("dom");
|
|
309
|
-
if (input.defaultDomain) {
|
|
310
|
-
this.database.db.query("UPDATE domains SET default_domain = 0, updated_at = ?, synced_at = NULL").run(timestamp);
|
|
311
|
-
updateConfig({ defaultDomain: hostname2, publicBaseUrl: `https://${hostname2}` });
|
|
312
|
-
}
|
|
313
|
-
this.database.db.query(`
|
|
314
|
-
INSERT INTO domains (
|
|
315
|
-
id, hostname, provider, default_domain, cloudflare_zone_id, cloudflare_account_id,
|
|
316
|
-
cloudflare_worker_name, origin_url, notes, metadata, machine_id, synced_at, created_at, updated_at
|
|
317
|
-
)
|
|
318
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
|
|
319
|
-
ON CONFLICT(hostname) DO UPDATE SET
|
|
320
|
-
provider = excluded.provider,
|
|
321
|
-
default_domain = excluded.default_domain,
|
|
322
|
-
cloudflare_zone_id = COALESCE(excluded.cloudflare_zone_id, domains.cloudflare_zone_id),
|
|
323
|
-
cloudflare_account_id = COALESCE(excluded.cloudflare_account_id, domains.cloudflare_account_id),
|
|
324
|
-
cloudflare_worker_name = COALESCE(excluded.cloudflare_worker_name, domains.cloudflare_worker_name),
|
|
325
|
-
origin_url = COALESCE(excluded.origin_url, domains.origin_url),
|
|
326
|
-
notes = COALESCE(excluded.notes, domains.notes),
|
|
327
|
-
metadata = excluded.metadata,
|
|
328
|
-
machine_id = excluded.machine_id,
|
|
329
|
-
synced_at = NULL,
|
|
330
|
-
updated_at = excluded.updated_at
|
|
331
|
-
`).run(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);
|
|
332
|
-
return this.getDomain(hostname2);
|
|
333
|
-
}
|
|
334
|
-
listDomains() {
|
|
335
|
-
const rows = this.database.db.query(`
|
|
336
|
-
SELECT * FROM domains
|
|
337
|
-
ORDER BY default_domain DESC, hostname ASC
|
|
338
|
-
`).all();
|
|
339
|
-
return rows.map(domainFromRow);
|
|
340
|
-
}
|
|
341
|
-
getDomain(hostnameOrId) {
|
|
342
|
-
const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
|
|
343
|
-
const row = this.database.db.query(`
|
|
344
|
-
SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
|
|
345
|
-
`).get(normalized, hostnameOrId);
|
|
346
|
-
return row ? domainFromRow(row) : null;
|
|
347
|
-
}
|
|
348
|
-
getDefaultDomain() {
|
|
349
|
-
const config = loadConfig();
|
|
350
|
-
if (config.defaultDomain) {
|
|
351
|
-
const configured = this.getDomain(config.defaultDomain);
|
|
352
|
-
if (configured)
|
|
353
|
-
return configured;
|
|
354
|
-
}
|
|
355
|
-
const row = this.database.db.query(`
|
|
356
|
-
SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
|
|
357
|
-
`).get();
|
|
358
|
-
return row ? domainFromRow(row) : null;
|
|
359
|
-
}
|
|
360
|
-
createLink(input) {
|
|
361
|
-
const domain = input.domain ? this.getDomain(input.domain) : this.getDefaultDomain();
|
|
362
|
-
if (!domain) {
|
|
363
|
-
throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
|
|
364
|
-
}
|
|
365
|
-
const destinationUrl = validateDestinationUrl(input.destinationUrl);
|
|
366
|
-
const timestamp = now();
|
|
367
|
-
const machineId = getMachineId();
|
|
368
|
-
const expiresAt = isoOrNull(input.expiresAt);
|
|
369
|
-
const slug = input.slug ? normalizeSlug(input.slug) : this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
|
|
370
|
-
try {
|
|
371
|
-
this.database.db.query(`
|
|
372
|
-
INSERT INTO links (
|
|
373
|
-
id, domain_id, slug, destination_url, title, active, expires_at, metadata,
|
|
374
|
-
machine_id, synced_at, created_at, updated_at
|
|
375
|
-
)
|
|
376
|
-
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
|
|
377
|
-
`).run(makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
|
|
378
|
-
} catch (error) {
|
|
379
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
380
|
-
if (message.includes("UNIQUE")) {
|
|
381
|
-
throw new Error(`Slug already exists for ${domain.hostname}: ${slug}`);
|
|
382
|
-
}
|
|
383
|
-
throw error;
|
|
384
|
-
}
|
|
385
|
-
return this.getLink(domain.hostname, slug);
|
|
386
|
-
}
|
|
387
|
-
listLinks(options = {}) {
|
|
388
|
-
const params = [];
|
|
389
|
-
let where = "WHERE 1 = 1";
|
|
390
|
-
if (options.domain) {
|
|
391
|
-
where += " AND d.hostname = ?";
|
|
392
|
-
params.push(normalizeHostname(options.domain));
|
|
393
|
-
}
|
|
394
|
-
if (options.activeOnly) {
|
|
395
|
-
where += " AND l.active = 1";
|
|
396
|
-
}
|
|
397
|
-
params.push(options.limit || 100);
|
|
398
|
-
const rows = this.database.db.query(`
|
|
399
|
-
SELECT l.*, d.hostname
|
|
400
|
-
FROM links l
|
|
401
|
-
JOIN domains d ON d.id = l.domain_id
|
|
402
|
-
${where}
|
|
403
|
-
ORDER BY l.created_at DESC
|
|
404
|
-
LIMIT ?
|
|
405
|
-
`).all(...params);
|
|
406
|
-
return rows.map(linkFromRow);
|
|
407
|
-
}
|
|
408
|
-
getLink(domainOrSlug, maybeSlug) {
|
|
409
|
-
const slug = normalizeSlug(maybeSlug || domainOrSlug);
|
|
410
|
-
const params = [slug];
|
|
411
|
-
let domainClause = "";
|
|
412
|
-
if (maybeSlug) {
|
|
413
|
-
domainClause = "AND d.hostname = ?";
|
|
414
|
-
params.push(normalizeHostname(domainOrSlug));
|
|
415
|
-
}
|
|
416
|
-
const row = this.database.db.query(`
|
|
417
|
-
SELECT l.*, d.hostname
|
|
418
|
-
FROM links l
|
|
419
|
-
JOIN domains d ON d.id = l.domain_id
|
|
420
|
-
WHERE l.slug = ? ${domainClause}
|
|
421
|
-
ORDER BY d.default_domain DESC, l.created_at ASC
|
|
422
|
-
LIMIT 1
|
|
423
|
-
`).get(...params);
|
|
424
|
-
return row ? linkFromRow(row) : null;
|
|
425
|
-
}
|
|
426
|
-
resolve(hostname2, slug) {
|
|
427
|
-
const normalizedSlug = normalizeSlug(slug);
|
|
428
|
-
const normalizedHost = normalizeHostname(hostname2);
|
|
429
|
-
const row = this.database.db.query(`
|
|
430
|
-
SELECT l.*, d.hostname
|
|
431
|
-
FROM links l
|
|
432
|
-
JOIN domains d ON d.id = l.domain_id
|
|
433
|
-
WHERE d.hostname = ? AND l.slug = ?
|
|
434
|
-
LIMIT 1
|
|
435
|
-
`).get(normalizedHost, normalizedSlug);
|
|
436
|
-
if (row)
|
|
437
|
-
return linkFromRow(row);
|
|
438
|
-
const fallback = this.getDefaultDomain();
|
|
439
|
-
if (!fallback || fallback.hostname === normalizedHost)
|
|
440
|
-
return null;
|
|
441
|
-
return this.getLink(fallback.hostname, normalizedSlug);
|
|
442
|
-
}
|
|
443
|
-
setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
|
|
444
|
-
const active = typeof maybeSlugOrActive === "boolean" ? maybeSlugOrActive : Boolean(maybeActive);
|
|
445
|
-
const link = typeof maybeSlugOrActive === "boolean" ? this.getLink(domainOrSlug) : this.getLink(domainOrSlug, maybeSlugOrActive);
|
|
446
|
-
if (!link)
|
|
447
|
-
throw new Error("Link not found.");
|
|
448
|
-
const timestamp = now();
|
|
449
|
-
this.database.db.query(`
|
|
450
|
-
UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
|
|
451
|
-
`).run(active ? 1 : 0, timestamp, link.id);
|
|
452
|
-
return this.getLink(link.hostname, link.slug);
|
|
453
|
-
}
|
|
454
|
-
deleteLink(domainOrSlug, maybeSlug) {
|
|
455
|
-
const link = maybeSlug ? this.getLink(domainOrSlug, maybeSlug) : this.getLink(domainOrSlug);
|
|
456
|
-
if (!link)
|
|
457
|
-
throw new Error("Link not found.");
|
|
458
|
-
this.database.db.query("DELETE FROM links WHERE id = ?").run(link.id);
|
|
459
|
-
return link;
|
|
460
|
-
}
|
|
461
|
-
recordClick(link, input = {}) {
|
|
462
|
-
const timestamp = now();
|
|
463
|
-
const machineId = getMachineId();
|
|
464
|
-
const ipHash = input.ip ? this.hashIp(input.ip) : null;
|
|
465
|
-
const id = makeId("clk");
|
|
466
|
-
this.database.db.query(`
|
|
467
|
-
INSERT INTO clicks (
|
|
468
|
-
id, link_id, domain_id, slug, clicked_at, ip_hash, user_agent, referer,
|
|
469
|
-
country, city, metadata, machine_id, synced_at, created_at, updated_at
|
|
470
|
-
)
|
|
471
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
|
|
472
|
-
`).run(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);
|
|
473
|
-
const row = this.database.db.query("SELECT * FROM clicks WHERE id = ?").get(id);
|
|
474
|
-
return clickFromRow(row);
|
|
475
|
-
}
|
|
476
|
-
getStats(domainOrSlug, maybeSlug) {
|
|
477
|
-
const link = maybeSlug ? this.getLink(domainOrSlug, maybeSlug) : this.getLink(domainOrSlug);
|
|
478
|
-
if (!link)
|
|
479
|
-
throw new Error("Link not found.");
|
|
480
|
-
const summary = this.database.db.query(`
|
|
481
|
-
SELECT COUNT(*) AS clicks, MAX(clicked_at) AS last_clicked_at FROM clicks WHERE link_id = ?
|
|
482
|
-
`).get(link.id);
|
|
483
|
-
const topReferrers = this.database.db.query(`
|
|
484
|
-
SELECT referer, COUNT(*) AS clicks
|
|
485
|
-
FROM clicks
|
|
486
|
-
WHERE link_id = ?
|
|
487
|
-
GROUP BY referer
|
|
488
|
-
ORDER BY clicks DESC
|
|
489
|
-
LIMIT 10
|
|
490
|
-
`).all(link.id);
|
|
491
|
-
const topUserAgents = this.database.db.query(`
|
|
492
|
-
SELECT user_agent, COUNT(*) AS clicks
|
|
493
|
-
FROM clicks
|
|
494
|
-
WHERE link_id = ?
|
|
495
|
-
GROUP BY user_agent
|
|
496
|
-
ORDER BY clicks DESC
|
|
497
|
-
LIMIT 10
|
|
498
|
-
`).all(link.id);
|
|
499
|
-
return {
|
|
500
|
-
link,
|
|
501
|
-
clicks: summary.clicks,
|
|
502
|
-
last_clicked_at: summary.last_clicked_at,
|
|
503
|
-
top_referrers: topReferrers,
|
|
504
|
-
top_user_agents: topUserAgents
|
|
505
|
-
};
|
|
506
|
-
}
|
|
507
|
-
totalStats() {
|
|
508
|
-
const row = this.database.db.query(`
|
|
509
|
-
SELECT
|
|
510
|
-
(SELECT COUNT(*) FROM domains) AS domains,
|
|
511
|
-
(SELECT COUNT(*) FROM links) AS links,
|
|
512
|
-
(SELECT COUNT(*) FROM clicks) AS clicks
|
|
513
|
-
`).get();
|
|
514
|
-
return row;
|
|
515
|
-
}
|
|
516
|
-
generateAvailableSlug(domainId, length) {
|
|
517
|
-
for (let attempt = 0;attempt < 32; attempt += 1) {
|
|
518
|
-
const slug = randomToken(length);
|
|
519
|
-
const exists = this.database.db.query(`
|
|
520
|
-
SELECT 1 FROM links WHERE domain_id = ? AND slug = ? LIMIT 1
|
|
521
|
-
`).get(domainId, slug);
|
|
522
|
-
if (!exists)
|
|
523
|
-
return slug;
|
|
524
|
-
}
|
|
525
|
-
throw new Error("Could not generate an unused slug after 32 attempts.");
|
|
526
|
-
}
|
|
527
|
-
hashIp(ip) {
|
|
528
|
-
const salt = process.env.SHORTLINKS_CLICK_SALT || "shortlinks-local";
|
|
529
|
-
return createHash("sha256").update(`${salt}:${ip}`).digest("hex");
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
// src/server.ts
|
|
533
|
-
function json(data, status = 200) {
|
|
534
|
-
return new Response(JSON.stringify(data, null, 2), {
|
|
535
|
-
status,
|
|
536
|
-
headers: { "content-type": "application/json; charset=utf-8" }
|
|
537
|
-
});
|
|
538
|
-
}
|
|
539
|
-
function getHost(request, fallback) {
|
|
540
|
-
const forwarded = request.headers.get("x-forwarded-host");
|
|
541
|
-
const host = forwarded || request.headers.get("host") || fallback || "";
|
|
542
|
-
return host.split(",")[0].trim().split(":")[0];
|
|
543
|
-
}
|
|
544
|
-
function getClientIp(request) {
|
|
545
|
-
const forwarded = request.headers.get("x-forwarded-for");
|
|
546
|
-
if (forwarded)
|
|
547
|
-
return forwarded.split(",")[0].trim();
|
|
548
|
-
return request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip");
|
|
549
|
-
}
|
|
550
|
-
function isExpired(link) {
|
|
551
|
-
return Boolean(link.expires_at && new Date(link.expires_at).getTime() <= Date.now());
|
|
552
|
-
}
|
|
553
|
-
function createShortlinksHandler(options = {}) {
|
|
554
|
-
const store = options.store || new ShortlinksStore(options.dbPath);
|
|
555
|
-
const redirectStatus = options.redirectStatus || 302;
|
|
556
|
-
return async (request) => {
|
|
557
|
-
const url = new URL(request.url);
|
|
558
|
-
if (url.pathname === "/healthz") {
|
|
559
|
-
return json({ ok: true, service: "shortlinks", stats: store.totalStats() });
|
|
560
|
-
}
|
|
561
|
-
if (url.pathname === "/" || url.pathname === "") {
|
|
562
|
-
return json({ service: "shortlinks", ok: true });
|
|
563
|
-
}
|
|
564
|
-
let slug = "";
|
|
565
|
-
try {
|
|
566
|
-
slug = decodeURIComponent(url.pathname.replace(/^\/+/, "").split("/")[0] || "");
|
|
567
|
-
} catch {
|
|
568
|
-
return json({ error: "Invalid slug." }, 400);
|
|
569
|
-
}
|
|
570
|
-
if (!slug)
|
|
571
|
-
return json({ error: "Missing slug." }, 404);
|
|
572
|
-
const host = getHost(request, options.defaultHost);
|
|
573
|
-
if (!host)
|
|
574
|
-
return json({ error: "Missing Host header." }, 400);
|
|
575
|
-
let link = null;
|
|
576
|
-
try {
|
|
577
|
-
link = store.resolve(host, slug);
|
|
578
|
-
} catch {
|
|
579
|
-
return json({ error: "Shortlink not found.", slug, host }, 404);
|
|
580
|
-
}
|
|
581
|
-
if (!link)
|
|
582
|
-
return json({ error: "Shortlink not found.", slug, host }, 404);
|
|
583
|
-
if (!link.active)
|
|
584
|
-
return json({ error: "Shortlink is disabled.", slug, host }, 410);
|
|
585
|
-
if (isExpired(link))
|
|
586
|
-
return json({ error: "Shortlink is expired.", slug, host }, 410);
|
|
587
|
-
store.recordClick(link, {
|
|
588
|
-
ip: getClientIp(request),
|
|
589
|
-
userAgent: request.headers.get("user-agent"),
|
|
590
|
-
referer: request.headers.get("referer"),
|
|
591
|
-
country: request.headers.get("cf-ipcountry"),
|
|
592
|
-
metadata: {
|
|
593
|
-
path: url.pathname,
|
|
594
|
-
query: url.search
|
|
595
|
-
}
|
|
596
|
-
});
|
|
597
|
-
return Response.redirect(link.destination_url, redirectStatus);
|
|
598
|
-
};
|
|
599
|
-
}
|
|
600
|
-
function serveShortlinks(options = {}) {
|
|
601
|
-
const host = options.host || "127.0.0.1";
|
|
602
|
-
const port = options.port || 8787;
|
|
603
|
-
const fetch2 = createShortlinksHandler(options);
|
|
604
|
-
return Bun.serve({ hostname: host, port, fetch: fetch2 });
|
|
605
|
-
}
|
|
606
|
-
// src/cloudflare.ts
|
|
607
|
-
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
608
|
-
import { join as join3 } from "path";
|
|
609
|
-
function createCloudflarePlan(input) {
|
|
610
|
-
const hostname2 = normalizeHostname(input.hostname);
|
|
611
|
-
const target = normalizeHostname(input.target);
|
|
612
|
-
const workerName = input.workerName || "shortlinks";
|
|
613
|
-
const proxied = input.proxied ?? true;
|
|
614
|
-
return {
|
|
615
|
-
hostname: hostname2,
|
|
616
|
-
target,
|
|
617
|
-
proxied,
|
|
618
|
-
workerName,
|
|
619
|
-
origin: input.origin,
|
|
620
|
-
dnsRecord: {
|
|
621
|
-
type: "CNAME",
|
|
622
|
-
name: hostname2,
|
|
623
|
-
content: target,
|
|
624
|
-
proxied
|
|
625
|
-
},
|
|
626
|
-
wranglerCommand: `wrangler deploy cloudflare/${workerName}.js --name ${workerName}`
|
|
627
|
-
};
|
|
628
|
-
}
|
|
629
|
-
function generateWorkerScript() {
|
|
630
|
-
return `export default {
|
|
631
|
-
async fetch(request, env) {
|
|
632
|
-
const origin = env.SHORTLINKS_ORIGIN;
|
|
633
|
-
if (!origin) {
|
|
634
|
-
return new Response("SHORTLINKS_ORIGIN is not configured", { status: 500 });
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
const incoming = new URL(request.url);
|
|
638
|
-
const upstream = new URL(incoming.pathname + incoming.search, origin);
|
|
639
|
-
const headers = new Headers(request.headers);
|
|
640
|
-
headers.set("x-forwarded-host", incoming.host);
|
|
641
|
-
headers.set("x-shortlinks-worker", "cloudflare");
|
|
642
|
-
|
|
643
|
-
return fetch(upstream.toString(), {
|
|
644
|
-
method: request.method,
|
|
645
|
-
headers,
|
|
646
|
-
body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
|
|
647
|
-
redirect: "manual"
|
|
648
|
-
});
|
|
649
|
-
}
|
|
650
|
-
};
|
|
651
|
-
`;
|
|
652
|
-
}
|
|
653
|
-
function writeWorkerFiles(options = {}) {
|
|
654
|
-
const outDir = options.outDir || "cloudflare";
|
|
655
|
-
const workerName = options.workerName || "shortlinks";
|
|
656
|
-
mkdirSync3(outDir, { recursive: true });
|
|
657
|
-
const workerPath = join3(outDir, `${workerName}.js`);
|
|
658
|
-
const wranglerPath = join3(outDir, "wrangler.example.toml");
|
|
659
|
-
writeFileSync3(workerPath, generateWorkerScript());
|
|
660
|
-
writeFileSync3(wranglerPath, `name = "${workerName}"
|
|
661
|
-
main = "${workerName}.js"
|
|
662
|
-
compatibility_date = "2026-05-01"
|
|
663
|
-
|
|
664
|
-
[vars]
|
|
665
|
-
SHORTLINKS_ORIGIN = "${options.origin || "https://shortlinks.example.com"}"
|
|
666
|
-
`);
|
|
667
|
-
return { workerPath, wranglerPath };
|
|
668
|
-
}
|
|
669
|
-
function cloudflareAuthHeaders(token) {
|
|
670
|
-
const apiToken = token || process.env.CLOUDFLARE_API_TOKEN;
|
|
671
|
-
if (apiToken)
|
|
672
|
-
return { authorization: `Bearer ${apiToken}` };
|
|
673
|
-
const apiKey = process.env.CLOUDFLARE_API_KEY;
|
|
674
|
-
const email = process.env.CLOUDFLARE_EMAIL;
|
|
675
|
-
if (apiKey && email) {
|
|
676
|
-
return {
|
|
677
|
-
"x-auth-key": apiKey,
|
|
678
|
-
"x-auth-email": email
|
|
679
|
-
};
|
|
680
|
-
}
|
|
681
|
-
throw new Error("Cloudflare auth is required: set CLOUDFLARE_API_TOKEN, or CLOUDFLARE_API_KEY plus CLOUDFLARE_EMAIL.");
|
|
682
|
-
}
|
|
683
|
-
async function cloudflareRequest(token, path, init = {}) {
|
|
684
|
-
const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
|
|
685
|
-
...init,
|
|
686
|
-
headers: {
|
|
687
|
-
...cloudflareAuthHeaders(token),
|
|
688
|
-
"content-type": "application/json",
|
|
689
|
-
...init.headers || {}
|
|
690
|
-
}
|
|
691
|
-
});
|
|
692
|
-
const body = await response.json();
|
|
693
|
-
if (!response.ok || body.success === false) {
|
|
694
|
-
const message = body.errors?.map((e) => e.message).join("; ") || response.statusText;
|
|
695
|
-
throw new Error(`Cloudflare API failed: ${message}`);
|
|
696
|
-
}
|
|
697
|
-
return body.result;
|
|
698
|
-
}
|
|
699
|
-
function candidateZones(hostname2) {
|
|
700
|
-
const parts = normalizeHostname(hostname2).split(".");
|
|
701
|
-
const candidates = [];
|
|
702
|
-
for (let i = 0;i < parts.length - 1; i += 1) {
|
|
703
|
-
candidates.push(parts.slice(i).join("."));
|
|
704
|
-
}
|
|
705
|
-
return candidates;
|
|
706
|
-
}
|
|
707
|
-
async function findCloudflareZoneId(hostname2, token) {
|
|
708
|
-
for (const zone of candidateZones(hostname2)) {
|
|
709
|
-
const result = await cloudflareRequest(token, `/zones?name=${encodeURIComponent(zone)}`);
|
|
710
|
-
if (result[0]?.id)
|
|
711
|
-
return result[0].id;
|
|
712
|
-
}
|
|
713
|
-
throw new Error(`Could not find a Cloudflare zone for ${hostname2}. Pass --zone-id explicitly.`);
|
|
714
|
-
}
|
|
715
|
-
async function upsertCloudflareDnsRecord(options) {
|
|
716
|
-
const token = options.token || process.env.CLOUDFLARE_API_TOKEN;
|
|
717
|
-
const plan = createCloudflarePlan({
|
|
718
|
-
hostname: options.hostname,
|
|
719
|
-
target: options.target,
|
|
720
|
-
origin: process.env.SHORTLINKS_ORIGIN || "https://shortlinks.example.com",
|
|
721
|
-
proxied: options.proxied
|
|
722
|
-
});
|
|
723
|
-
if (options.dryRun)
|
|
724
|
-
return plan;
|
|
725
|
-
const zoneId = options.zoneId || await findCloudflareZoneId(plan.hostname, token);
|
|
726
|
-
const existing = await cloudflareRequest(token, `/zones/${zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(plan.hostname)}`);
|
|
727
|
-
const payload = JSON.stringify(plan.dnsRecord);
|
|
728
|
-
if (existing[0]?.id) {
|
|
729
|
-
const updated = await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${existing[0].id}`, {
|
|
730
|
-
method: "PUT",
|
|
731
|
-
body: payload
|
|
732
|
-
});
|
|
733
|
-
return { id: updated.id, action: "updated" };
|
|
734
|
-
}
|
|
735
|
-
const created = await cloudflareRequest(token, `/zones/${zoneId}/dns_records`, {
|
|
736
|
-
method: "POST",
|
|
737
|
-
body: payload
|
|
738
|
-
});
|
|
739
|
-
return { id: created.id, action: "created" };
|
|
740
|
-
}
|
|
741
|
-
// src/local.ts
|
|
742
|
-
import { spawnSync } from "child_process";
|
|
743
|
-
import { homedir as homedir2 } from "os";
|
|
744
|
-
import { join as join4 } from "path";
|
|
745
|
-
function createLocalSetupPlan(input) {
|
|
746
|
-
const domain = normalizeHostname(input.domain);
|
|
747
|
-
const targetHost = input.targetHost || "127.0.0.1";
|
|
748
|
-
const port = input.port || 8787;
|
|
749
|
-
const certDir = input.certDir || join4(homedir2(), ".hasna", "machines", "certs");
|
|
750
|
-
const certPath = join4(certDir, `${domain}.pem`);
|
|
751
|
-
const keyPath = join4(certDir, `${domain}-key.pem`);
|
|
752
|
-
return {
|
|
753
|
-
domain,
|
|
754
|
-
targetHost,
|
|
755
|
-
port,
|
|
756
|
-
hostsEntry: `${targetHost} ${domain}`,
|
|
757
|
-
caddySnippet: `${domain} {
|
|
758
|
-
reverse_proxy ${targetHost}:${port}
|
|
759
|
-
tls ${certPath} ${keyPath}
|
|
760
|
-
}`,
|
|
761
|
-
certPath,
|
|
762
|
-
keyPath,
|
|
763
|
-
machinesCommand: `machines dns add --domain ${domain} --target-host ${targetHost} --port ${port} --json`,
|
|
764
|
-
sudoRequired: true
|
|
765
|
-
};
|
|
766
|
-
}
|
|
767
|
-
function registerMachinesDns(input) {
|
|
768
|
-
const plan = createLocalSetupPlan(input);
|
|
769
|
-
const args = [
|
|
770
|
-
"dns",
|
|
771
|
-
"add",
|
|
772
|
-
"--domain",
|
|
773
|
-
plan.domain,
|
|
774
|
-
"--target-host",
|
|
775
|
-
plan.targetHost,
|
|
776
|
-
"--port",
|
|
777
|
-
String(plan.port),
|
|
778
|
-
"--json"
|
|
779
|
-
];
|
|
780
|
-
const result = spawnSync("machines", args, { encoding: "utf-8" });
|
|
781
|
-
return {
|
|
782
|
-
command: `machines ${args.join(" ")}`,
|
|
783
|
-
status: result.status,
|
|
784
|
-
stdout: result.stdout || "",
|
|
785
|
-
stderr: result.stderr || ""
|
|
786
|
-
};
|
|
787
|
-
}
|
|
788
|
-
// src/pg-migrations.ts
|
|
789
|
-
var PG_MIGRATIONS = [
|
|
790
|
-
`
|
|
791
|
-
CREATE TABLE IF NOT EXISTS domains (
|
|
792
|
-
id TEXT PRIMARY KEY,
|
|
793
|
-
hostname TEXT NOT NULL UNIQUE,
|
|
794
|
-
provider TEXT NOT NULL DEFAULT 'manual',
|
|
795
|
-
default_domain INTEGER NOT NULL DEFAULT 0,
|
|
796
|
-
cloudflare_zone_id TEXT,
|
|
797
|
-
cloudflare_account_id TEXT,
|
|
798
|
-
cloudflare_worker_name TEXT,
|
|
799
|
-
origin_url TEXT,
|
|
800
|
-
notes TEXT,
|
|
801
|
-
metadata TEXT NOT NULL DEFAULT '{}',
|
|
802
|
-
machine_id TEXT,
|
|
803
|
-
synced_at TIMESTAMPTZ,
|
|
804
|
-
created_at TIMESTAMPTZ NOT NULL,
|
|
805
|
-
updated_at TIMESTAMPTZ NOT NULL
|
|
806
|
-
);
|
|
807
|
-
|
|
808
|
-
CREATE TABLE IF NOT EXISTS links (
|
|
809
|
-
id TEXT PRIMARY KEY,
|
|
810
|
-
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
811
|
-
slug TEXT NOT NULL,
|
|
812
|
-
destination_url TEXT NOT NULL,
|
|
813
|
-
title TEXT,
|
|
814
|
-
active INTEGER NOT NULL DEFAULT 1,
|
|
815
|
-
expires_at TIMESTAMPTZ,
|
|
816
|
-
metadata TEXT NOT NULL DEFAULT '{}',
|
|
817
|
-
machine_id TEXT,
|
|
818
|
-
synced_at TIMESTAMPTZ,
|
|
819
|
-
created_at TIMESTAMPTZ NOT NULL,
|
|
820
|
-
updated_at TIMESTAMPTZ NOT NULL,
|
|
821
|
-
UNIQUE(domain_id, slug)
|
|
822
|
-
);
|
|
823
|
-
|
|
824
|
-
CREATE TABLE IF NOT EXISTS clicks (
|
|
825
|
-
id TEXT PRIMARY KEY,
|
|
826
|
-
link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
|
|
827
|
-
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
828
|
-
slug TEXT NOT NULL,
|
|
829
|
-
clicked_at TIMESTAMPTZ NOT NULL,
|
|
830
|
-
ip_hash TEXT,
|
|
831
|
-
user_agent TEXT,
|
|
832
|
-
referer TEXT,
|
|
833
|
-
country TEXT,
|
|
834
|
-
city TEXT,
|
|
835
|
-
metadata TEXT NOT NULL DEFAULT '{}',
|
|
836
|
-
machine_id TEXT,
|
|
837
|
-
synced_at TIMESTAMPTZ,
|
|
838
|
-
created_at TIMESTAMPTZ NOT NULL,
|
|
839
|
-
updated_at TIMESTAMPTZ NOT NULL
|
|
840
|
-
);
|
|
841
|
-
|
|
842
|
-
CREATE INDEX IF NOT EXISTS idx_domains_hostname ON domains(hostname);
|
|
843
|
-
CREATE INDEX IF NOT EXISTS idx_domains_default ON domains(default_domain);
|
|
844
|
-
CREATE INDEX IF NOT EXISTS idx_links_domain_slug ON links(domain_id, slug);
|
|
845
|
-
CREATE INDEX IF NOT EXISTS idx_links_active ON links(active);
|
|
846
|
-
CREATE INDEX IF NOT EXISTS idx_links_updated ON links(updated_at);
|
|
847
|
-
CREATE INDEX IF NOT EXISTS idx_clicks_link ON clicks(link_id);
|
|
848
|
-
CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
|
|
849
|
-
CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
|
|
850
|
-
CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
|
|
851
|
-
`
|
|
852
|
-
];
|
|
853
|
-
export {
|
|
854
|
-
writeWorkerFiles,
|
|
855
|
-
upsertCloudflareDnsRecord,
|
|
856
|
-
serveShortlinks,
|
|
857
|
-
saveConfig,
|
|
858
|
-
registerMachinesDns,
|
|
859
|
-
randomToken,
|
|
860
|
-
now,
|
|
861
|
-
normalizeSlug,
|
|
862
|
-
normalizeHostname,
|
|
863
|
-
makeId,
|
|
864
|
-
loadConfig,
|
|
865
|
-
getDatabasePath,
|
|
866
|
-
getDataDir,
|
|
867
|
-
getConfigPath,
|
|
868
|
-
generateWorkerScript,
|
|
869
|
-
formatShortUrl,
|
|
870
|
-
createShortlinksHandler,
|
|
871
|
-
createLocalSetupPlan,
|
|
872
|
-
createCloudflarePlan,
|
|
873
|
-
ShortlinksStore,
|
|
874
|
-
ShortlinksDatabase,
|
|
875
|
-
SQLITE_MIGRATIONS,
|
|
876
|
-
PG_MIGRATIONS
|
|
877
|
-
};
|