@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/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
|
|
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 and `@hasna/domains`. It defaults to local SQLite and can serve from an app-owned PostgreSQL database when `HASNA_SHORTLINKS_STORE=postgres` and `HASNA_SHORTLINKS_DATABASE_URL` are configured.
|
|
6
6
|
|
|
7
7
|
[](https://www.npmjs.com/package/@hasna/shortlinks)
|
|
8
8
|
[](LICENSE)
|
|
@@ -64,7 +64,6 @@ 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
|
|
68
67
|
shortlinks doctor
|
|
69
68
|
```
|
|
70
69
|
|
|
@@ -125,36 +124,32 @@ shortlinks domain buy new-short-domain.ai --dry-run
|
|
|
125
124
|
|
|
126
125
|
This package does not install or call any removed `connect-*` packages.
|
|
127
126
|
|
|
128
|
-
##
|
|
127
|
+
## PostgreSQL Runtime
|
|
129
128
|
|
|
130
|
-
|
|
129
|
+
Production serving can use a shortlinks-owned PostgreSQL database without any shared table-sync package:
|
|
131
130
|
|
|
132
131
|
```bash
|
|
133
|
-
|
|
134
|
-
shortlinks
|
|
135
|
-
|
|
136
|
-
shortlinks cloud pull
|
|
137
|
-
shortlinks cloud sync
|
|
138
|
-
```
|
|
139
|
-
|
|
140
|
-
The cloud database service name is `shortlinks`.
|
|
141
|
-
Use direct RDS mode for production and live management:
|
|
132
|
+
export HASNA_SHORTLINKS_STORE=postgres
|
|
133
|
+
export HASNA_SHORTLINKS_DATABASE_URL=postgres://shortlinks:password@db.example.com:5432/shortlinks
|
|
134
|
+
export HASNA_SHORTLINKS_DATABASE_SSL=true
|
|
142
135
|
|
|
143
|
-
|
|
144
|
-
shortlinks
|
|
145
|
-
shortlinks
|
|
146
|
-
shortlinks serve --
|
|
136
|
+
shortlinks postgres status
|
|
137
|
+
shortlinks postgres plan --schema-sql
|
|
138
|
+
shortlinks postgres migrate
|
|
139
|
+
shortlinks --store postgres serve --host 127.0.0.1 --port 8787 --default-host has.na
|
|
147
140
|
```
|
|
148
141
|
|
|
142
|
+
The canonical production runtime secret path is `hasna/xyz/opensource/shortlinks/prod/postgres`. Use the URL environment variables above rather than writing shared runtime config files into the shortlinks data directory.
|
|
143
|
+
|
|
149
144
|
## AWS Origin
|
|
150
145
|
|
|
151
146
|
For an apex domain that needs stable A records, `infra/aws-ec2-user-data.sh` bootstraps a small EC2 redirect origin with:
|
|
152
147
|
|
|
153
148
|
- `@hasna/shortlinks` installed through Bun
|
|
154
|
-
- direct reads and click writes against the `shortlinks`
|
|
149
|
+
- direct reads and click writes against the app-owned `shortlinks` PostgreSQL database
|
|
155
150
|
- Caddy terminating HTTPS and proxying to `shortlinks serve`
|
|
156
151
|
|
|
157
|
-
The script reads the
|
|
152
|
+
The script reads the connection settings from AWS Secrets Manager through the instance role; it does not contain secret values.
|
|
158
153
|
|
|
159
154
|
## Development
|
|
160
155
|
|
package/dist/cli/index.js
CHANGED
|
@@ -3271,7 +3271,8 @@ import { mkdirSync as mkdirSync2 } from "fs";
|
|
|
3271
3271
|
import { dirname as dirname2 } from "path";
|
|
3272
3272
|
|
|
3273
3273
|
// src/config.ts
|
|
3274
|
-
import { existsSync as existsSync2, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
3274
|
+
import { existsSync as existsSync2, linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
3275
|
+
import { randomBytes } from "crypto";
|
|
3275
3276
|
import { homedir as homedir2 } from "os";
|
|
3276
3277
|
import { dirname, join as join2, resolve } from "path";
|
|
3277
3278
|
var SERVICE_NAME = "shortlinks";
|
|
@@ -3287,6 +3288,9 @@ function ensureDataDir() {
|
|
|
3287
3288
|
function getConfigPath() {
|
|
3288
3289
|
return join2(ensureDataDir(), "config.json");
|
|
3289
3290
|
}
|
|
3291
|
+
function getClickSaltPath() {
|
|
3292
|
+
return join2(ensureDataDir(), "click-salt");
|
|
3293
|
+
}
|
|
3290
3294
|
function getDatabasePath(explicitPath) {
|
|
3291
3295
|
if (explicitPath)
|
|
3292
3296
|
return resolve(explicitPath);
|
|
@@ -3294,6 +3298,51 @@ function getDatabasePath(explicitPath) {
|
|
|
3294
3298
|
return resolve(process.env.SHORTLINKS_DB);
|
|
3295
3299
|
return join2(ensureDataDir(), `${SERVICE_NAME}.db`);
|
|
3296
3300
|
}
|
|
3301
|
+
function readClickSaltFile(path) {
|
|
3302
|
+
try {
|
|
3303
|
+
const saved = readFileSync(path, "utf-8").trim();
|
|
3304
|
+
return saved || null;
|
|
3305
|
+
} catch {
|
|
3306
|
+
return null;
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
function clickSaltError(path, error) {
|
|
3310
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
3311
|
+
return new Error(`Could not initialize click salt at ${path}. Set SHORTLINKS_CLICK_SALT or fix data directory permissions. ${detail}`);
|
|
3312
|
+
}
|
|
3313
|
+
function getClickSalt() {
|
|
3314
|
+
const explicit = process.env.SHORTLINKS_CLICK_SALT?.trim();
|
|
3315
|
+
if (explicit)
|
|
3316
|
+
return explicit;
|
|
3317
|
+
const path = getClickSaltPath();
|
|
3318
|
+
const saved = readClickSaltFile(path);
|
|
3319
|
+
if (saved)
|
|
3320
|
+
return saved;
|
|
3321
|
+
const generated = randomBytes(32).toString("hex");
|
|
3322
|
+
const tempPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
3323
|
+
try {
|
|
3324
|
+
writeFileSync(tempPath, `${generated}
|
|
3325
|
+
`, { flag: "wx", mode: 384 });
|
|
3326
|
+
try {
|
|
3327
|
+
linkSync(tempPath, path);
|
|
3328
|
+
return generated;
|
|
3329
|
+
} catch (error) {
|
|
3330
|
+
const winner = readClickSaltFile(path);
|
|
3331
|
+
if (winner)
|
|
3332
|
+
return winner;
|
|
3333
|
+
throw clickSaltError(path, error);
|
|
3334
|
+
} finally {
|
|
3335
|
+
try {
|
|
3336
|
+
unlinkSync(tempPath);
|
|
3337
|
+
} catch {}
|
|
3338
|
+
}
|
|
3339
|
+
} catch (error) {
|
|
3340
|
+
const winner = readClickSaltFile(path);
|
|
3341
|
+
if (winner)
|
|
3342
|
+
return winner;
|
|
3343
|
+
throw clickSaltError(path, error);
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3297
3346
|
function loadConfig() {
|
|
3298
3347
|
const path = getConfigPath();
|
|
3299
3348
|
if (!existsSync2(path))
|
|
@@ -3465,13 +3514,13 @@ import { hostname } from "os";
|
|
|
3465
3514
|
import { join as join3 } from "path";
|
|
3466
3515
|
|
|
3467
3516
|
// src/slug.ts
|
|
3468
|
-
import { randomBytes } from "crypto";
|
|
3517
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
3469
3518
|
var SLUG_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
3470
3519
|
var DEFAULT_SLUG_LENGTH = 7;
|
|
3471
3520
|
function randomToken(length = DEFAULT_SLUG_LENGTH) {
|
|
3472
3521
|
if (length < 1 || length > 128)
|
|
3473
3522
|
throw new Error("Token length must be between 1 and 128.");
|
|
3474
|
-
const bytes =
|
|
3523
|
+
const bytes = randomBytes2(length);
|
|
3475
3524
|
let out = "";
|
|
3476
3525
|
for (let i = 0;i < length; i += 1) {
|
|
3477
3526
|
out += SLUG_ALPHABET[bytes[i] % SLUG_ALPHABET.length];
|
|
@@ -3791,13 +3840,178 @@ class ShortlinksStore {
|
|
|
3791
3840
|
throw new Error("Could not generate an unused slug after 32 attempts.");
|
|
3792
3841
|
}
|
|
3793
3842
|
hashIp(ip) {
|
|
3794
|
-
|
|
3795
|
-
return createHash("sha256").update(`${salt}:${ip}`).digest("hex");
|
|
3843
|
+
return createHash("sha256").update(`${getClickSalt()}:${ip}`).digest("hex");
|
|
3796
3844
|
}
|
|
3797
3845
|
}
|
|
3798
3846
|
|
|
3799
3847
|
// src/pg-store.ts
|
|
3800
3848
|
import { createHash as createHash2 } from "crypto";
|
|
3849
|
+
|
|
3850
|
+
// src/runtime.ts
|
|
3851
|
+
var SHORTLINKS_RUNTIME_ENV = {
|
|
3852
|
+
store: "HASNA_SHORTLINKS_STORE",
|
|
3853
|
+
databaseUrl: "HASNA_SHORTLINKS_DATABASE_URL",
|
|
3854
|
+
databaseSsl: "HASNA_SHORTLINKS_DATABASE_SSL"
|
|
3855
|
+
};
|
|
3856
|
+
var SHORTLINKS_RUNTIME_FALLBACK_ENV = {
|
|
3857
|
+
store: "SHORTLINKS_STORE",
|
|
3858
|
+
databaseUrl: "SHORTLINKS_DATABASE_URL",
|
|
3859
|
+
databaseSsl: "SHORTLINKS_DATABASE_SSL"
|
|
3860
|
+
};
|
|
3861
|
+
var CANONICAL_SHORTLINKS_POSTGRES_CLUSTER = "hasna-xyz-infra-apps-prod-postgres";
|
|
3862
|
+
var CANONICAL_SHORTLINKS_POSTGRES_DATABASE = "shortlinks";
|
|
3863
|
+
var CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH = "hasna/xyz/opensource/shortlinks/prod/postgres";
|
|
3864
|
+
function getCanonicalShortlinksPostgresConfig() {
|
|
3865
|
+
return {
|
|
3866
|
+
cluster: CANONICAL_SHORTLINKS_POSTGRES_CLUSTER,
|
|
3867
|
+
database: CANONICAL_SHORTLINKS_POSTGRES_DATABASE,
|
|
3868
|
+
runtimeSecretPath: CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH,
|
|
3869
|
+
primaryEnv: SHORTLINKS_RUNTIME_ENV.databaseUrl,
|
|
3870
|
+
fallbackEnv: SHORTLINKS_RUNTIME_FALLBACK_ENV.databaseUrl
|
|
3871
|
+
};
|
|
3872
|
+
}
|
|
3873
|
+
function parseShortlinksStoreMode(value) {
|
|
3874
|
+
const normalized = clean(value)?.toLowerCase();
|
|
3875
|
+
if (!normalized)
|
|
3876
|
+
return "local";
|
|
3877
|
+
if (normalized === "local" || normalized === "postgres")
|
|
3878
|
+
return normalized;
|
|
3879
|
+
if (normalized === "pg")
|
|
3880
|
+
return "postgres";
|
|
3881
|
+
throw new Error(`${SHORTLINKS_RUNTIME_ENV.store} must be local or postgres`);
|
|
3882
|
+
}
|
|
3883
|
+
function getShortlinksStoreMode(env2 = process.env) {
|
|
3884
|
+
return parseShortlinksStoreMode(readRuntimeEnv(env2, "store").value);
|
|
3885
|
+
}
|
|
3886
|
+
function getShortlinksDatabaseUrl(env2 = process.env) {
|
|
3887
|
+
return readRuntimeEnv(env2, "databaseUrl").value;
|
|
3888
|
+
}
|
|
3889
|
+
function getShortlinksDatabaseSsl(env2 = process.env) {
|
|
3890
|
+
return parseBoolean(readRuntimeEnv(env2, "databaseSsl").value, true);
|
|
3891
|
+
}
|
|
3892
|
+
function getShortlinksRuntimeEnvName(env2, key) {
|
|
3893
|
+
const primary = SHORTLINKS_RUNTIME_ENV[key];
|
|
3894
|
+
if (clean(env2[primary]))
|
|
3895
|
+
return primary;
|
|
3896
|
+
return SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
|
|
3897
|
+
}
|
|
3898
|
+
function loadShortlinksRuntimeConfig(env2 = process.env) {
|
|
3899
|
+
const mode = getShortlinksStoreMode(env2);
|
|
3900
|
+
const databaseUrl = getShortlinksDatabaseUrl(env2);
|
|
3901
|
+
return {
|
|
3902
|
+
service: "shortlinks",
|
|
3903
|
+
mode,
|
|
3904
|
+
...databaseUrl ? {
|
|
3905
|
+
database: {
|
|
3906
|
+
provider: "postgres",
|
|
3907
|
+
url: databaseUrl,
|
|
3908
|
+
ssl: getShortlinksDatabaseSsl(env2)
|
|
3909
|
+
}
|
|
3910
|
+
} : {}
|
|
3911
|
+
};
|
|
3912
|
+
}
|
|
3913
|
+
function assertShortlinksPostgresConfig(config) {
|
|
3914
|
+
if (config.mode !== "postgres")
|
|
3915
|
+
return;
|
|
3916
|
+
if (!config.database?.url) {
|
|
3917
|
+
throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseUrl} is required when ${SHORTLINKS_RUNTIME_ENV.store}=postgres`);
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
function getShortlinksRuntimeStatus(env2 = process.env) {
|
|
3921
|
+
const issues = [];
|
|
3922
|
+
const warnings = [];
|
|
3923
|
+
let config;
|
|
3924
|
+
try {
|
|
3925
|
+
config = loadShortlinksRuntimeConfig(env2);
|
|
3926
|
+
} catch (error) {
|
|
3927
|
+
issues.push(error instanceof Error ? error.message : String(error));
|
|
3928
|
+
config = { service: "shortlinks", mode: "local" };
|
|
3929
|
+
}
|
|
3930
|
+
try {
|
|
3931
|
+
assertShortlinksPostgresConfig(config);
|
|
3932
|
+
} catch (error) {
|
|
3933
|
+
issues.push(error instanceof Error ? error.message : String(error));
|
|
3934
|
+
}
|
|
3935
|
+
if (config.mode === "local" && config.database?.url) {
|
|
3936
|
+
warnings.push(`${SHORTLINKS_RUNTIME_ENV.store}=local ignores configured Postgres database settings`);
|
|
3937
|
+
}
|
|
3938
|
+
return {
|
|
3939
|
+
ok: issues.length === 0,
|
|
3940
|
+
service: "shortlinks",
|
|
3941
|
+
mode: config.mode,
|
|
3942
|
+
local_default: config.mode === "local",
|
|
3943
|
+
postgres_enabled: config.mode === "postgres",
|
|
3944
|
+
database: {
|
|
3945
|
+
configured: Boolean(config.database?.url),
|
|
3946
|
+
provider: config.database?.provider ?? null,
|
|
3947
|
+
redacted_url: redactDatabaseUrl(config.database?.url),
|
|
3948
|
+
ssl: config.database?.ssl ?? null
|
|
3949
|
+
},
|
|
3950
|
+
env: runtimeEnvStatus(env2),
|
|
3951
|
+
canonical: getCanonicalShortlinksPostgresConfig(),
|
|
3952
|
+
issues,
|
|
3953
|
+
warnings,
|
|
3954
|
+
no_network: true
|
|
3955
|
+
};
|
|
3956
|
+
}
|
|
3957
|
+
function redactDatabaseUrl(value) {
|
|
3958
|
+
if (!value)
|
|
3959
|
+
return null;
|
|
3960
|
+
try {
|
|
3961
|
+
const url = new URL(value);
|
|
3962
|
+
if (url.username)
|
|
3963
|
+
url.username = "***";
|
|
3964
|
+
if (url.password)
|
|
3965
|
+
url.password = "***";
|
|
3966
|
+
for (const key of Array.from(url.searchParams.keys())) {
|
|
3967
|
+
if (isSensitiveQueryKey(key))
|
|
3968
|
+
url.searchParams.set(key, "***");
|
|
3969
|
+
}
|
|
3970
|
+
return url.toString();
|
|
3971
|
+
} catch {
|
|
3972
|
+
return "(redacted)";
|
|
3973
|
+
}
|
|
3974
|
+
}
|
|
3975
|
+
function runtimeEnvStatus(env2) {
|
|
3976
|
+
return Object.fromEntries(Object.entries(SHORTLINKS_RUNTIME_ENV).map(([key, name]) => {
|
|
3977
|
+
const activeName = getShortlinksRuntimeEnvName(env2, key);
|
|
3978
|
+
return [
|
|
3979
|
+
key,
|
|
3980
|
+
{
|
|
3981
|
+
name,
|
|
3982
|
+
active_name: activeName,
|
|
3983
|
+
configured: Boolean(clean(env2[activeName]))
|
|
3984
|
+
}
|
|
3985
|
+
];
|
|
3986
|
+
}));
|
|
3987
|
+
}
|
|
3988
|
+
function readRuntimeEnv(env2, key) {
|
|
3989
|
+
const primary = SHORTLINKS_RUNTIME_ENV[key];
|
|
3990
|
+
const primaryValue = clean(env2[primary]);
|
|
3991
|
+
if (primaryValue)
|
|
3992
|
+
return { name: primary, value: primaryValue };
|
|
3993
|
+
const fallback = SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
|
|
3994
|
+
return { name: fallback, value: clean(env2[fallback]) };
|
|
3995
|
+
}
|
|
3996
|
+
function parseBoolean(value, fallback) {
|
|
3997
|
+
const normalized = clean(value)?.toLowerCase();
|
|
3998
|
+
if (!normalized)
|
|
3999
|
+
return fallback;
|
|
4000
|
+
if (["1", "true", "yes", "on"].includes(normalized))
|
|
4001
|
+
return true;
|
|
4002
|
+
if (["0", "false", "no", "off"].includes(normalized))
|
|
4003
|
+
return false;
|
|
4004
|
+
throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseSsl} must be true or false`);
|
|
4005
|
+
}
|
|
4006
|
+
function isSensitiveQueryKey(key) {
|
|
4007
|
+
return /(?:^|[_-])(pass(?:word)?|pwd|secret|token|credential|auth|api[_-]?key|access[_-]?key)(?:$|[_-])/i.test(key);
|
|
4008
|
+
}
|
|
4009
|
+
function clean(value) {
|
|
4010
|
+
const trimmed = value?.trim();
|
|
4011
|
+
return trimmed ? trimmed : undefined;
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
// src/pg-store.ts
|
|
3801
4015
|
function parseJsonObject3(value) {
|
|
3802
4016
|
if (!value)
|
|
3803
4017
|
return {};
|
|
@@ -3810,6 +4024,47 @@ function parseJsonObject3(value) {
|
|
|
3810
4024
|
return {};
|
|
3811
4025
|
}
|
|
3812
4026
|
}
|
|
4027
|
+
async function loadPgPool() {
|
|
4028
|
+
const importer = new Function("specifier", "return import(specifier)");
|
|
4029
|
+
const module = await importer("pg");
|
|
4030
|
+
return module.Pool;
|
|
4031
|
+
}
|
|
4032
|
+
function toPostgresSql(sql) {
|
|
4033
|
+
let index = 0;
|
|
4034
|
+
return sql.replace(/\?/g, () => `$${++index}`);
|
|
4035
|
+
}
|
|
4036
|
+
function createPgPoolConfig(connectionString, options = {}) {
|
|
4037
|
+
const ssl = options.ssl ?? true;
|
|
4038
|
+
return {
|
|
4039
|
+
connectionString,
|
|
4040
|
+
...ssl ? { ssl: { rejectUnauthorized: true } } : { ssl: false }
|
|
4041
|
+
};
|
|
4042
|
+
}
|
|
4043
|
+
|
|
4044
|
+
class PgPoolAdapter {
|
|
4045
|
+
pool;
|
|
4046
|
+
constructor(pool) {
|
|
4047
|
+
this.pool = pool;
|
|
4048
|
+
}
|
|
4049
|
+
static async create(connectionString, options = {}) {
|
|
4050
|
+
const Pool = await loadPgPool();
|
|
4051
|
+
return new PgPoolAdapter(new Pool(createPgPoolConfig(connectionString, options)));
|
|
4052
|
+
}
|
|
4053
|
+
async get(sql, ...params) {
|
|
4054
|
+
const result = await this.pool.query(toPostgresSql(sql), params);
|
|
4055
|
+
return result.rows[0] ?? null;
|
|
4056
|
+
}
|
|
4057
|
+
async all(sql, ...params) {
|
|
4058
|
+
const result = await this.pool.query(toPostgresSql(sql), params);
|
|
4059
|
+
return result.rows;
|
|
4060
|
+
}
|
|
4061
|
+
async run(sql, ...params) {
|
|
4062
|
+
return this.pool.query(toPostgresSql(sql), params);
|
|
4063
|
+
}
|
|
4064
|
+
async close() {
|
|
4065
|
+
await this.pool.end();
|
|
4066
|
+
}
|
|
4067
|
+
}
|
|
3813
4068
|
function toIsoString(value) {
|
|
3814
4069
|
if (value instanceof Date)
|
|
3815
4070
|
return value.toISOString();
|
|
@@ -3878,13 +4133,15 @@ class PgShortlinksStore {
|
|
|
3878
4133
|
constructor(pg) {
|
|
3879
4134
|
this.pg = pg;
|
|
3880
4135
|
}
|
|
3881
|
-
static async fromConnectionString(connectionString) {
|
|
3882
|
-
|
|
3883
|
-
return new PgShortlinksStore(new PgAdapterAsync(connectionString));
|
|
4136
|
+
static async fromConnectionString(connectionString, options = {}) {
|
|
4137
|
+
return new PgShortlinksStore(await PgPoolAdapter.create(connectionString, options));
|
|
3884
4138
|
}
|
|
3885
|
-
static async
|
|
3886
|
-
const
|
|
3887
|
-
|
|
4139
|
+
static async fromEnv(env2 = process.env) {
|
|
4140
|
+
const connectionString = getShortlinksDatabaseUrl(env2);
|
|
4141
|
+
if (!connectionString) {
|
|
4142
|
+
throw new Error("HASNA_SHORTLINKS_DATABASE_URL is required when shortlinks uses the postgres store");
|
|
4143
|
+
}
|
|
4144
|
+
return PgShortlinksStore.fromConnectionString(connectionString, { ssl: getShortlinksDatabaseSsl(env2) });
|
|
3888
4145
|
}
|
|
3889
4146
|
async close() {
|
|
3890
4147
|
await this.pg.close?.();
|
|
@@ -4100,8 +4357,7 @@ class PgShortlinksStore {
|
|
|
4100
4357
|
};
|
|
4101
4358
|
}
|
|
4102
4359
|
hashIp(ip) {
|
|
4103
|
-
|
|
4104
|
-
return createHash2("sha256").update(`${salt}:${ip}`).digest("hex");
|
|
4360
|
+
return createHash2("sha256").update(`${getClickSalt()}:${ip}`).digest("hex");
|
|
4105
4361
|
}
|
|
4106
4362
|
async generateAvailableSlug(domainId, length) {
|
|
4107
4363
|
for (let attempt = 0;attempt < 32; attempt += 1) {
|
|
@@ -4115,6 +4371,52 @@ class PgShortlinksStore {
|
|
|
4115
4371
|
throw new Error("Could not generate an unused slug after 32 attempts.");
|
|
4116
4372
|
}
|
|
4117
4373
|
}
|
|
4374
|
+
async function applyPostgresMigrations(connectionString, migrations, options = {}) {
|
|
4375
|
+
const Pool = await loadPgPool();
|
|
4376
|
+
const pool = new Pool(createPgPoolConfig(connectionString, options));
|
|
4377
|
+
const client = await pool.connect();
|
|
4378
|
+
const run = (sql, ...params) => client.query(toPostgresSql(sql), params);
|
|
4379
|
+
const get = async (sql, ...params) => {
|
|
4380
|
+
const result = await run(sql, ...params);
|
|
4381
|
+
return result.rows[0] ?? null;
|
|
4382
|
+
};
|
|
4383
|
+
const applied = [];
|
|
4384
|
+
const skipped = [];
|
|
4385
|
+
try {
|
|
4386
|
+
await run("BEGIN");
|
|
4387
|
+
await run(`
|
|
4388
|
+
SELECT pg_advisory_xact_lock(hashtext(?))
|
|
4389
|
+
`, "shortlinks:migrations");
|
|
4390
|
+
await run(`
|
|
4391
|
+
CREATE TABLE IF NOT EXISTS _shortlinks_migrations (
|
|
4392
|
+
id INTEGER PRIMARY KEY,
|
|
4393
|
+
service TEXT NOT NULL DEFAULT 'shortlinks',
|
|
4394
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
4395
|
+
)
|
|
4396
|
+
`);
|
|
4397
|
+
for (let i = 0;i < migrations.length; i += 1) {
|
|
4398
|
+
const id = i + 1;
|
|
4399
|
+
const existing = await get("SELECT id FROM _shortlinks_migrations WHERE id = ? LIMIT 1", id);
|
|
4400
|
+
if (existing) {
|
|
4401
|
+
skipped.push(id);
|
|
4402
|
+
continue;
|
|
4403
|
+
}
|
|
4404
|
+
await run(migrations[i]);
|
|
4405
|
+
await run("INSERT INTO _shortlinks_migrations (id, service, applied_at) VALUES (?, ?, now())", id, "shortlinks");
|
|
4406
|
+
applied.push(id);
|
|
4407
|
+
}
|
|
4408
|
+
await run("COMMIT");
|
|
4409
|
+
return { service: "shortlinks", applied, skipped };
|
|
4410
|
+
} catch (error) {
|
|
4411
|
+
try {
|
|
4412
|
+
await run("ROLLBACK");
|
|
4413
|
+
} catch {}
|
|
4414
|
+
throw error;
|
|
4415
|
+
} finally {
|
|
4416
|
+
client.release();
|
|
4417
|
+
await pool.end();
|
|
4418
|
+
}
|
|
4419
|
+
}
|
|
4118
4420
|
|
|
4119
4421
|
// src/server.ts
|
|
4120
4422
|
var REDIRECT_ALLOW_HEADER = "GET, HEAD";
|
|
@@ -4531,14 +4833,20 @@ function withStore(fn) {
|
|
|
4531
4833
|
}
|
|
4532
4834
|
function storeMode() {
|
|
4533
4835
|
const opts = program2.opts();
|
|
4534
|
-
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4836
|
+
return parseShortlinksStoreMode(opts.store || process.env.HASNA_SHORTLINKS_STORE || process.env.SHORTLINKS_STORE || "local");
|
|
4837
|
+
}
|
|
4838
|
+
function runtimeEnv() {
|
|
4839
|
+
const opts = program2.opts();
|
|
4840
|
+
return opts.store ? { ...process.env, HASNA_SHORTLINKS_STORE: opts.store } : process.env;
|
|
4841
|
+
}
|
|
4842
|
+
function localStatsIfDatabaseExists(dbPath) {
|
|
4843
|
+
if (!existsSync4(dbPath))
|
|
4844
|
+
return null;
|
|
4845
|
+
return withStore((store) => store.totalStats());
|
|
4538
4846
|
}
|
|
4539
4847
|
async function withRuntimeStore(fn) {
|
|
4540
|
-
if (storeMode() === "
|
|
4541
|
-
const store2 = await PgShortlinksStore.
|
|
4848
|
+
if (storeMode() === "postgres") {
|
|
4849
|
+
const store2 = await PgShortlinksStore.fromEnv();
|
|
4542
4850
|
try {
|
|
4543
4851
|
return await fn(store2);
|
|
4544
4852
|
} finally {
|
|
@@ -4559,7 +4867,7 @@ function commandExists(command) {
|
|
|
4559
4867
|
const result = spawnSync3("which", [command], { encoding: "utf-8" });
|
|
4560
4868
|
return result.status === 0;
|
|
4561
4869
|
}
|
|
4562
|
-
program2.name("shortlinks").description("CLI-only shortlink manager with custom domains, click tracking, Cloudflare helpers, and
|
|
4870
|
+
program2.name("shortlinks").description("CLI-only shortlink manager with custom domains, click tracking, Cloudflare helpers, and app-owned Postgres runtime support").version(getPackageVersion()).option("--db <path>", "SQLite database path").option("--store <mode>", "Data store mode: local or postgres", process.env.HASNA_SHORTLINKS_STORE || process.env.SHORTLINKS_STORE || "local").option("-j, --json", "Output JSON for agents and scripts");
|
|
4563
4871
|
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) => {
|
|
4564
4872
|
try {
|
|
4565
4873
|
const result = await withRuntimeStore(async (store) => {
|
|
@@ -4819,9 +5127,9 @@ program2.command("stats [slug]").description("Show overall stats or stats for a
|
|
|
4819
5127
|
handleError(error);
|
|
4820
5128
|
}
|
|
4821
5129
|
});
|
|
4822
|
-
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").
|
|
5130
|
+
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(async (opts) => {
|
|
4823
5131
|
try {
|
|
4824
|
-
const store =
|
|
5132
|
+
const store = storeMode() === "postgres" ? await PgShortlinksStore.fromEnv() : undefined;
|
|
4825
5133
|
const server = serveShortlinks({
|
|
4826
5134
|
store,
|
|
4827
5135
|
dbPath: program2.opts().db,
|
|
@@ -4829,7 +5137,7 @@ program2.command("serve").description("Run the redirect server that records clic
|
|
|
4829
5137
|
port: Number(opts.port),
|
|
4830
5138
|
defaultHost: opts.defaultHost
|
|
4831
5139
|
});
|
|
4832
|
-
const mode = store ? "
|
|
5140
|
+
const mode = store ? "postgres" : "local";
|
|
4833
5141
|
console.log(source_default.green(`shortlinks redirect server listening on http://${server.hostname}:${server.port} (${mode})`));
|
|
4834
5142
|
} catch (error) {
|
|
4835
5143
|
handleError(error);
|
|
@@ -4875,65 +5183,68 @@ cfCmd.command("dns <hostname>").description("Create or update the Cloudflare CNA
|
|
|
4875
5183
|
handleError(error);
|
|
4876
5184
|
}
|
|
4877
5185
|
});
|
|
4878
|
-
var
|
|
4879
|
-
|
|
5186
|
+
var postgresCmd = program2.command("postgres").description("Shortlinks-owned PostgreSQL runtime helpers");
|
|
5187
|
+
postgresCmd.command("migrate").description("Apply shortlinks PostgreSQL migrations").option("--connection-string <url>", "PostgreSQL connection string").option("--no-ssl", "Disable PostgreSQL TLS only for local development").option("--dry-run", "Show migration settings without opening a network connection").option("-j, --json", "Output JSON").action(async (opts) => {
|
|
4880
5188
|
try {
|
|
4881
|
-
const
|
|
4882
|
-
|
|
4883
|
-
|
|
5189
|
+
const conn = opts.connectionString || getShortlinksDatabaseUrl();
|
|
5190
|
+
if (!conn)
|
|
5191
|
+
throw new Error("HASNA_SHORTLINKS_DATABASE_URL or --connection-string is required.");
|
|
5192
|
+
const ssl = opts.ssl === false ? false : getShortlinksDatabaseSsl();
|
|
5193
|
+
if (opts.dryRun) {
|
|
5194
|
+
const result2 = {
|
|
5195
|
+
service: "shortlinks",
|
|
5196
|
+
dry_run: true,
|
|
5197
|
+
no_network: true,
|
|
5198
|
+
database: {
|
|
5199
|
+
configured: true,
|
|
5200
|
+
redacted_url: redactDatabaseUrl(conn),
|
|
5201
|
+
ssl
|
|
5202
|
+
},
|
|
5203
|
+
migrations: PG_MIGRATIONS.length
|
|
5204
|
+
};
|
|
5205
|
+
print2(result2, opts, () => console.log(JSON.stringify(result2, null, 2)));
|
|
5206
|
+
return;
|
|
5207
|
+
}
|
|
5208
|
+
const result = await applyPostgresMigrations(conn, PG_MIGRATIONS, { ssl });
|
|
4884
5209
|
print2(result, opts, () => console.log(JSON.stringify(result, null, 2)));
|
|
4885
5210
|
} catch (error) {
|
|
4886
5211
|
handleError(error);
|
|
4887
5212
|
}
|
|
4888
5213
|
});
|
|
4889
|
-
|
|
4890
|
-
const {
|
|
4891
|
-
getCloudConfig,
|
|
4892
|
-
getConnectionString,
|
|
4893
|
-
SqliteAdapter,
|
|
4894
|
-
PgAdapterAsync,
|
|
4895
|
-
listSqliteTables,
|
|
4896
|
-
listPgTables,
|
|
4897
|
-
syncPush,
|
|
4898
|
-
syncPull
|
|
4899
|
-
} = await import("@hasna/cloud");
|
|
4900
|
-
const config = getCloudConfig();
|
|
4901
|
-
if (config.mode === "local")
|
|
4902
|
-
throw new Error("Cloud mode is local. Run `cloud setup` first.");
|
|
4903
|
-
const local = new SqliteAdapter(getDatabasePath(program2.opts().db));
|
|
4904
|
-
const remote = new PgAdapterAsync(getConnectionString("shortlinks"));
|
|
5214
|
+
postgresCmd.command("status").description("Show local and PostgreSQL runtime configuration health without opening network connections").option("-j, --json", "Output JSON").action((opts) => {
|
|
4905
5215
|
try {
|
|
4906
|
-
const
|
|
4907
|
-
const
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
}
|
|
4917
|
-
print2({ service: "shortlinks", results }, opts, () => console.log(JSON.stringify({ service: "shortlinks", results }, null, 2)));
|
|
4918
|
-
} finally {
|
|
4919
|
-
local.close?.();
|
|
4920
|
-
await remote.close?.();
|
|
5216
|
+
const dbPath = getDatabasePath(program2.opts().db);
|
|
5217
|
+
const data = {
|
|
5218
|
+
...getShortlinksRuntimeStatus(runtimeEnv()),
|
|
5219
|
+
service: "shortlinks",
|
|
5220
|
+
db_path: dbPath,
|
|
5221
|
+
db_exists: existsSync4(dbPath)
|
|
5222
|
+
};
|
|
5223
|
+
print2(data, opts, () => console.log(JSON.stringify(data, null, 2)));
|
|
5224
|
+
} catch (error) {
|
|
5225
|
+
handleError(error);
|
|
4921
5226
|
}
|
|
4922
|
-
}
|
|
4923
|
-
|
|
4924
|
-
cloudCmd.command(direction).description(`${direction === "sync" ? "Bidirectionally sync" : direction === "push" ? "Push" : "Pull"} shortlinks data ${direction === "pull" ? "from" : "to"} PostgreSQL`).option("--tables <tables>", "Comma-separated table names").option("-j, --json", "Output JSON").action((opts) => syncCloud(direction, opts).catch(handleError));
|
|
4925
|
-
}
|
|
4926
|
-
cloudCmd.command("status").description("Show local and cloud configuration health").option("-j, --json", "Output JSON").action(async (opts) => {
|
|
5227
|
+
});
|
|
5228
|
+
postgresCmd.command("plan").description("Render a dry-run PostgreSQL setup plan").option("--schema-sql", "Include migration SQL").option("-j, --json", "Output JSON").action((opts) => {
|
|
4927
5229
|
try {
|
|
4928
|
-
const
|
|
4929
|
-
const stats = withStore((store) => store.totalStats());
|
|
4930
|
-
const config = getCloudConfig();
|
|
5230
|
+
const status = getShortlinksRuntimeStatus(runtimeEnv());
|
|
4931
5231
|
const data = {
|
|
5232
|
+
ok: status.ok,
|
|
4932
5233
|
service: "shortlinks",
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
5234
|
+
dry_run: true,
|
|
5235
|
+
no_network: true,
|
|
5236
|
+
status,
|
|
5237
|
+
postgres: {
|
|
5238
|
+
required: status.mode === "postgres",
|
|
5239
|
+
configured: status.database.configured,
|
|
5240
|
+
schema_sql: opts.schemaSql ? PG_MIGRATIONS : []
|
|
5241
|
+
},
|
|
5242
|
+
steps: [
|
|
5243
|
+
"Read local SQLite state",
|
|
5244
|
+
status.mode === "postgres" ? "Prepare direct shortlinks Postgres runtime" : "Keep serving from local SQLite",
|
|
5245
|
+
"Run migrations with shortlinks postgres migrate before serving from Postgres",
|
|
5246
|
+
"Report planned changes without opening network connections"
|
|
5247
|
+
]
|
|
4937
5248
|
};
|
|
4938
5249
|
print2(data, opts, () => console.log(JSON.stringify(data, null, 2)));
|
|
4939
5250
|
} catch (error) {
|
|
@@ -4978,23 +5289,26 @@ localCmd.command("setup <domain>").description("Record local domain mapping with
|
|
|
4978
5289
|
});
|
|
4979
5290
|
program2.command("doctor").description("Check local shortlinks tooling and integration readiness").option("-j, --json", "Output JSON").action(async (opts) => {
|
|
4980
5291
|
try {
|
|
4981
|
-
const
|
|
4982
|
-
const
|
|
5292
|
+
const runtime = getShortlinksRuntimeStatus(runtimeEnv());
|
|
5293
|
+
const dbPath = getDatabasePath(program2.opts().db);
|
|
4983
5294
|
const data = {
|
|
4984
5295
|
service: "shortlinks",
|
|
4985
|
-
|
|
5296
|
+
ok: runtime.ok,
|
|
5297
|
+
store: runtime.mode,
|
|
4986
5298
|
data_dir: getDataDir(),
|
|
4987
5299
|
config_path: getConfigPath(),
|
|
4988
|
-
db_path:
|
|
4989
|
-
db_exists: existsSync4(
|
|
4990
|
-
stats,
|
|
5300
|
+
db_path: dbPath,
|
|
5301
|
+
db_exists: existsSync4(dbPath),
|
|
5302
|
+
stats: localStatsIfDatabaseExists(dbPath),
|
|
5303
|
+
runtime,
|
|
5304
|
+
no_network: true,
|
|
4991
5305
|
commands: {
|
|
4992
5306
|
domains: commandExists("domains"),
|
|
4993
|
-
cloud: commandExists("cloud"),
|
|
4994
5307
|
wrangler: commandExists("wrangler"),
|
|
4995
5308
|
secrets: commandExists("secrets")
|
|
4996
5309
|
},
|
|
4997
5310
|
environment: {
|
|
5311
|
+
shortlinks_database_url_present: Boolean(getShortlinksDatabaseUrl(runtimeEnv())),
|
|
4998
5312
|
cloudflare_api_token_present: Boolean(process.env.CLOUDFLARE_API_TOKEN),
|
|
4999
5313
|
cloudflare_api_key_present: Boolean(process.env.CLOUDFLARE_API_KEY),
|
|
5000
5314
|
cloudflare_email_present: Boolean(process.env.CLOUDFLARE_EMAIL),
|