@hasna/shortlinks 0.1.22 → 0.1.24
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 +40 -20
- package/dist/cli/index.js +2507 -2019
- package/dist/cloudflare.js +141 -4
- package/dist/db/migrations.d.ts +20 -0
- package/dist/generated/storage-kit/health.d.ts +19 -0
- package/dist/generated/storage-kit/index.d.ts +7 -0
- package/dist/generated/storage-kit/migrations.d.ts +47 -0
- package/dist/generated/storage-kit/mode.d.ts +47 -0
- package/dist/generated/storage-kit/pool.d.ts +33 -0
- package/dist/generated/storage-kit/query.d.ts +35 -0
- package/dist/generated/storage-kit/tls.d.ts +25 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +3139 -232
- package/dist/mcp/http.d.ts +16 -0
- package/dist/mcp/index.d.ts +14 -0
- package/dist/mcp/index.js +1493 -0
- package/dist/pg-store.d.ts +20 -2
- package/dist/pg-store.js +913 -0
- package/dist/runtime.d.ts +65 -0
- package/dist/runtime.js +214 -0
- package/dist/sdk/generated.d.ts +178 -0
- package/dist/sdk/index.d.ts +12 -0
- package/dist/sdk/index.js +500 -0
- package/dist/serve/app.d.ts +21 -0
- package/dist/serve/index.d.ts +14 -0
- package/dist/serve/index.js +8817 -0
- package/dist/serve/openapi.d.ts +8 -0
- package/dist/server.js +33 -0
- package/infra/aws-ec2-user-data.sh +32 -31
- package/package.json +29 -7
package/dist/cli/index.js
CHANGED
|
@@ -33,7 +33,7 @@ var __toESM = (mod, isNodeMode, target) => {
|
|
|
33
33
|
var __commonJS = (cb, mod) => () => (mod || cb((mod = { exports: {} }).exports, mod), mod.exports);
|
|
34
34
|
var __require = import.meta.require;
|
|
35
35
|
|
|
36
|
-
// node_modules/commander/lib/error.js
|
|
36
|
+
// node_modules/.pnpm/commander@13.1.0/node_modules/commander/lib/error.js
|
|
37
37
|
var require_error = __commonJS((exports) => {
|
|
38
38
|
class CommanderError extends Error {
|
|
39
39
|
constructor(exitCode, code, message) {
|
|
@@ -57,7 +57,7 @@ var require_error = __commonJS((exports) => {
|
|
|
57
57
|
exports.InvalidArgumentError = InvalidArgumentError;
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
// node_modules/commander/lib/argument.js
|
|
60
|
+
// node_modules/.pnpm/commander@13.1.0/node_modules/commander/lib/argument.js
|
|
61
61
|
var require_argument = __commonJS((exports) => {
|
|
62
62
|
var { InvalidArgumentError } = require_error();
|
|
63
63
|
|
|
@@ -136,7 +136,7 @@ var require_argument = __commonJS((exports) => {
|
|
|
136
136
|
exports.humanReadableArgName = humanReadableArgName;
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
-
// node_modules/commander/lib/help.js
|
|
139
|
+
// node_modules/.pnpm/commander@13.1.0/node_modules/commander/lib/help.js
|
|
140
140
|
var require_help = __commonJS((exports) => {
|
|
141
141
|
var { humanReadableArgName } = require_argument();
|
|
142
142
|
|
|
@@ -486,7 +486,7 @@ ${itemIndentStr}`);
|
|
|
486
486
|
exports.stripColor = stripColor;
|
|
487
487
|
});
|
|
488
488
|
|
|
489
|
-
// node_modules/commander/lib/option.js
|
|
489
|
+
// node_modules/.pnpm/commander@13.1.0/node_modules/commander/lib/option.js
|
|
490
490
|
var require_option = __commonJS((exports) => {
|
|
491
491
|
var { InvalidArgumentError } = require_error();
|
|
492
492
|
|
|
@@ -664,7 +664,7 @@ var require_option = __commonJS((exports) => {
|
|
|
664
664
|
exports.DualOptions = DualOptions;
|
|
665
665
|
});
|
|
666
666
|
|
|
667
|
-
// node_modules/commander/lib/suggestSimilar.js
|
|
667
|
+
// node_modules/.pnpm/commander@13.1.0/node_modules/commander/lib/suggestSimilar.js
|
|
668
668
|
var require_suggestSimilar = __commonJS((exports) => {
|
|
669
669
|
var maxDistance = 3;
|
|
670
670
|
function editDistance(a, b) {
|
|
@@ -737,7 +737,7 @@ var require_suggestSimilar = __commonJS((exports) => {
|
|
|
737
737
|
exports.suggestSimilar = suggestSimilar;
|
|
738
738
|
});
|
|
739
739
|
|
|
740
|
-
// node_modules/commander/lib/command.js
|
|
740
|
+
// node_modules/.pnpm/commander@13.1.0/node_modules/commander/lib/command.js
|
|
741
741
|
var require_command = __commonJS((exports) => {
|
|
742
742
|
var EventEmitter = __require("events").EventEmitter;
|
|
743
743
|
var childProcess = __require("child_process");
|
|
@@ -979,7 +979,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
|
|
|
979
979
|
this._exitCallback = (err) => {
|
|
980
980
|
if (err.code !== "commander.executeSubCommandAsync") {
|
|
981
981
|
throw err;
|
|
982
|
-
}
|
|
982
|
+
} else {}
|
|
983
983
|
};
|
|
984
984
|
}
|
|
985
985
|
return this;
|
|
@@ -2047,7 +2047,7 @@ Expecting one of '${allowedValues.join("', '")}'`);
|
|
|
2047
2047
|
exports.useColor = useColor;
|
|
2048
2048
|
});
|
|
2049
2049
|
|
|
2050
|
-
// node_modules/commander/index.js
|
|
2050
|
+
// node_modules/.pnpm/commander@13.1.0/node_modules/commander/index.js
|
|
2051
2051
|
var require_commander = __commonJS((exports) => {
|
|
2052
2052
|
var { Argument } = require_argument();
|
|
2053
2053
|
var { Command } = require_command();
|
|
@@ -2067,1557 +2067,948 @@ var require_commander = __commonJS((exports) => {
|
|
|
2067
2067
|
exports.InvalidOptionArgumentError = InvalidArgumentError;
|
|
2068
2068
|
});
|
|
2069
2069
|
|
|
2070
|
-
//
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2070
|
+
// src/runtime.ts
|
|
2071
|
+
var SHORTLINKS_RUNTIME_ENV = {
|
|
2072
|
+
store: "HASNA_SHORTLINKS_STORE",
|
|
2073
|
+
databaseUrl: "HASNA_SHORTLINKS_DATABASE_URL",
|
|
2074
|
+
databaseSsl: "HASNA_SHORTLINKS_DATABASE_SSL"
|
|
2075
|
+
};
|
|
2076
|
+
var SHORTLINKS_RUNTIME_FALLBACK_ENV = {
|
|
2077
|
+
store: "SHORTLINKS_STORE",
|
|
2078
|
+
databaseUrl: "SHORTLINKS_DATABASE_URL",
|
|
2079
|
+
databaseSsl: "SHORTLINKS_DATABASE_SSL"
|
|
2080
|
+
};
|
|
2081
|
+
var CANONICAL_SHORTLINKS_POSTGRES_CLUSTER = "hasna-xyz-infra-apps-prod-postgres";
|
|
2082
|
+
var CANONICAL_SHORTLINKS_POSTGRES_DATABASE = "shortlinks";
|
|
2083
|
+
var CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH = "hasna/xyz/opensource/shortlinks/prod/postgres";
|
|
2084
|
+
function getCanonicalShortlinksPostgresConfig() {
|
|
2085
|
+
return {
|
|
2086
|
+
cluster: CANONICAL_SHORTLINKS_POSTGRES_CLUSTER,
|
|
2087
|
+
database: CANONICAL_SHORTLINKS_POSTGRES_DATABASE,
|
|
2088
|
+
runtimeSecretPath: CANONICAL_SHORTLINKS_RUNTIME_SECRET_PATH,
|
|
2089
|
+
primaryEnv: SHORTLINKS_RUNTIME_ENV.databaseUrl,
|
|
2090
|
+
fallbackEnv: SHORTLINKS_RUNTIME_FALLBACK_ENV.databaseUrl
|
|
2091
|
+
};
|
|
2086
2092
|
}
|
|
2087
|
-
function
|
|
2088
|
-
const
|
|
2089
|
-
|
|
2093
|
+
function parseShortlinksStoreMode(value) {
|
|
2094
|
+
const normalized = clean(value)?.toLowerCase();
|
|
2095
|
+
if (!normalized)
|
|
2096
|
+
return "local";
|
|
2097
|
+
if (normalized === "local" || normalized === "postgres")
|
|
2098
|
+
return normalized;
|
|
2099
|
+
if (normalized === "pg")
|
|
2100
|
+
return "postgres";
|
|
2101
|
+
throw new Error(`${SHORTLINKS_RUNTIME_ENV.store} must be local or postgres`);
|
|
2090
2102
|
}
|
|
2091
|
-
function
|
|
2092
|
-
|
|
2093
|
-
return true;
|
|
2094
|
-
if (value === undefined)
|
|
2095
|
-
return false;
|
|
2096
|
-
const matchers = Array.isArray(matcher) ? matcher : [matcher];
|
|
2097
|
-
return matchers.some((item) => wildcardToRegExp(item).test(value));
|
|
2103
|
+
function getShortlinksStoreMode(env = process.env) {
|
|
2104
|
+
return parseShortlinksStoreMode(readRuntimeEnv(env, "store").value);
|
|
2098
2105
|
}
|
|
2099
|
-
function
|
|
2100
|
-
|
|
2101
|
-
return true;
|
|
2102
|
-
return Object.entries(matcher).every(([path, expected]) => {
|
|
2103
|
-
const actual = getPathValue(input, path);
|
|
2104
|
-
if (typeof expected === "string" || Array.isArray(expected)) {
|
|
2105
|
-
return matchString(actual === undefined ? undefined : String(actual), expected);
|
|
2106
|
-
}
|
|
2107
|
-
return actual === expected;
|
|
2108
|
-
});
|
|
2106
|
+
function getShortlinksDatabaseUrl(env = process.env) {
|
|
2107
|
+
return readRuntimeEnv(env, "databaseUrl").value;
|
|
2109
2108
|
}
|
|
2110
|
-
function
|
|
2111
|
-
return
|
|
2109
|
+
function getShortlinksDatabaseSsl(env = process.env) {
|
|
2110
|
+
return parseBoolean(readRuntimeEnv(env, "databaseSsl").value, true);
|
|
2112
2111
|
}
|
|
2113
|
-
function
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
return channel.filters.some((filter) => eventMatchesFilter(event, filter));
|
|
2112
|
+
function getShortlinksRuntimeEnvName(env, key) {
|
|
2113
|
+
const primary = SHORTLINKS_RUNTIME_ENV[key];
|
|
2114
|
+
if (clean(env[primary]))
|
|
2115
|
+
return primary;
|
|
2116
|
+
return SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
|
|
2119
2117
|
}
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
return
|
|
2118
|
+
function loadShortlinksRuntimeConfig(env = process.env) {
|
|
2119
|
+
const mode = getShortlinksStoreMode(env);
|
|
2120
|
+
const databaseUrl = getShortlinksDatabaseUrl(env);
|
|
2121
|
+
return {
|
|
2122
|
+
service: "shortlinks",
|
|
2123
|
+
mode,
|
|
2124
|
+
...databaseUrl ? {
|
|
2125
|
+
database: {
|
|
2126
|
+
provider: "postgres",
|
|
2127
|
+
url: databaseUrl,
|
|
2128
|
+
ssl: getShortlinksDatabaseSsl(env)
|
|
2129
|
+
}
|
|
2130
|
+
} : {}
|
|
2131
|
+
};
|
|
2124
2132
|
}
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
deliveriesPath;
|
|
2131
|
-
constructor(dataDir = getEventsDataDir()) {
|
|
2132
|
-
this.dataDir = dataDir;
|
|
2133
|
-
this.channelsPath = join(dataDir, "channels.json");
|
|
2134
|
-
this.eventsPath = join(dataDir, "events.json");
|
|
2135
|
-
this.deliveriesPath = join(dataDir, "deliveries.json");
|
|
2136
|
-
}
|
|
2137
|
-
async init() {
|
|
2138
|
-
await mkdir(this.dataDir, { recursive: true, mode: 448 });
|
|
2139
|
-
await chmod(this.dataDir, 448).catch(() => {
|
|
2140
|
-
return;
|
|
2141
|
-
});
|
|
2142
|
-
await this.ensureArrayFile(this.channelsPath);
|
|
2143
|
-
await this.ensureArrayFile(this.eventsPath);
|
|
2144
|
-
await this.ensureArrayFile(this.deliveriesPath);
|
|
2145
|
-
}
|
|
2146
|
-
async addChannel(channel) {
|
|
2147
|
-
await this.init();
|
|
2148
|
-
const channels = await this.readJson(this.channelsPath, []);
|
|
2149
|
-
const index = channels.findIndex((item) => item.id === channel.id);
|
|
2150
|
-
if (index >= 0) {
|
|
2151
|
-
channels[index] = { ...channel, createdAt: channels[index].createdAt, updatedAt: new Date().toISOString() };
|
|
2152
|
-
} else {
|
|
2153
|
-
channels.push(channel);
|
|
2154
|
-
}
|
|
2155
|
-
await this.writeJson(this.channelsPath, channels);
|
|
2156
|
-
return index >= 0 ? channels[index] : channel;
|
|
2157
|
-
}
|
|
2158
|
-
async listChannels() {
|
|
2159
|
-
await this.init();
|
|
2160
|
-
return this.readJson(this.channelsPath, []);
|
|
2161
|
-
}
|
|
2162
|
-
async getChannel(id) {
|
|
2163
|
-
const channels = await this.listChannels();
|
|
2164
|
-
return channels.find((channel) => channel.id === id);
|
|
2165
|
-
}
|
|
2166
|
-
async removeChannel(id) {
|
|
2167
|
-
await this.init();
|
|
2168
|
-
const channels = await this.readJson(this.channelsPath, []);
|
|
2169
|
-
const next = channels.filter((channel) => channel.id !== id);
|
|
2170
|
-
await this.writeJson(this.channelsPath, next);
|
|
2171
|
-
return next.length !== channels.length;
|
|
2172
|
-
}
|
|
2173
|
-
async appendEvent(event) {
|
|
2174
|
-
await this.init();
|
|
2175
|
-
const events = await this.readJson(this.eventsPath, []);
|
|
2176
|
-
events.push(event);
|
|
2177
|
-
await this.writeJson(this.eventsPath, events);
|
|
2178
|
-
return event;
|
|
2179
|
-
}
|
|
2180
|
-
async listEvents() {
|
|
2181
|
-
await this.init();
|
|
2182
|
-
return this.readJson(this.eventsPath, []);
|
|
2183
|
-
}
|
|
2184
|
-
async findEventByIdentity(identity) {
|
|
2185
|
-
const events = await this.listEvents();
|
|
2186
|
-
return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
|
|
2187
|
-
}
|
|
2188
|
-
async appendDelivery(result) {
|
|
2189
|
-
await this.init();
|
|
2190
|
-
const deliveries = await this.readJson(this.deliveriesPath, []);
|
|
2191
|
-
deliveries.push(result);
|
|
2192
|
-
await this.writeJson(this.deliveriesPath, deliveries);
|
|
2193
|
-
return result;
|
|
2194
|
-
}
|
|
2195
|
-
async listDeliveries() {
|
|
2196
|
-
await this.init();
|
|
2197
|
-
return this.readJson(this.deliveriesPath, []);
|
|
2133
|
+
function assertShortlinksPostgresConfig(config) {
|
|
2134
|
+
if (config.mode !== "postgres")
|
|
2135
|
+
return;
|
|
2136
|
+
if (!config.database?.url) {
|
|
2137
|
+
throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseUrl} is required when ${SHORTLINKS_RUNTIME_ENV.store}=postgres`);
|
|
2198
2138
|
}
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2139
|
+
}
|
|
2140
|
+
function getShortlinksRuntimeStatus(env = process.env) {
|
|
2141
|
+
const issues = [];
|
|
2142
|
+
const warnings = [];
|
|
2143
|
+
let config;
|
|
2144
|
+
try {
|
|
2145
|
+
config = loadShortlinksRuntimeConfig(env);
|
|
2146
|
+
} catch (error) {
|
|
2147
|
+
issues.push(error instanceof Error ? error.message : String(error));
|
|
2148
|
+
config = { service: "shortlinks", mode: "local" };
|
|
2205
2149
|
}
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
}
|
|
2211
|
-
await chmod(path, 384).catch(() => {
|
|
2212
|
-
return;
|
|
2213
|
-
});
|
|
2150
|
+
try {
|
|
2151
|
+
assertShortlinksPostgresConfig(config);
|
|
2152
|
+
} catch (error) {
|
|
2153
|
+
issues.push(error instanceof Error ? error.message : String(error));
|
|
2214
2154
|
}
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
const raw = await readFile(path, "utf-8");
|
|
2218
|
-
if (!raw.trim())
|
|
2219
|
-
return fallback;
|
|
2220
|
-
return JSON.parse(raw);
|
|
2221
|
-
} catch (error) {
|
|
2222
|
-
if (error.code === "ENOENT")
|
|
2223
|
-
return fallback;
|
|
2224
|
-
throw error;
|
|
2225
|
-
}
|
|
2155
|
+
if (config.mode === "local" && config.database?.url) {
|
|
2156
|
+
warnings.push(`${SHORTLINKS_RUNTIME_ENV.store}=local ignores configured Postgres database settings`);
|
|
2226
2157
|
}
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2158
|
+
return {
|
|
2159
|
+
ok: issues.length === 0,
|
|
2160
|
+
service: "shortlinks",
|
|
2161
|
+
mode: config.mode,
|
|
2162
|
+
local_default: config.mode === "local",
|
|
2163
|
+
postgres_enabled: config.mode === "postgres",
|
|
2164
|
+
database: {
|
|
2165
|
+
configured: Boolean(config.database?.url),
|
|
2166
|
+
provider: config.database?.provider ?? null,
|
|
2167
|
+
redacted_url: redactDatabaseUrl(config.database?.url),
|
|
2168
|
+
ssl: config.database?.ssl ?? null
|
|
2169
|
+
},
|
|
2170
|
+
env: runtimeEnvStatus(env),
|
|
2171
|
+
canonical: getCanonicalShortlinksPostgresConfig(),
|
|
2172
|
+
issues,
|
|
2173
|
+
warnings,
|
|
2174
|
+
no_network: true
|
|
2175
|
+
};
|
|
2176
|
+
}
|
|
2177
|
+
function redactDatabaseUrl(value) {
|
|
2178
|
+
if (!value)
|
|
2179
|
+
return null;
|
|
2180
|
+
try {
|
|
2181
|
+
const url = new URL(value);
|
|
2182
|
+
if (url.username)
|
|
2183
|
+
url.username = "***";
|
|
2184
|
+
if (url.password)
|
|
2185
|
+
url.password = "***";
|
|
2186
|
+
for (const key of Array.from(url.searchParams.keys())) {
|
|
2187
|
+
if (isSensitiveQueryKey(key))
|
|
2188
|
+
url.searchParams.set(key, "***");
|
|
2189
|
+
}
|
|
2190
|
+
return url.toString();
|
|
2191
|
+
} catch {
|
|
2192
|
+
return "(redacted)";
|
|
2235
2193
|
}
|
|
2236
2194
|
}
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2195
|
+
function runtimeEnvStatus(env) {
|
|
2196
|
+
return Object.fromEntries(Object.entries(SHORTLINKS_RUNTIME_ENV).map(([key, name]) => {
|
|
2197
|
+
const activeName = getShortlinksRuntimeEnvName(env, key);
|
|
2198
|
+
return [
|
|
2199
|
+
key,
|
|
2200
|
+
{
|
|
2201
|
+
name,
|
|
2202
|
+
active_name: activeName,
|
|
2203
|
+
configured: Boolean(clean(env[activeName]))
|
|
2204
|
+
}
|
|
2205
|
+
];
|
|
2206
|
+
}));
|
|
2240
2207
|
}
|
|
2241
|
-
function
|
|
2242
|
-
const
|
|
2243
|
-
|
|
2208
|
+
function readRuntimeEnv(env, key) {
|
|
2209
|
+
const primary = SHORTLINKS_RUNTIME_ENV[key];
|
|
2210
|
+
const primaryValue = clean(env[primary]);
|
|
2211
|
+
if (primaryValue)
|
|
2212
|
+
return { name: primary, value: primaryValue };
|
|
2213
|
+
const fallback = SHORTLINKS_RUNTIME_FALLBACK_ENV[key];
|
|
2214
|
+
return { name: fallback, value: clean(env[fallback]) };
|
|
2244
2215
|
}
|
|
2245
|
-
function
|
|
2246
|
-
|
|
2216
|
+
function parseBoolean(value, fallback) {
|
|
2217
|
+
const normalized = clean(value)?.toLowerCase();
|
|
2218
|
+
if (!normalized)
|
|
2219
|
+
return fallback;
|
|
2220
|
+
if (["1", "true", "yes", "on"].includes(normalized))
|
|
2221
|
+
return true;
|
|
2222
|
+
if (["0", "false", "no", "off"].includes(normalized))
|
|
2223
|
+
return false;
|
|
2224
|
+
throw new Error(`${SHORTLINKS_RUNTIME_ENV.databaseSsl} must be true or false`);
|
|
2247
2225
|
}
|
|
2248
|
-
function
|
|
2249
|
-
return
|
|
2226
|
+
function isSensitiveQueryKey(key) {
|
|
2227
|
+
return /(?:^|[_-])(pass(?:word)?|pwd|secret|token|credential|auth|api[_-]?key|access[_-]?key)(?:$|[_-])/i.test(key);
|
|
2250
2228
|
}
|
|
2251
|
-
function
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
const body = JSON.stringify(event);
|
|
2255
|
-
const timestamp = event.time;
|
|
2256
|
-
const headers = {
|
|
2257
|
-
"Content-Type": "application/json",
|
|
2258
|
-
"User-Agent": "@hasna/events",
|
|
2259
|
-
"X-Hasna-Event-Id": event.id,
|
|
2260
|
-
"X-Hasna-Event-Type": event.type,
|
|
2261
|
-
"X-Hasna-Timestamp": timestamp,
|
|
2262
|
-
...channel.webhook.headers
|
|
2263
|
-
};
|
|
2264
|
-
if (channel.webhook.secret) {
|
|
2265
|
-
headers["X-Hasna-Signature"] = signPayload(channel.webhook.secret, timestamp, body);
|
|
2266
|
-
}
|
|
2267
|
-
return { body, headers };
|
|
2229
|
+
function clean(value) {
|
|
2230
|
+
const trimmed = value?.trim();
|
|
2231
|
+
return trimmed ? trimmed : undefined;
|
|
2268
2232
|
}
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2233
|
+
|
|
2234
|
+
// src/pg-store.ts
|
|
2235
|
+
import { createHash } from "crypto";
|
|
2236
|
+
|
|
2237
|
+
// src/config.ts
|
|
2238
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
2239
|
+
import { randomBytes } from "crypto";
|
|
2240
|
+
import { homedir } from "os";
|
|
2241
|
+
import { dirname, join, resolve } from "path";
|
|
2242
|
+
var SERVICE_NAME = "shortlinks";
|
|
2243
|
+
var DEFAULT_DATA_DIR = join(homedir(), ".hasna", SERVICE_NAME);
|
|
2244
|
+
function getDataDir() {
|
|
2245
|
+
return resolve(process.env.SHORTLINKS_HOME || DEFAULT_DATA_DIR);
|
|
2246
|
+
}
|
|
2247
|
+
function ensureDataDir() {
|
|
2248
|
+
const dir = getDataDir();
|
|
2249
|
+
mkdirSync(dir, { recursive: true });
|
|
2250
|
+
return dir;
|
|
2251
|
+
}
|
|
2252
|
+
function getConfigPath() {
|
|
2253
|
+
return join(ensureDataDir(), "config.json");
|
|
2254
|
+
}
|
|
2255
|
+
function getClickSaltPath() {
|
|
2256
|
+
return join(ensureDataDir(), "click-salt");
|
|
2257
|
+
}
|
|
2258
|
+
function getDatabasePath(explicitPath) {
|
|
2259
|
+
if (explicitPath)
|
|
2260
|
+
return resolve(explicitPath);
|
|
2261
|
+
if (process.env.SHORTLINKS_DB)
|
|
2262
|
+
return resolve(process.env.SHORTLINKS_DB);
|
|
2263
|
+
return join(ensureDataDir(), `${SERVICE_NAME}.db`);
|
|
2264
|
+
}
|
|
2265
|
+
function readClickSaltFile(path) {
|
|
2276
2266
|
try {
|
|
2277
|
-
const
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
signal: controller.signal
|
|
2282
|
-
});
|
|
2283
|
-
const responseBody = truncate(await response.text());
|
|
2284
|
-
return {
|
|
2285
|
-
attempt: 1,
|
|
2286
|
-
status: response.ok ? "success" : "failed",
|
|
2287
|
-
startedAt,
|
|
2288
|
-
completedAt: now(),
|
|
2289
|
-
responseStatus: response.status,
|
|
2290
|
-
responseBody,
|
|
2291
|
-
error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
|
|
2292
|
-
};
|
|
2293
|
-
} catch (error) {
|
|
2294
|
-
return {
|
|
2295
|
-
attempt: 1,
|
|
2296
|
-
status: "failed",
|
|
2297
|
-
startedAt,
|
|
2298
|
-
completedAt: now(),
|
|
2299
|
-
error: error instanceof Error ? error.message : String(error)
|
|
2300
|
-
};
|
|
2301
|
-
} finally {
|
|
2302
|
-
clearTimeout(timeout);
|
|
2267
|
+
const saved = readFileSync(path, "utf-8").trim();
|
|
2268
|
+
return saved || null;
|
|
2269
|
+
} catch {
|
|
2270
|
+
return null;
|
|
2303
2271
|
}
|
|
2304
2272
|
}
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
const startedAt = now();
|
|
2309
|
-
const eventJson = JSON.stringify(event);
|
|
2310
|
-
const env = {
|
|
2311
|
-
...process.env,
|
|
2312
|
-
...channel.command.env,
|
|
2313
|
-
HASNA_CHANNEL_ID: channel.id,
|
|
2314
|
-
HASNA_EVENT_ID: event.id,
|
|
2315
|
-
HASNA_EVENT_TYPE: event.type,
|
|
2316
|
-
HASNA_EVENT_SOURCE: event.source,
|
|
2317
|
-
HASNA_EVENT_SUBJECT: event.subject ?? "",
|
|
2318
|
-
HASNA_EVENT_SEVERITY: event.severity,
|
|
2319
|
-
HASNA_EVENT_TIME: event.time,
|
|
2320
|
-
HASNA_EVENT_DEDUPE_KEY: event.dedupeKey ?? "",
|
|
2321
|
-
HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
|
|
2322
|
-
HASNA_EVENT_JSON: eventJson
|
|
2323
|
-
};
|
|
2324
|
-
return new Promise((resolve) => {
|
|
2325
|
-
const child = spawn(channel.command.command, channel.command.args ?? [], {
|
|
2326
|
-
cwd: channel.command.cwd,
|
|
2327
|
-
env,
|
|
2328
|
-
stdio: ["pipe", "pipe", "pipe"]
|
|
2329
|
-
});
|
|
2330
|
-
let stdout = "";
|
|
2331
|
-
let stderr = "";
|
|
2332
|
-
const timeout = setTimeout(() => child.kill("SIGTERM"), channel.command.timeoutMs ?? 15000);
|
|
2333
|
-
child.stdin.end(eventJson);
|
|
2334
|
-
child.stdout.on("data", (chunk) => {
|
|
2335
|
-
stdout += chunk.toString();
|
|
2336
|
-
});
|
|
2337
|
-
child.stderr.on("data", (chunk) => {
|
|
2338
|
-
stderr += chunk.toString();
|
|
2339
|
-
});
|
|
2340
|
-
child.on("error", (error) => {
|
|
2341
|
-
clearTimeout(timeout);
|
|
2342
|
-
resolve({
|
|
2343
|
-
attempt: 1,
|
|
2344
|
-
status: "failed",
|
|
2345
|
-
startedAt,
|
|
2346
|
-
completedAt: now(),
|
|
2347
|
-
stdout: truncate(stdout),
|
|
2348
|
-
stderr: truncate(stderr),
|
|
2349
|
-
error: error.message
|
|
2350
|
-
});
|
|
2351
|
-
});
|
|
2352
|
-
child.on("close", (code, signal) => {
|
|
2353
|
-
clearTimeout(timeout);
|
|
2354
|
-
const success = code === 0;
|
|
2355
|
-
resolve({
|
|
2356
|
-
attempt: 1,
|
|
2357
|
-
status: success ? "success" : "failed",
|
|
2358
|
-
startedAt,
|
|
2359
|
-
completedAt: now(),
|
|
2360
|
-
stdout: truncate(stdout),
|
|
2361
|
-
stderr: truncate(stderr),
|
|
2362
|
-
error: success ? undefined : `Command exited with ${signal ? `signal ${signal}` : `code ${code}`}`
|
|
2363
|
-
});
|
|
2364
|
-
});
|
|
2365
|
-
});
|
|
2366
|
-
}
|
|
2367
|
-
async function dispatchChannel(event, channel, options = {}) {
|
|
2368
|
-
if (channel.transport === "webhook")
|
|
2369
|
-
return dispatchWebhook(event, channel, options);
|
|
2370
|
-
if (channel.transport === "command")
|
|
2371
|
-
return dispatchCommand(event, channel);
|
|
2372
|
-
return {
|
|
2373
|
-
attempt: 1,
|
|
2374
|
-
status: "skipped",
|
|
2375
|
-
startedAt: now(),
|
|
2376
|
-
completedAt: now(),
|
|
2377
|
-
error: `Unsupported transport: ${channel.transport}`
|
|
2378
|
-
};
|
|
2379
|
-
}
|
|
2380
|
-
function createDeliveryResult(event, channel, attempts) {
|
|
2381
|
-
const status = attempts.some((attempt) => attempt.status === "success") ? "success" : attempts.every((attempt) => attempt.status === "skipped") ? "skipped" : "failed";
|
|
2382
|
-
return {
|
|
2383
|
-
id: randomUUID(),
|
|
2384
|
-
eventId: event.id,
|
|
2385
|
-
channelId: channel.id,
|
|
2386
|
-
transport: channel.transport,
|
|
2387
|
-
status,
|
|
2388
|
-
attempts,
|
|
2389
|
-
createdAt: attempts[0]?.startedAt ?? now(),
|
|
2390
|
-
completedAt: attempts.at(-1)?.completedAt ?? now()
|
|
2391
|
-
};
|
|
2392
|
-
}
|
|
2393
|
-
function createEvent(input) {
|
|
2394
|
-
return {
|
|
2395
|
-
id: input.id ?? randomUUID2(),
|
|
2396
|
-
source: input.source,
|
|
2397
|
-
type: input.type,
|
|
2398
|
-
time: normalizeTime(input.time),
|
|
2399
|
-
subject: input.subject,
|
|
2400
|
-
severity: input.severity ?? "info",
|
|
2401
|
-
data: input.data ?? {},
|
|
2402
|
-
message: input.message,
|
|
2403
|
-
dedupeKey: input.dedupeKey,
|
|
2404
|
-
schemaVersion: input.schemaVersion ?? "1.0",
|
|
2405
|
-
metadata: input.metadata ?? {}
|
|
2406
|
-
};
|
|
2273
|
+
function clickSaltError(path, error) {
|
|
2274
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
2275
|
+
return new Error(`Could not initialize click salt at ${path}. Set SHORTLINKS_CLICK_SALT or fix data directory permissions. ${detail}`);
|
|
2407
2276
|
}
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
}
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
})
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
|
|
2434
|
-
if (options.dedupe !== false) {
|
|
2435
|
-
const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
|
|
2436
|
-
if (existing) {
|
|
2437
|
-
return { event: existing, deliveries: [], deduped: true };
|
|
2438
|
-
}
|
|
2277
|
+
function getClickSalt() {
|
|
2278
|
+
const explicit = process.env.SHORTLINKS_CLICK_SALT?.trim();
|
|
2279
|
+
if (explicit)
|
|
2280
|
+
return explicit;
|
|
2281
|
+
const path = getClickSaltPath();
|
|
2282
|
+
const saved = readClickSaltFile(path);
|
|
2283
|
+
if (saved)
|
|
2284
|
+
return saved;
|
|
2285
|
+
const generated = randomBytes(32).toString("hex");
|
|
2286
|
+
const tempPath = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
2287
|
+
try {
|
|
2288
|
+
writeFileSync(tempPath, `${generated}
|
|
2289
|
+
`, { flag: "wx", mode: 384 });
|
|
2290
|
+
try {
|
|
2291
|
+
linkSync(tempPath, path);
|
|
2292
|
+
return generated;
|
|
2293
|
+
} catch (error) {
|
|
2294
|
+
const winner = readClickSaltFile(path);
|
|
2295
|
+
if (winner)
|
|
2296
|
+
return winner;
|
|
2297
|
+
throw clickSaltError(path, error);
|
|
2298
|
+
} finally {
|
|
2299
|
+
try {
|
|
2300
|
+
unlinkSync(tempPath);
|
|
2301
|
+
} catch {}
|
|
2439
2302
|
}
|
|
2440
|
-
|
|
2441
|
-
const
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
return this.store.listEvents();
|
|
2303
|
+
} catch (error) {
|
|
2304
|
+
const winner = readClickSaltFile(path);
|
|
2305
|
+
if (winner)
|
|
2306
|
+
return winner;
|
|
2307
|
+
throw clickSaltError(path, error);
|
|
2446
2308
|
}
|
|
2447
|
-
|
|
2448
|
-
|
|
2309
|
+
}
|
|
2310
|
+
function loadConfig() {
|
|
2311
|
+
const path = getConfigPath();
|
|
2312
|
+
if (!existsSync(path))
|
|
2313
|
+
return {};
|
|
2314
|
+
try {
|
|
2315
|
+
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
2316
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
2317
|
+
} catch {
|
|
2318
|
+
return {};
|
|
2449
2319
|
}
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2320
|
+
}
|
|
2321
|
+
function saveConfig(config) {
|
|
2322
|
+
const path = getConfigPath();
|
|
2323
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
2324
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}
|
|
2325
|
+
`);
|
|
2326
|
+
}
|
|
2327
|
+
function updateConfig(patch) {
|
|
2328
|
+
const next = {
|
|
2329
|
+
...loadConfig(),
|
|
2330
|
+
...patch,
|
|
2331
|
+
cloudflare: {
|
|
2332
|
+
...loadConfig().cloudflare,
|
|
2333
|
+
...patch.cloudflare
|
|
2459
2334
|
}
|
|
2460
|
-
|
|
2335
|
+
};
|
|
2336
|
+
saveConfig(next);
|
|
2337
|
+
return next;
|
|
2338
|
+
}
|
|
2339
|
+
function normalizeHostname(input) {
|
|
2340
|
+
const raw = input.trim().toLowerCase();
|
|
2341
|
+
if (!raw)
|
|
2342
|
+
throw new Error("Domain is required.");
|
|
2343
|
+
const withProtocol = raw.includes("://") ? raw : `https://${raw}`;
|
|
2344
|
+
let hostname;
|
|
2345
|
+
try {
|
|
2346
|
+
hostname = new URL(withProtocol).hostname;
|
|
2347
|
+
} catch {
|
|
2348
|
+
throw new Error(`Invalid domain: ${input}`);
|
|
2461
2349
|
}
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
source: input.source ?? "hasna.events",
|
|
2468
|
-
type: input.type ?? "events.test",
|
|
2469
|
-
subject: input.subject ?? id,
|
|
2470
|
-
severity: input.severity ?? "info",
|
|
2471
|
-
data: input.data ?? { test: true },
|
|
2472
|
-
message: input.message ?? "Hasna events test delivery",
|
|
2473
|
-
dedupeKey: input.dedupeKey,
|
|
2474
|
-
schemaVersion: input.schemaVersion,
|
|
2475
|
-
metadata: input.metadata,
|
|
2476
|
-
time: input.time,
|
|
2477
|
-
id: input.id
|
|
2478
|
-
});
|
|
2479
|
-
const eventForChannel = await this.applyRedaction(event, channel);
|
|
2480
|
-
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
2481
|
-
await this.store.appendDelivery(result);
|
|
2482
|
-
return result;
|
|
2350
|
+
hostname = hostname.replace(/\.$/, "");
|
|
2351
|
+
const labels = hostname.split(".");
|
|
2352
|
+
const labelsAreValid = labels.every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9-]+$/.test(label) && !label.startsWith("-") && !label.endsWith("-"));
|
|
2353
|
+
if (hostname.length > 253 || !labelsAreValid) {
|
|
2354
|
+
throw new Error(`Invalid domain: ${input}`);
|
|
2483
2355
|
}
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
if (options.type && event.type !== options.type)
|
|
2491
|
-
return false;
|
|
2492
|
-
return true;
|
|
2493
|
-
});
|
|
2494
|
-
if (options.dryRun)
|
|
2495
|
-
return { events, deliveries: [] };
|
|
2496
|
-
const deliveries = [];
|
|
2497
|
-
for (const event of events) {
|
|
2498
|
-
deliveries.push(...await this.deliver(event));
|
|
2499
|
-
}
|
|
2500
|
-
return { events, deliveries };
|
|
2356
|
+
return hostname;
|
|
2357
|
+
}
|
|
2358
|
+
function formatShortUrl(hostname, slug, publicBaseUrl) {
|
|
2359
|
+
if (publicBaseUrl) {
|
|
2360
|
+
const base = publicBaseUrl.endsWith("/") ? publicBaseUrl : `${publicBaseUrl}/`;
|
|
2361
|
+
return new URL(slug, base).toString();
|
|
2501
2362
|
}
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2363
|
+
return `https://${hostname}/${slug}`;
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
// src/database.ts
|
|
2367
|
+
import { Database } from "bun:sqlite";
|
|
2368
|
+
import { mkdirSync as mkdirSync2 } from "fs";
|
|
2369
|
+
import { dirname as dirname2 } from "path";
|
|
2370
|
+
function now() {
|
|
2371
|
+
return new Date().toISOString();
|
|
2372
|
+
}
|
|
2373
|
+
function makeId(prefix) {
|
|
2374
|
+
const bytes = crypto.getRandomValues(new Uint8Array(12));
|
|
2375
|
+
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
2376
|
+
return `${prefix}_${hex}`;
|
|
2377
|
+
}
|
|
2378
|
+
var SQLITE_MIGRATIONS = [
|
|
2379
|
+
`
|
|
2380
|
+
CREATE TABLE IF NOT EXISTS domains (
|
|
2381
|
+
id TEXT PRIMARY KEY,
|
|
2382
|
+
hostname TEXT NOT NULL UNIQUE,
|
|
2383
|
+
provider TEXT NOT NULL DEFAULT 'manual',
|
|
2384
|
+
default_domain INTEGER NOT NULL DEFAULT 0,
|
|
2385
|
+
cloudflare_zone_id TEXT,
|
|
2386
|
+
cloudflare_account_id TEXT,
|
|
2387
|
+
cloudflare_worker_name TEXT,
|
|
2388
|
+
origin_url TEXT,
|
|
2389
|
+
notes TEXT,
|
|
2390
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
2391
|
+
machine_id TEXT,
|
|
2392
|
+
synced_at TEXT,
|
|
2393
|
+
created_at TEXT NOT NULL,
|
|
2394
|
+
updated_at TEXT NOT NULL
|
|
2395
|
+
);
|
|
2396
|
+
|
|
2397
|
+
CREATE TABLE IF NOT EXISTS links (
|
|
2398
|
+
id TEXT PRIMARY KEY,
|
|
2399
|
+
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
2400
|
+
slug TEXT NOT NULL,
|
|
2401
|
+
destination_url TEXT NOT NULL,
|
|
2402
|
+
title TEXT,
|
|
2403
|
+
active INTEGER NOT NULL DEFAULT 1,
|
|
2404
|
+
expires_at TEXT,
|
|
2405
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
2406
|
+
machine_id TEXT,
|
|
2407
|
+
synced_at TEXT,
|
|
2408
|
+
created_at TEXT NOT NULL,
|
|
2409
|
+
updated_at TEXT NOT NULL,
|
|
2410
|
+
UNIQUE(domain_id, slug)
|
|
2411
|
+
);
|
|
2412
|
+
|
|
2413
|
+
CREATE TABLE IF NOT EXISTS clicks (
|
|
2414
|
+
id TEXT PRIMARY KEY,
|
|
2415
|
+
link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
|
|
2416
|
+
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
2417
|
+
slug TEXT NOT NULL,
|
|
2418
|
+
clicked_at TEXT NOT NULL,
|
|
2419
|
+
ip_hash TEXT,
|
|
2420
|
+
user_agent TEXT,
|
|
2421
|
+
referer TEXT,
|
|
2422
|
+
country TEXT,
|
|
2423
|
+
city TEXT,
|
|
2424
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
2425
|
+
machine_id TEXT,
|
|
2426
|
+
synced_at TEXT,
|
|
2427
|
+
created_at TEXT NOT NULL,
|
|
2428
|
+
updated_at TEXT NOT NULL
|
|
2429
|
+
);
|
|
2430
|
+
|
|
2431
|
+
CREATE INDEX IF NOT EXISTS idx_domains_hostname ON domains(hostname);
|
|
2432
|
+
CREATE INDEX IF NOT EXISTS idx_domains_default ON domains(default_domain);
|
|
2433
|
+
CREATE INDEX IF NOT EXISTS idx_links_domain_slug ON links(domain_id, slug);
|
|
2434
|
+
CREATE INDEX IF NOT EXISTS idx_links_active ON links(active);
|
|
2435
|
+
CREATE INDEX IF NOT EXISTS idx_links_updated ON links(updated_at);
|
|
2436
|
+
CREATE INDEX IF NOT EXISTS idx_clicks_link ON clicks(link_id);
|
|
2437
|
+
CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
|
|
2438
|
+
CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
|
|
2439
|
+
CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
|
|
2440
|
+
`
|
|
2441
|
+
];
|
|
2442
|
+
|
|
2443
|
+
class ShortlinksDatabase {
|
|
2444
|
+
db;
|
|
2445
|
+
path;
|
|
2446
|
+
constructor(path) {
|
|
2447
|
+
this.path = getDatabasePath(path);
|
|
2448
|
+
mkdirSync2(dirname2(this.path), { recursive: true });
|
|
2449
|
+
this.db = new Database(this.path);
|
|
2450
|
+
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
2451
|
+
this.applyMigrations();
|
|
2508
2452
|
}
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2453
|
+
close() {
|
|
2454
|
+
this.db.close();
|
|
2455
|
+
}
|
|
2456
|
+
applyMigrations() {
|
|
2457
|
+
this.db.exec(`
|
|
2458
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
2459
|
+
id INTEGER PRIMARY KEY,
|
|
2460
|
+
applied_at TEXT NOT NULL
|
|
2461
|
+
);
|
|
2462
|
+
`);
|
|
2463
|
+
for (let i = 0;i < SQLITE_MIGRATIONS.length; i += 1) {
|
|
2464
|
+
const id = i + 1;
|
|
2465
|
+
const applied = this.db.query("SELECT id FROM _migrations WHERE id = ?").get(id);
|
|
2466
|
+
if (applied)
|
|
2467
|
+
continue;
|
|
2468
|
+
const migration = SQLITE_MIGRATIONS[i];
|
|
2469
|
+
const apply = this.db.transaction(() => {
|
|
2470
|
+
this.db.exec(migration);
|
|
2471
|
+
this.db.query("INSERT INTO _migrations (id, applied_at) VALUES (?, ?)").run(id, now());
|
|
2472
|
+
});
|
|
2473
|
+
apply();
|
|
2523
2474
|
}
|
|
2524
|
-
return createDeliveryResult(event, channel, attempts);
|
|
2525
2475
|
}
|
|
2526
2476
|
}
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2477
|
+
|
|
2478
|
+
// src/machine.ts
|
|
2479
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
2480
|
+
import { hostname } from "os";
|
|
2481
|
+
import { join as join2 } from "path";
|
|
2482
|
+
|
|
2483
|
+
// src/slug.ts
|
|
2484
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
2485
|
+
var SLUG_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
2486
|
+
var DEFAULT_SLUG_LENGTH = 7;
|
|
2487
|
+
function randomToken(length = DEFAULT_SLUG_LENGTH) {
|
|
2488
|
+
if (length < 1 || length > 128)
|
|
2489
|
+
throw new Error("Token length must be between 1 and 128.");
|
|
2490
|
+
const bytes = randomBytes2(length);
|
|
2491
|
+
let out = "";
|
|
2492
|
+
for (let i = 0;i < length; i += 1) {
|
|
2493
|
+
out += SLUG_ALPHABET[bytes[i] % SLUG_ALPHABET.length];
|
|
2533
2494
|
}
|
|
2534
|
-
return
|
|
2495
|
+
return out;
|
|
2535
2496
|
}
|
|
2536
|
-
function
|
|
2537
|
-
const
|
|
2538
|
-
if (
|
|
2539
|
-
|
|
2540
|
-
if (
|
|
2541
|
-
|
|
2497
|
+
function normalizeSlug(slug) {
|
|
2498
|
+
const normalized = slug.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
|
2499
|
+
if (!normalized)
|
|
2500
|
+
throw new Error("Slug is required.");
|
|
2501
|
+
if (!/^[A-Za-z0-9_-]{1,96}$/.test(normalized)) {
|
|
2502
|
+
throw new Error("Slug can only contain letters, numbers, underscores, and dashes.");
|
|
2542
2503
|
}
|
|
2543
|
-
return
|
|
2544
|
-
}
|
|
2545
|
-
function sanitizeChannelsForOutput(channels) {
|
|
2546
|
-
return channels.map(sanitizeChannelForOutput);
|
|
2547
|
-
}
|
|
2548
|
-
function redactSensitiveKeys(event, replacement = "[REDACTED]") {
|
|
2549
|
-
return redactValue(event, replacement);
|
|
2504
|
+
return normalized;
|
|
2550
2505
|
}
|
|
2551
|
-
|
|
2552
|
-
|
|
2506
|
+
|
|
2507
|
+
// src/machine.ts
|
|
2508
|
+
function getMachineId() {
|
|
2509
|
+
const path = join2(ensureDataDir(), "machine-id");
|
|
2510
|
+
if (existsSync2(path)) {
|
|
2511
|
+
const existing = readFileSync2(path, "utf-8").trim();
|
|
2512
|
+
if (existing)
|
|
2513
|
+
return existing;
|
|
2514
|
+
}
|
|
2515
|
+
const safeHost = hostname().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
2516
|
+
const id = `${safeHost || "machine"}-${randomToken(8).toLowerCase()}`;
|
|
2517
|
+
writeFileSync2(path, `${id}
|
|
2518
|
+
`);
|
|
2519
|
+
return id;
|
|
2553
2520
|
}
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
if (!value
|
|
2521
|
+
|
|
2522
|
+
// src/pg-store.ts
|
|
2523
|
+
function parseJsonObject(value) {
|
|
2524
|
+
if (!value)
|
|
2525
|
+
return {};
|
|
2526
|
+
if (typeof value === "object" && !Array.isArray(value))
|
|
2558
2527
|
return value;
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
}
|
|
2564
|
-
function setPath(input, path, replacement) {
|
|
2565
|
-
const parts = path.split(".");
|
|
2566
|
-
let cursor = input;
|
|
2567
|
-
for (const part of parts.slice(0, -1)) {
|
|
2568
|
-
const next = cursor[part];
|
|
2569
|
-
if (!next || typeof next !== "object")
|
|
2570
|
-
return;
|
|
2571
|
-
cursor = next;
|
|
2528
|
+
try {
|
|
2529
|
+
const parsed = JSON.parse(String(value));
|
|
2530
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
2531
|
+
} catch {
|
|
2532
|
+
return {};
|
|
2572
2533
|
}
|
|
2573
|
-
const last = parts.at(-1);
|
|
2574
|
-
if (last && last in cursor)
|
|
2575
|
-
cursor[last] = replacement;
|
|
2576
2534
|
}
|
|
2577
|
-
function
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
return
|
|
2535
|
+
async function loadPgPool() {
|
|
2536
|
+
const importer = new Function("specifier", "return import(specifier)");
|
|
2537
|
+
const module = await importer("pg");
|
|
2538
|
+
return module.Pool;
|
|
2581
2539
|
}
|
|
2582
|
-
function
|
|
2540
|
+
function toPostgresSql(sql) {
|
|
2541
|
+
let index = 0;
|
|
2542
|
+
return sql.replace(/\?/g, () => `$${++index}`);
|
|
2543
|
+
}
|
|
2544
|
+
function createPgPoolConfig(connectionString, options = {}) {
|
|
2545
|
+
const ssl = options.ssl ?? true;
|
|
2583
2546
|
return {
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
multiplier: Math.max(1, policy?.multiplier ?? 2)
|
|
2547
|
+
connectionString,
|
|
2548
|
+
...ssl ? { ssl: { rejectUnauthorized: true } } : { ssl: false }
|
|
2587
2549
|
};
|
|
2588
2550
|
}
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
throw new Error("Expected a JSON object");
|
|
2551
|
+
|
|
2552
|
+
class PgPoolAdapter {
|
|
2553
|
+
pool;
|
|
2554
|
+
constructor(pool) {
|
|
2555
|
+
this.pool = pool;
|
|
2595
2556
|
}
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2557
|
+
static async create(connectionString, options = {}) {
|
|
2558
|
+
const Pool = await loadPgPool();
|
|
2559
|
+
return new PgPoolAdapter(new Pool(createPgPoolConfig(connectionString, options)));
|
|
2560
|
+
}
|
|
2561
|
+
async get(sql, ...params) {
|
|
2562
|
+
const result = await this.pool.query(toPostgresSql(sql), params);
|
|
2563
|
+
return result.rows[0] ?? null;
|
|
2564
|
+
}
|
|
2565
|
+
async all(sql, ...params) {
|
|
2566
|
+
const result = await this.pool.query(toPostgresSql(sql), params);
|
|
2567
|
+
return result.rows;
|
|
2568
|
+
}
|
|
2569
|
+
async run(sql, ...params) {
|
|
2570
|
+
return this.pool.query(toPostgresSql(sql), params);
|
|
2571
|
+
}
|
|
2572
|
+
async close() {
|
|
2573
|
+
await this.pool.end();
|
|
2607
2574
|
}
|
|
2608
|
-
return headers;
|
|
2609
2575
|
}
|
|
2610
|
-
function
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
if (options.type)
|
|
2615
|
-
filter2.type = options.type;
|
|
2616
|
-
if (options.subject)
|
|
2617
|
-
filter2.subject = options.subject;
|
|
2618
|
-
if (options.severity)
|
|
2619
|
-
filter2.severity = options.severity;
|
|
2620
|
-
return Object.keys(filter2).length > 0 ? [filter2] : undefined;
|
|
2621
|
-
}
|
|
2622
|
-
function createClient(options) {
|
|
2623
|
-
if (options.createClient)
|
|
2624
|
-
return options.createClient();
|
|
2625
|
-
return new EventsClient({ store: new JsonEventsStore(options.dataDir) });
|
|
2626
|
-
}
|
|
2627
|
-
function print(value, json, text) {
|
|
2628
|
-
if (json)
|
|
2629
|
-
console.log(JSON.stringify(value, null, 2));
|
|
2630
|
-
else
|
|
2631
|
-
console.log(text);
|
|
2576
|
+
function toIsoString(value) {
|
|
2577
|
+
if (value instanceof Date)
|
|
2578
|
+
return value.toISOString();
|
|
2579
|
+
return String(value);
|
|
2632
2580
|
}
|
|
2633
|
-
function
|
|
2634
|
-
|
|
2581
|
+
function nullableIso(value) {
|
|
2582
|
+
if (value === null || value === undefined)
|
|
2583
|
+
return null;
|
|
2584
|
+
return toIsoString(value);
|
|
2635
2585
|
}
|
|
2636
|
-
function
|
|
2637
|
-
return
|
|
2586
|
+
function domainFromRow(row) {
|
|
2587
|
+
return {
|
|
2588
|
+
...row,
|
|
2589
|
+
default_domain: Boolean(row.default_domain),
|
|
2590
|
+
synced_at: nullableIso(row.synced_at),
|
|
2591
|
+
created_at: toIsoString(row.created_at),
|
|
2592
|
+
updated_at: toIsoString(row.updated_at),
|
|
2593
|
+
metadata: parseJsonObject(row.metadata)
|
|
2594
|
+
};
|
|
2638
2595
|
}
|
|
2639
|
-
function
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
redact: actionOptions.redact?.length ? { paths: actionOptions.redact } : undefined,
|
|
2651
|
-
createdAt: timestamp,
|
|
2652
|
-
updatedAt: timestamp
|
|
2653
|
-
};
|
|
2654
|
-
if (actionOptions.transport === "webhook") {
|
|
2655
|
-
channel.webhook = { url: target, secret: actionOptions.secret, headers: parseHeaders(actionOptions.header), timeoutMs: actionOptions.timeoutMs };
|
|
2656
|
-
} else if (actionOptions.transport === "command") {
|
|
2657
|
-
channel.command = { command: target, args: actionOptions.arg ?? [], timeoutMs: actionOptions.timeoutMs };
|
|
2658
|
-
} else {
|
|
2659
|
-
throw new Error(`Transport ${actionOptions.transport} is reserved for future use and cannot be added yet`);
|
|
2660
|
-
}
|
|
2661
|
-
const saved = await createClient(options).addChannel(channel);
|
|
2662
|
-
print(sanitizeChannelForOutput(saved), wantsJson(actionOptions, command), `Added ${saved.transport} channel ${saved.id}`);
|
|
2663
|
-
});
|
|
2664
|
-
webhooks.command("list").description("List configured subscriptions").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
|
|
2665
|
-
const channels = await createClient(options).listChannels();
|
|
2666
|
-
if (wantsJson(actionOptions, command)) {
|
|
2667
|
-
console.log(JSON.stringify(sanitizeChannelsForOutput(channels), null, 2));
|
|
2668
|
-
return;
|
|
2669
|
-
}
|
|
2670
|
-
if (!channels.length) {
|
|
2671
|
-
console.log("No channels configured.");
|
|
2672
|
-
return;
|
|
2673
|
-
}
|
|
2674
|
-
for (const channel of channels) {
|
|
2675
|
-
console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
|
|
2676
|
-
}
|
|
2677
|
-
});
|
|
2678
|
-
webhooks.command("remove").description("Remove a subscription").argument("<id>", "Subscription/channel identifier").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
|
|
2679
|
-
const removed = await createClient(options).removeChannel(id);
|
|
2680
|
-
print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
|
|
2681
|
-
});
|
|
2682
|
-
webhooks.command("test").description("Send a test event to one subscription").argument("<id>", "Subscription/channel identifier").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events test delivery").option("--data <json>", "Event data JSON object").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
|
|
2683
|
-
const result = await createClient(options).testChannel(id, {
|
|
2684
|
-
source: options.source,
|
|
2685
|
-
type: actionOptions.type,
|
|
2686
|
-
subject: actionOptions.subject ?? id,
|
|
2687
|
-
message: actionOptions.message,
|
|
2688
|
-
data: parseJsonObject(actionOptions.data, { test: true })
|
|
2689
|
-
});
|
|
2690
|
-
print(result, wantsJson(actionOptions, command), `${result.status}: ${result.channelId}`);
|
|
2691
|
-
});
|
|
2692
|
-
return webhooks;
|
|
2596
|
+
function linkFromRow(row) {
|
|
2597
|
+
return {
|
|
2598
|
+
...row,
|
|
2599
|
+
active: Boolean(row.active),
|
|
2600
|
+
expires_at: nullableIso(row.expires_at),
|
|
2601
|
+
synced_at: nullableIso(row.synced_at),
|
|
2602
|
+
created_at: toIsoString(row.created_at),
|
|
2603
|
+
updated_at: toIsoString(row.updated_at),
|
|
2604
|
+
metadata: parseJsonObject(row.metadata),
|
|
2605
|
+
short_url: formatShortUrl(row.hostname, row.slug)
|
|
2606
|
+
};
|
|
2693
2607
|
}
|
|
2694
|
-
function
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
metadata: parseJsonObject(actionOptions.metadata, {})
|
|
2706
|
-
}, { deliver: actionOptions.deliver, dedupe: actionOptions.dedupe });
|
|
2707
|
-
print(result, wantsJson(actionOptions, command), `${result.deduped ? "Deduped" : "Emitted"} ${result.event.id} to ${result.deliveries.length} channel(s)`);
|
|
2708
|
-
});
|
|
2709
|
-
events.command("list").description("List recorded events").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--limit <n>", "Limit results", parseNumber).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
|
|
2710
|
-
let rows = await createClient(options).listEvents();
|
|
2711
|
-
if (actionOptions.source)
|
|
2712
|
-
rows = rows.filter((event) => event.source === actionOptions.source);
|
|
2713
|
-
if (actionOptions.type)
|
|
2714
|
-
rows = rows.filter((event) => event.type === actionOptions.type);
|
|
2715
|
-
if (actionOptions.limit)
|
|
2716
|
-
rows = rows.slice(-actionOptions.limit);
|
|
2717
|
-
if (wantsJson(actionOptions, command)) {
|
|
2718
|
-
console.log(JSON.stringify(rows, null, 2));
|
|
2719
|
-
return;
|
|
2720
|
-
}
|
|
2721
|
-
if (!rows.length) {
|
|
2722
|
-
console.log("No events recorded.");
|
|
2723
|
-
return;
|
|
2724
|
-
}
|
|
2725
|
-
for (const event of rows)
|
|
2726
|
-
console.log(`${event.time} ${event.id} ${event.source} ${event.type} ${event.severity}`);
|
|
2727
|
-
});
|
|
2728
|
-
events.command("replay").description("Replay recorded events").option("--id <id>", "Replay one event id").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--dry-run", "Preview without delivery", false).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
|
|
2729
|
-
const result = await createClient(options).replay({
|
|
2730
|
-
eventId: actionOptions.id,
|
|
2731
|
-
source: actionOptions.source,
|
|
2732
|
-
type: actionOptions.type,
|
|
2733
|
-
dryRun: actionOptions.dryRun
|
|
2734
|
-
});
|
|
2735
|
-
print(result, wantsJson(actionOptions, command), `Replayed ${result.events.length} event(s), ${result.deliveries.length} delivery result(s)`);
|
|
2736
|
-
});
|
|
2737
|
-
return events;
|
|
2608
|
+
function validateDestinationUrl(url) {
|
|
2609
|
+
let parsed;
|
|
2610
|
+
try {
|
|
2611
|
+
parsed = new URL(url);
|
|
2612
|
+
} catch {
|
|
2613
|
+
throw new Error(`Invalid destination URL: ${url}`);
|
|
2614
|
+
}
|
|
2615
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
2616
|
+
throw new Error("Destination URL must start with http:// or https://.");
|
|
2617
|
+
}
|
|
2618
|
+
return parsed.toString();
|
|
2738
2619
|
}
|
|
2739
|
-
function
|
|
2740
|
-
|
|
2741
|
-
|
|
2620
|
+
function isoOrNull(input) {
|
|
2621
|
+
if (!input)
|
|
2622
|
+
return null;
|
|
2623
|
+
const date = new Date(input);
|
|
2624
|
+
if (Number.isNaN(date.getTime()))
|
|
2625
|
+
throw new Error(`Invalid date: ${input}`);
|
|
2626
|
+
return date.toISOString();
|
|
2742
2627
|
}
|
|
2743
|
-
function
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2628
|
+
function clickFromRow(row) {
|
|
2629
|
+
return {
|
|
2630
|
+
...row,
|
|
2631
|
+
clicked_at: toIsoString(row.clicked_at),
|
|
2632
|
+
synced_at: nullableIso(row.synced_at),
|
|
2633
|
+
created_at: toIsoString(row.created_at),
|
|
2634
|
+
updated_at: toIsoString(row.updated_at),
|
|
2635
|
+
metadata: parseJsonObject(row.metadata)
|
|
2636
|
+
};
|
|
2748
2637
|
}
|
|
2749
|
-
function
|
|
2750
|
-
|
|
2751
|
-
|
|
2638
|
+
function createKitPgAdapter(client) {
|
|
2639
|
+
return {
|
|
2640
|
+
async get(sql, ...params) {
|
|
2641
|
+
return client.get(toPostgresSql(sql), params);
|
|
2642
|
+
},
|
|
2643
|
+
async all(sql, ...params) {
|
|
2644
|
+
return client.many(toPostgresSql(sql), params);
|
|
2645
|
+
},
|
|
2646
|
+
async run(sql, ...params) {
|
|
2647
|
+
return client.query(toPostgresSql(sql), params);
|
|
2648
|
+
}
|
|
2649
|
+
};
|
|
2752
2650
|
}
|
|
2753
2651
|
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
createCommand,
|
|
2759
|
-
createArgument,
|
|
2760
|
-
createOption,
|
|
2761
|
-
CommanderError,
|
|
2762
|
-
InvalidArgumentError,
|
|
2763
|
-
InvalidOptionArgumentError,
|
|
2764
|
-
Command,
|
|
2765
|
-
Argument,
|
|
2766
|
-
Option,
|
|
2767
|
-
Help
|
|
2768
|
-
} = import__.default;
|
|
2769
|
-
|
|
2770
|
-
// node_modules/chalk/source/vendor/ansi-styles/index.js
|
|
2771
|
-
var ANSI_BACKGROUND_OFFSET = 10;
|
|
2772
|
-
var wrapAnsi16 = (offset = 0) => (code) => `\x1B[${code + offset}m`;
|
|
2773
|
-
var wrapAnsi256 = (offset = 0) => (code) => `\x1B[${38 + offset};5;${code}m`;
|
|
2774
|
-
var wrapAnsi16m = (offset = 0) => (red, green, blue) => `\x1B[${38 + offset};2;${red};${green};${blue}m`;
|
|
2775
|
-
var styles = {
|
|
2776
|
-
modifier: {
|
|
2777
|
-
reset: [0, 0],
|
|
2778
|
-
bold: [1, 22],
|
|
2779
|
-
dim: [2, 22],
|
|
2780
|
-
italic: [3, 23],
|
|
2781
|
-
underline: [4, 24],
|
|
2782
|
-
overline: [53, 55],
|
|
2783
|
-
inverse: [7, 27],
|
|
2784
|
-
hidden: [8, 28],
|
|
2785
|
-
strikethrough: [9, 29]
|
|
2786
|
-
},
|
|
2787
|
-
color: {
|
|
2788
|
-
black: [30, 39],
|
|
2789
|
-
red: [31, 39],
|
|
2790
|
-
green: [32, 39],
|
|
2791
|
-
yellow: [33, 39],
|
|
2792
|
-
blue: [34, 39],
|
|
2793
|
-
magenta: [35, 39],
|
|
2794
|
-
cyan: [36, 39],
|
|
2795
|
-
white: [37, 39],
|
|
2796
|
-
blackBright: [90, 39],
|
|
2797
|
-
gray: [90, 39],
|
|
2798
|
-
grey: [90, 39],
|
|
2799
|
-
redBright: [91, 39],
|
|
2800
|
-
greenBright: [92, 39],
|
|
2801
|
-
yellowBright: [93, 39],
|
|
2802
|
-
blueBright: [94, 39],
|
|
2803
|
-
magentaBright: [95, 39],
|
|
2804
|
-
cyanBright: [96, 39],
|
|
2805
|
-
whiteBright: [97, 39]
|
|
2806
|
-
},
|
|
2807
|
-
bgColor: {
|
|
2808
|
-
bgBlack: [40, 49],
|
|
2809
|
-
bgRed: [41, 49],
|
|
2810
|
-
bgGreen: [42, 49],
|
|
2811
|
-
bgYellow: [43, 49],
|
|
2812
|
-
bgBlue: [44, 49],
|
|
2813
|
-
bgMagenta: [45, 49],
|
|
2814
|
-
bgCyan: [46, 49],
|
|
2815
|
-
bgWhite: [47, 49],
|
|
2816
|
-
bgBlackBright: [100, 49],
|
|
2817
|
-
bgGray: [100, 49],
|
|
2818
|
-
bgGrey: [100, 49],
|
|
2819
|
-
bgRedBright: [101, 49],
|
|
2820
|
-
bgGreenBright: [102, 49],
|
|
2821
|
-
bgYellowBright: [103, 49],
|
|
2822
|
-
bgBlueBright: [104, 49],
|
|
2823
|
-
bgMagentaBright: [105, 49],
|
|
2824
|
-
bgCyanBright: [106, 49],
|
|
2825
|
-
bgWhiteBright: [107, 49]
|
|
2652
|
+
class PgShortlinksStore {
|
|
2653
|
+
pg;
|
|
2654
|
+
constructor(pg) {
|
|
2655
|
+
this.pg = pg;
|
|
2826
2656
|
}
|
|
2827
|
-
}
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
open: `\x1B[${style[0]}m`,
|
|
2838
|
-
close: `\x1B[${style[1]}m`
|
|
2839
|
-
};
|
|
2840
|
-
group[styleName] = styles[styleName];
|
|
2841
|
-
codes.set(style[0], style[1]);
|
|
2657
|
+
static async fromConnectionString(connectionString, options = {}) {
|
|
2658
|
+
return new PgShortlinksStore(await PgPoolAdapter.create(connectionString, options));
|
|
2659
|
+
}
|
|
2660
|
+
static fromQueryClient(client) {
|
|
2661
|
+
return new PgShortlinksStore(createKitPgAdapter(client));
|
|
2662
|
+
}
|
|
2663
|
+
static async fromEnv(env = process.env) {
|
|
2664
|
+
const connectionString = getShortlinksDatabaseUrl(env);
|
|
2665
|
+
if (!connectionString) {
|
|
2666
|
+
throw new Error("HASNA_SHORTLINKS_DATABASE_URL is required when shortlinks uses the postgres store");
|
|
2842
2667
|
}
|
|
2843
|
-
|
|
2844
|
-
value: group,
|
|
2845
|
-
enumerable: false
|
|
2846
|
-
});
|
|
2668
|
+
return PgShortlinksStore.fromConnectionString(connectionString, { ssl: getShortlinksDatabaseSsl(env) });
|
|
2847
2669
|
}
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
styles.bgColor.ansi16m = wrapAnsi16m(ANSI_BACKGROUND_OFFSET);
|
|
2860
|
-
Object.defineProperties(styles, {
|
|
2861
|
-
rgbToAnsi256: {
|
|
2862
|
-
value(red, green, blue) {
|
|
2863
|
-
if (red === green && green === blue) {
|
|
2864
|
-
if (red < 8) {
|
|
2865
|
-
return 16;
|
|
2866
|
-
}
|
|
2867
|
-
if (red > 248) {
|
|
2868
|
-
return 231;
|
|
2869
|
-
}
|
|
2870
|
-
return Math.round((red - 8) / 247 * 24) + 232;
|
|
2871
|
-
}
|
|
2872
|
-
return 16 + 36 * Math.round(red / 255 * 5) + 6 * Math.round(green / 255 * 5) + Math.round(blue / 255 * 5);
|
|
2873
|
-
},
|
|
2874
|
-
enumerable: false
|
|
2875
|
-
},
|
|
2876
|
-
hexToRgb: {
|
|
2877
|
-
value(hex) {
|
|
2878
|
-
const matches = /[a-f\d]{6}|[a-f\d]{3}/i.exec(hex.toString(16));
|
|
2879
|
-
if (!matches) {
|
|
2880
|
-
return [0, 0, 0];
|
|
2881
|
-
}
|
|
2882
|
-
let [colorString] = matches;
|
|
2883
|
-
if (colorString.length === 3) {
|
|
2884
|
-
colorString = [...colorString].map((character) => character + character).join("");
|
|
2885
|
-
}
|
|
2886
|
-
const integer = Number.parseInt(colorString, 16);
|
|
2887
|
-
return [
|
|
2888
|
-
integer >> 16 & 255,
|
|
2889
|
-
integer >> 8 & 255,
|
|
2890
|
-
integer & 255
|
|
2891
|
-
];
|
|
2892
|
-
},
|
|
2893
|
-
enumerable: false
|
|
2894
|
-
},
|
|
2895
|
-
hexToAnsi256: {
|
|
2896
|
-
value: (hex) => styles.rgbToAnsi256(...styles.hexToRgb(hex)),
|
|
2897
|
-
enumerable: false
|
|
2898
|
-
},
|
|
2899
|
-
ansi256ToAnsi: {
|
|
2900
|
-
value(code) {
|
|
2901
|
-
if (code < 8) {
|
|
2902
|
-
return 30 + code;
|
|
2903
|
-
}
|
|
2904
|
-
if (code < 16) {
|
|
2905
|
-
return 90 + (code - 8);
|
|
2906
|
-
}
|
|
2907
|
-
let red;
|
|
2908
|
-
let green;
|
|
2909
|
-
let blue;
|
|
2910
|
-
if (code >= 232) {
|
|
2911
|
-
red = ((code - 232) * 10 + 8) / 255;
|
|
2912
|
-
green = red;
|
|
2913
|
-
blue = red;
|
|
2914
|
-
} else {
|
|
2915
|
-
code -= 16;
|
|
2916
|
-
const remainder = code % 36;
|
|
2917
|
-
red = Math.floor(code / 36) / 5;
|
|
2918
|
-
green = Math.floor(remainder / 6) / 5;
|
|
2919
|
-
blue = remainder % 6 / 5;
|
|
2920
|
-
}
|
|
2921
|
-
const value = Math.max(red, green, blue) * 2;
|
|
2922
|
-
if (value === 0) {
|
|
2923
|
-
return 30;
|
|
2924
|
-
}
|
|
2925
|
-
let result = 30 + (Math.round(blue) << 2 | Math.round(green) << 1 | Math.round(red));
|
|
2926
|
-
if (value === 2) {
|
|
2927
|
-
result += 60;
|
|
2928
|
-
}
|
|
2929
|
-
return result;
|
|
2930
|
-
},
|
|
2931
|
-
enumerable: false
|
|
2932
|
-
},
|
|
2933
|
-
rgbToAnsi: {
|
|
2934
|
-
value: (red, green, blue) => styles.ansi256ToAnsi(styles.rgbToAnsi256(red, green, blue)),
|
|
2935
|
-
enumerable: false
|
|
2936
|
-
},
|
|
2937
|
-
hexToAnsi: {
|
|
2938
|
-
value: (hex) => styles.ansi256ToAnsi(styles.hexToAnsi256(hex)),
|
|
2939
|
-
enumerable: false
|
|
2940
|
-
}
|
|
2941
|
-
});
|
|
2942
|
-
return styles;
|
|
2943
|
-
}
|
|
2944
|
-
var ansiStyles = assembleStyles();
|
|
2945
|
-
var ansi_styles_default = ansiStyles;
|
|
2946
|
-
|
|
2947
|
-
// node_modules/chalk/source/vendor/supports-color/index.js
|
|
2948
|
-
import process2 from "process";
|
|
2949
|
-
import os from "os";
|
|
2950
|
-
import tty from "tty";
|
|
2951
|
-
function hasFlag(flag, argv = globalThis.Deno ? globalThis.Deno.args : process2.argv) {
|
|
2952
|
-
const prefix = flag.startsWith("-") ? "" : flag.length === 1 ? "-" : "--";
|
|
2953
|
-
const position = argv.indexOf(prefix + flag);
|
|
2954
|
-
const terminatorPosition = argv.indexOf("--");
|
|
2955
|
-
return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition);
|
|
2956
|
-
}
|
|
2957
|
-
var { env } = process2;
|
|
2958
|
-
var flagForceColor;
|
|
2959
|
-
if (hasFlag("no-color") || hasFlag("no-colors") || hasFlag("color=false") || hasFlag("color=never")) {
|
|
2960
|
-
flagForceColor = 0;
|
|
2961
|
-
} else if (hasFlag("color") || hasFlag("colors") || hasFlag("color=true") || hasFlag("color=always")) {
|
|
2962
|
-
flagForceColor = 1;
|
|
2963
|
-
}
|
|
2964
|
-
function envForceColor() {
|
|
2965
|
-
if ("FORCE_COLOR" in env) {
|
|
2966
|
-
if (env.FORCE_COLOR === "true") {
|
|
2967
|
-
return 1;
|
|
2968
|
-
}
|
|
2969
|
-
if (env.FORCE_COLOR === "false") {
|
|
2970
|
-
return 0;
|
|
2670
|
+
async close() {
|
|
2671
|
+
await this.pg.close?.();
|
|
2672
|
+
}
|
|
2673
|
+
async addDomain(input) {
|
|
2674
|
+
const hostname2 = normalizeHostname(input.hostname);
|
|
2675
|
+
const timestamp = now();
|
|
2676
|
+
const machineId = getMachineId();
|
|
2677
|
+
const existing = await this.getDomain(hostname2);
|
|
2678
|
+
const id = existing?.id || makeId("dom");
|
|
2679
|
+
if (input.defaultDomain) {
|
|
2680
|
+
await this.pg.run("UPDATE domains SET default_domain = 0, updated_at = ? WHERE default_domain = 1", timestamp);
|
|
2971
2681
|
}
|
|
2972
|
-
|
|
2682
|
+
await this.pg.run(`
|
|
2683
|
+
INSERT INTO domains (
|
|
2684
|
+
id, hostname, provider, default_domain, cloudflare_zone_id, cloudflare_account_id,
|
|
2685
|
+
cloudflare_worker_name, origin_url, notes, metadata, machine_id, synced_at, created_at, updated_at
|
|
2686
|
+
)
|
|
2687
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
|
|
2688
|
+
ON CONFLICT(hostname) DO UPDATE SET
|
|
2689
|
+
provider = excluded.provider,
|
|
2690
|
+
default_domain = excluded.default_domain,
|
|
2691
|
+
cloudflare_zone_id = COALESCE(excluded.cloudflare_zone_id, domains.cloudflare_zone_id),
|
|
2692
|
+
cloudflare_account_id = COALESCE(excluded.cloudflare_account_id, domains.cloudflare_account_id),
|
|
2693
|
+
cloudflare_worker_name = COALESCE(excluded.cloudflare_worker_name, domains.cloudflare_worker_name),
|
|
2694
|
+
origin_url = COALESCE(excluded.origin_url, domains.origin_url),
|
|
2695
|
+
notes = COALESCE(excluded.notes, domains.notes),
|
|
2696
|
+
metadata = excluded.metadata,
|
|
2697
|
+
machine_id = excluded.machine_id,
|
|
2698
|
+
synced_at = NULL,
|
|
2699
|
+
updated_at = excluded.updated_at
|
|
2700
|
+
`, 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);
|
|
2701
|
+
return await this.getDomain(hostname2);
|
|
2973
2702
|
}
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2703
|
+
async listDomains() {
|
|
2704
|
+
const rows = await this.pg.all(`
|
|
2705
|
+
SELECT * FROM domains
|
|
2706
|
+
ORDER BY default_domain DESC, hostname ASC
|
|
2707
|
+
`);
|
|
2708
|
+
return rows.map(domainFromRow);
|
|
2978
2709
|
}
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
}
|
|
2986
|
-
function _supportsColor(haveStream, { streamIsTTY, sniffFlags = true } = {}) {
|
|
2987
|
-
const noFlagForceColor = envForceColor();
|
|
2988
|
-
if (noFlagForceColor !== undefined) {
|
|
2989
|
-
flagForceColor = noFlagForceColor;
|
|
2710
|
+
async getDomain(hostnameOrId) {
|
|
2711
|
+
const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
|
|
2712
|
+
const row = await this.pg.get(`
|
|
2713
|
+
SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
|
|
2714
|
+
`, normalized, hostnameOrId);
|
|
2715
|
+
return row ? domainFromRow(row) : null;
|
|
2990
2716
|
}
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2717
|
+
async getDefaultDomain() {
|
|
2718
|
+
const row = await this.pg.get(`
|
|
2719
|
+
SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
|
|
2720
|
+
`);
|
|
2721
|
+
return row ? domainFromRow(row) : null;
|
|
2994
2722
|
}
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2723
|
+
async createLink(input) {
|
|
2724
|
+
const domain = input.domain ? await this.getDomain(input.domain) : await this.getDefaultDomain();
|
|
2725
|
+
if (!domain) {
|
|
2726
|
+
throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
|
|
2998
2727
|
}
|
|
2999
|
-
|
|
3000
|
-
|
|
2728
|
+
const destinationUrl = validateDestinationUrl(input.destinationUrl);
|
|
2729
|
+
const timestamp = now();
|
|
2730
|
+
const machineId = getMachineId();
|
|
2731
|
+
const expiresAt = isoOrNull(input.expiresAt);
|
|
2732
|
+
const slug = input.slug ? normalizeSlug(input.slug) : await this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
|
|
2733
|
+
try {
|
|
2734
|
+
await this.pg.run(`
|
|
2735
|
+
INSERT INTO links (
|
|
2736
|
+
id, domain_id, slug, destination_url, title, active, expires_at, metadata,
|
|
2737
|
+
machine_id, synced_at, created_at, updated_at
|
|
2738
|
+
)
|
|
2739
|
+
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
|
|
2740
|
+
`, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
|
|
2741
|
+
} catch (error) {
|
|
2742
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2743
|
+
if (message.includes("unique") || message.includes("duplicate")) {
|
|
2744
|
+
throw new Error(`Slug already exists for ${domain.hostname}: ${slug}`);
|
|
2745
|
+
}
|
|
2746
|
+
throw error;
|
|
3001
2747
|
}
|
|
2748
|
+
return await this.getLink(domain.hostname, slug);
|
|
3002
2749
|
}
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
const min = forceColor || 0;
|
|
3010
|
-
if (env.TERM === "dumb") {
|
|
3011
|
-
return min;
|
|
3012
|
-
}
|
|
3013
|
-
if (process2.platform === "win32") {
|
|
3014
|
-
const osRelease = os.release().split(".");
|
|
3015
|
-
if (Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) {
|
|
3016
|
-
return Number(osRelease[2]) >= 14931 ? 3 : 2;
|
|
2750
|
+
async listLinks(options = {}) {
|
|
2751
|
+
const params = [];
|
|
2752
|
+
let where = "WHERE 1 = 1";
|
|
2753
|
+
if (options.domain) {
|
|
2754
|
+
where += " AND d.hostname = ?";
|
|
2755
|
+
params.push(normalizeHostname(options.domain));
|
|
3017
2756
|
}
|
|
3018
|
-
|
|
2757
|
+
if (options.activeOnly)
|
|
2758
|
+
where += " AND l.active = 1";
|
|
2759
|
+
params.push(options.limit || 100);
|
|
2760
|
+
const rows = await this.pg.all(`
|
|
2761
|
+
SELECT l.*, d.hostname
|
|
2762
|
+
FROM links l
|
|
2763
|
+
JOIN domains d ON d.id = l.domain_id
|
|
2764
|
+
${where}
|
|
2765
|
+
ORDER BY l.created_at DESC
|
|
2766
|
+
LIMIT ?
|
|
2767
|
+
`, ...params);
|
|
2768
|
+
return rows.map(linkFromRow);
|
|
3019
2769
|
}
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
if (
|
|
3025
|
-
|
|
2770
|
+
async getLink(domainOrSlug, maybeSlug) {
|
|
2771
|
+
const slug = normalizeSlug(maybeSlug || domainOrSlug);
|
|
2772
|
+
const params = [slug];
|
|
2773
|
+
let domainClause = "";
|
|
2774
|
+
if (maybeSlug) {
|
|
2775
|
+
domainClause = "AND d.hostname = ?";
|
|
2776
|
+
params.push(normalizeHostname(domainOrSlug));
|
|
3026
2777
|
}
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
2778
|
+
const row = await this.pg.get(`
|
|
2779
|
+
SELECT l.*, d.hostname
|
|
2780
|
+
FROM links l
|
|
2781
|
+
JOIN domains d ON d.id = l.domain_id
|
|
2782
|
+
WHERE l.slug = ? ${domainClause}
|
|
2783
|
+
ORDER BY d.default_domain DESC, l.created_at ASC
|
|
2784
|
+
LIMIT 1
|
|
2785
|
+
`, ...params);
|
|
2786
|
+
return row ? linkFromRow(row) : null;
|
|
3031
2787
|
}
|
|
3032
|
-
|
|
3033
|
-
|
|
2788
|
+
async totalStats() {
|
|
2789
|
+
const row = await this.pg.get(`
|
|
2790
|
+
SELECT
|
|
2791
|
+
(SELECT COUNT(*)::int FROM domains) AS domains,
|
|
2792
|
+
(SELECT COUNT(*)::int FROM links) AS links,
|
|
2793
|
+
(SELECT COUNT(*)::int FROM clicks) AS clicks
|
|
2794
|
+
`);
|
|
2795
|
+
return row;
|
|
3034
2796
|
}
|
|
3035
|
-
|
|
3036
|
-
|
|
2797
|
+
async resolve(hostname2, slug) {
|
|
2798
|
+
const normalizedHost = normalizeHostname(hostname2);
|
|
2799
|
+
const normalizedSlug = normalizeSlug(slug);
|
|
2800
|
+
const row = await this.pg.get(`
|
|
2801
|
+
SELECT l.*, d.hostname
|
|
2802
|
+
FROM links l
|
|
2803
|
+
JOIN domains d ON d.id = l.domain_id
|
|
2804
|
+
WHERE d.hostname = ? AND l.slug = ?
|
|
2805
|
+
LIMIT 1
|
|
2806
|
+
`, normalizedHost, normalizedSlug);
|
|
2807
|
+
if (row)
|
|
2808
|
+
return linkFromRow(row);
|
|
2809
|
+
const fallback = await this.pg.get(`
|
|
2810
|
+
SELECT l.*, d.hostname
|
|
2811
|
+
FROM links l
|
|
2812
|
+
JOIN domains d ON d.id = l.domain_id
|
|
2813
|
+
WHERE d.default_domain = 1 AND l.slug = ?
|
|
2814
|
+
ORDER BY d.created_at ASC
|
|
2815
|
+
LIMIT 1
|
|
2816
|
+
`, normalizedSlug);
|
|
2817
|
+
return fallback ? linkFromRow(fallback) : null;
|
|
3037
2818
|
}
|
|
3038
|
-
|
|
3039
|
-
|
|
2819
|
+
async setLinkActive(domainOrSlug, maybeSlugOrActive, maybeActive) {
|
|
2820
|
+
const active = typeof maybeSlugOrActive === "boolean" ? maybeSlugOrActive : Boolean(maybeActive);
|
|
2821
|
+
const link = typeof maybeSlugOrActive === "boolean" ? await this.getLink(domainOrSlug) : await this.getLink(domainOrSlug, maybeSlugOrActive);
|
|
2822
|
+
if (!link)
|
|
2823
|
+
throw new Error("Link not found.");
|
|
2824
|
+
const timestamp = now();
|
|
2825
|
+
await this.pg.run(`
|
|
2826
|
+
UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
|
|
2827
|
+
`, active ? 1 : 0, timestamp, link.id);
|
|
2828
|
+
return await this.getLink(link.hostname, link.slug);
|
|
3040
2829
|
}
|
|
3041
|
-
|
|
3042
|
-
|
|
2830
|
+
async deleteLink(domainOrSlug, maybeSlug) {
|
|
2831
|
+
const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
|
|
2832
|
+
if (!link)
|
|
2833
|
+
throw new Error("Link not found.");
|
|
2834
|
+
await this.pg.run("DELETE FROM links WHERE id = ?", link.id);
|
|
2835
|
+
return link;
|
|
3043
2836
|
}
|
|
3044
|
-
|
|
3045
|
-
const
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
2837
|
+
async recordClick(link, input = {}) {
|
|
2838
|
+
const timestamp = now();
|
|
2839
|
+
const machineId = getMachineId();
|
|
2840
|
+
const ipHash = input.ip ? this.hashIp(input.ip) : null;
|
|
2841
|
+
const id = makeId("clk");
|
|
2842
|
+
await this.pg.run(`
|
|
2843
|
+
INSERT INTO clicks (
|
|
2844
|
+
id, link_id, domain_id, slug, clicked_at, ip_hash, user_agent, referer,
|
|
2845
|
+
country, city, metadata, machine_id, synced_at, created_at, updated_at
|
|
2846
|
+
)
|
|
2847
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
|
|
2848
|
+
`, 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);
|
|
2849
|
+
const row = await this.pg.get("SELECT * FROM clicks WHERE id = ?", id);
|
|
2850
|
+
return clickFromRow(row);
|
|
3054
2851
|
}
|
|
3055
|
-
|
|
3056
|
-
|
|
2852
|
+
async getStats(domainOrSlug, maybeSlug) {
|
|
2853
|
+
const link = maybeSlug ? await this.getLink(domainOrSlug, maybeSlug) : await this.getLink(domainOrSlug);
|
|
2854
|
+
if (!link)
|
|
2855
|
+
throw new Error("Link not found.");
|
|
2856
|
+
const summary = await this.pg.get(`
|
|
2857
|
+
SELECT COUNT(*)::int AS clicks, MAX(clicked_at) AS last_clicked_at FROM clicks WHERE link_id = ?
|
|
2858
|
+
`, link.id);
|
|
2859
|
+
const topReferrers = await this.pg.all(`
|
|
2860
|
+
SELECT referer, COUNT(*)::int AS clicks
|
|
2861
|
+
FROM clicks
|
|
2862
|
+
WHERE link_id = ?
|
|
2863
|
+
GROUP BY referer
|
|
2864
|
+
ORDER BY clicks DESC
|
|
2865
|
+
LIMIT 10
|
|
2866
|
+
`, link.id);
|
|
2867
|
+
const topUserAgents = await this.pg.all(`
|
|
2868
|
+
SELECT user_agent, COUNT(*)::int AS clicks
|
|
2869
|
+
FROM clicks
|
|
2870
|
+
WHERE link_id = ?
|
|
2871
|
+
GROUP BY user_agent
|
|
2872
|
+
ORDER BY clicks DESC
|
|
2873
|
+
LIMIT 10
|
|
2874
|
+
`, link.id);
|
|
2875
|
+
return {
|
|
2876
|
+
link,
|
|
2877
|
+
clicks: summary.clicks,
|
|
2878
|
+
last_clicked_at: nullableIso(summary.last_clicked_at),
|
|
2879
|
+
top_referrers: topReferrers,
|
|
2880
|
+
top_user_agents: topUserAgents
|
|
2881
|
+
};
|
|
3057
2882
|
}
|
|
3058
|
-
|
|
3059
|
-
return
|
|
2883
|
+
hashIp(ip) {
|
|
2884
|
+
return createHash("sha256").update(`${getClickSalt()}:${ip}`).digest("hex");
|
|
3060
2885
|
}
|
|
3061
|
-
|
|
3062
|
-
|
|
2886
|
+
async generateAvailableSlug(domainId, length) {
|
|
2887
|
+
for (let attempt = 0;attempt < 32; attempt += 1) {
|
|
2888
|
+
const slug = randomToken(length);
|
|
2889
|
+
const exists = await this.pg.get(`
|
|
2890
|
+
SELECT 1 FROM links WHERE domain_id = ? AND slug = ? LIMIT 1
|
|
2891
|
+
`, domainId, slug);
|
|
2892
|
+
if (!exists)
|
|
2893
|
+
return slug;
|
|
2894
|
+
}
|
|
2895
|
+
throw new Error("Could not generate an unused slug after 32 attempts.");
|
|
3063
2896
|
}
|
|
3064
|
-
return min;
|
|
3065
|
-
}
|
|
3066
|
-
function createSupportsColor(stream, options = {}) {
|
|
3067
|
-
const level = _supportsColor(stream, {
|
|
3068
|
-
streamIsTTY: stream && stream.isTTY,
|
|
3069
|
-
...options
|
|
3070
|
-
});
|
|
3071
|
-
return translateLevel(level);
|
|
3072
2897
|
}
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
2898
|
+
async function applyPostgresMigrations(connectionString, migrations, options = {}) {
|
|
2899
|
+
const Pool = await loadPgPool();
|
|
2900
|
+
const pool = new Pool(createPgPoolConfig(connectionString, options));
|
|
2901
|
+
const client = await pool.connect();
|
|
2902
|
+
const run = (sql, ...params) => client.query(toPostgresSql(sql), params);
|
|
2903
|
+
const get = async (sql, ...params) => {
|
|
2904
|
+
const result = await run(sql, ...params);
|
|
2905
|
+
return result.rows[0] ?? null;
|
|
2906
|
+
};
|
|
2907
|
+
const applied = [];
|
|
2908
|
+
const skipped = [];
|
|
2909
|
+
try {
|
|
2910
|
+
await run("BEGIN");
|
|
2911
|
+
await run(`
|
|
2912
|
+
SELECT pg_advisory_xact_lock(hashtext(?))
|
|
2913
|
+
`, "shortlinks:migrations");
|
|
2914
|
+
await run(`
|
|
2915
|
+
CREATE TABLE IF NOT EXISTS _shortlinks_migrations (
|
|
2916
|
+
id INTEGER PRIMARY KEY,
|
|
2917
|
+
service TEXT NOT NULL DEFAULT 'shortlinks',
|
|
2918
|
+
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
2919
|
+
)
|
|
2920
|
+
`);
|
|
2921
|
+
for (let i = 0;i < migrations.length; i += 1) {
|
|
2922
|
+
const id = i + 1;
|
|
2923
|
+
const existing = await get("SELECT id FROM _shortlinks_migrations WHERE id = ? LIMIT 1", id);
|
|
2924
|
+
if (existing) {
|
|
2925
|
+
skipped.push(id);
|
|
2926
|
+
continue;
|
|
2927
|
+
}
|
|
2928
|
+
await run(migrations[i]);
|
|
2929
|
+
await run("INSERT INTO _shortlinks_migrations (id, service, applied_at) VALUES (?, ?, now())", id, "shortlinks");
|
|
2930
|
+
applied.push(id);
|
|
2931
|
+
}
|
|
2932
|
+
await run("COMMIT");
|
|
2933
|
+
return { service: "shortlinks", applied, skipped };
|
|
2934
|
+
} catch (error) {
|
|
2935
|
+
try {
|
|
2936
|
+
await run("ROLLBACK");
|
|
2937
|
+
} catch {}
|
|
2938
|
+
throw error;
|
|
2939
|
+
} finally {
|
|
2940
|
+
client.release();
|
|
2941
|
+
await pool.end();
|
|
3084
2942
|
}
|
|
3085
|
-
const substringLength = substring.length;
|
|
3086
|
-
let endIndex = 0;
|
|
3087
|
-
let returnValue = "";
|
|
3088
|
-
do {
|
|
3089
|
-
returnValue += string.slice(endIndex, index) + substring + replacer;
|
|
3090
|
-
endIndex = index + substringLength;
|
|
3091
|
-
index = string.indexOf(substring, endIndex);
|
|
3092
|
-
} while (index !== -1);
|
|
3093
|
-
returnValue += string.slice(endIndex);
|
|
3094
|
-
return returnValue;
|
|
3095
|
-
}
|
|
3096
|
-
function stringEncaseCRLFWithFirstIndex(string, prefix, postfix, index) {
|
|
3097
|
-
let endIndex = 0;
|
|
3098
|
-
let returnValue = "";
|
|
3099
|
-
do {
|
|
3100
|
-
const gotCR = string[index - 1] === "\r";
|
|
3101
|
-
returnValue += string.slice(endIndex, gotCR ? index - 1 : index) + prefix + (gotCR ? `\r
|
|
3102
|
-
` : `
|
|
3103
|
-
`) + postfix;
|
|
3104
|
-
endIndex = index + 1;
|
|
3105
|
-
index = string.indexOf(`
|
|
3106
|
-
`, endIndex);
|
|
3107
|
-
} while (index !== -1);
|
|
3108
|
-
returnValue += string.slice(endIndex);
|
|
3109
|
-
return returnValue;
|
|
3110
2943
|
}
|
|
3111
2944
|
|
|
3112
|
-
//
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
];
|
|
3123
|
-
var styles2 = Object.create(null);
|
|
3124
|
-
var applyOptions = (object, options = {}) => {
|
|
3125
|
-
if (options.level && !(Number.isInteger(options.level) && options.level >= 0 && options.level <= 3)) {
|
|
3126
|
-
throw new Error("The `level` option should be an integer from 0 to 3");
|
|
2945
|
+
// src/store.ts
|
|
2946
|
+
import { createHash as createHash2 } from "crypto";
|
|
2947
|
+
function parseJsonObject2(value) {
|
|
2948
|
+
if (!value)
|
|
2949
|
+
return {};
|
|
2950
|
+
try {
|
|
2951
|
+
const parsed = JSON.parse(value);
|
|
2952
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
2953
|
+
} catch {
|
|
2954
|
+
return {};
|
|
3127
2955
|
}
|
|
3128
|
-
const colorLevel = stdoutColor ? stdoutColor.level : 0;
|
|
3129
|
-
object.level = options.level === undefined ? colorLevel : options.level;
|
|
3130
|
-
};
|
|
3131
|
-
var chalkFactory = (options) => {
|
|
3132
|
-
const chalk = (...strings) => strings.join(" ");
|
|
3133
|
-
applyOptions(chalk, options);
|
|
3134
|
-
Object.setPrototypeOf(chalk, createChalk.prototype);
|
|
3135
|
-
return chalk;
|
|
3136
|
-
};
|
|
3137
|
-
function createChalk(options) {
|
|
3138
|
-
return chalkFactory(options);
|
|
3139
2956
|
}
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
Object.defineProperty(this, styleName, { value: builder });
|
|
3146
|
-
return builder;
|
|
3147
|
-
}
|
|
2957
|
+
function domainFromRow2(row) {
|
|
2958
|
+
return {
|
|
2959
|
+
...row,
|
|
2960
|
+
default_domain: Boolean(row.default_domain),
|
|
2961
|
+
metadata: parseJsonObject2(row.metadata)
|
|
3148
2962
|
};
|
|
3149
2963
|
}
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
if (model === "rgb") {
|
|
3159
|
-
if (level === "ansi16m") {
|
|
3160
|
-
return ansi_styles_default[type].ansi16m(...arguments_);
|
|
3161
|
-
}
|
|
3162
|
-
if (level === "ansi256") {
|
|
3163
|
-
return ansi_styles_default[type].ansi256(ansi_styles_default.rgbToAnsi256(...arguments_));
|
|
3164
|
-
}
|
|
3165
|
-
return ansi_styles_default[type].ansi(ansi_styles_default.rgbToAnsi(...arguments_));
|
|
3166
|
-
}
|
|
3167
|
-
if (model === "hex") {
|
|
3168
|
-
return getModelAnsi("rgb", level, type, ...ansi_styles_default.hexToRgb(...arguments_));
|
|
3169
|
-
}
|
|
3170
|
-
return ansi_styles_default[type][model](...arguments_);
|
|
3171
|
-
};
|
|
3172
|
-
var usedModels = ["rgb", "hex", "ansi256"];
|
|
3173
|
-
for (const model of usedModels) {
|
|
3174
|
-
styles2[model] = {
|
|
3175
|
-
get() {
|
|
3176
|
-
const { level } = this;
|
|
3177
|
-
return function(...arguments_) {
|
|
3178
|
-
const styler = createStyler(getModelAnsi(model, levelMapping[level], "color", ...arguments_), ansi_styles_default.color.close, this[STYLER]);
|
|
3179
|
-
return createBuilder(this, styler, this[IS_EMPTY]);
|
|
3180
|
-
};
|
|
3181
|
-
}
|
|
3182
|
-
};
|
|
3183
|
-
const bgModel = "bg" + model[0].toUpperCase() + model.slice(1);
|
|
3184
|
-
styles2[bgModel] = {
|
|
3185
|
-
get() {
|
|
3186
|
-
const { level } = this;
|
|
3187
|
-
return function(...arguments_) {
|
|
3188
|
-
const styler = createStyler(getModelAnsi(model, levelMapping[level], "bgColor", ...arguments_), ansi_styles_default.bgColor.close, this[STYLER]);
|
|
3189
|
-
return createBuilder(this, styler, this[IS_EMPTY]);
|
|
3190
|
-
};
|
|
3191
|
-
}
|
|
2964
|
+
function linkFromRow2(row) {
|
|
2965
|
+
const config = loadConfig();
|
|
2966
|
+
const publicBaseUrl = config.defaultDomain === row.hostname ? config.publicBaseUrl : undefined;
|
|
2967
|
+
return {
|
|
2968
|
+
...row,
|
|
2969
|
+
active: Boolean(row.active),
|
|
2970
|
+
metadata: parseJsonObject2(row.metadata),
|
|
2971
|
+
short_url: formatShortUrl(row.hostname, row.slug, publicBaseUrl)
|
|
3192
2972
|
};
|
|
3193
2973
|
}
|
|
3194
|
-
|
|
3195
|
-
...styles2,
|
|
3196
|
-
level: {
|
|
3197
|
-
enumerable: true,
|
|
3198
|
-
get() {
|
|
3199
|
-
return this[GENERATOR].level;
|
|
3200
|
-
},
|
|
3201
|
-
set(level) {
|
|
3202
|
-
this[GENERATOR].level = level;
|
|
3203
|
-
}
|
|
3204
|
-
}
|
|
3205
|
-
});
|
|
3206
|
-
var createStyler = (open, close, parent) => {
|
|
3207
|
-
let openAll;
|
|
3208
|
-
let closeAll;
|
|
3209
|
-
if (parent === undefined) {
|
|
3210
|
-
openAll = open;
|
|
3211
|
-
closeAll = close;
|
|
3212
|
-
} else {
|
|
3213
|
-
openAll = parent.openAll + open;
|
|
3214
|
-
closeAll = close + parent.closeAll;
|
|
3215
|
-
}
|
|
2974
|
+
function clickFromRow2(row) {
|
|
3216
2975
|
return {
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
openAll,
|
|
3220
|
-
closeAll,
|
|
3221
|
-
parent
|
|
2976
|
+
...row,
|
|
2977
|
+
metadata: parseJsonObject2(row.metadata)
|
|
3222
2978
|
};
|
|
3223
|
-
};
|
|
3224
|
-
var createBuilder = (self, _styler, _isEmpty) => {
|
|
3225
|
-
const builder = (...arguments_) => applyStyle(builder, arguments_.length === 1 ? "" + arguments_[0] : arguments_.join(" "));
|
|
3226
|
-
Object.setPrototypeOf(builder, proto);
|
|
3227
|
-
builder[GENERATOR] = self;
|
|
3228
|
-
builder[STYLER] = _styler;
|
|
3229
|
-
builder[IS_EMPTY] = _isEmpty;
|
|
3230
|
-
return builder;
|
|
3231
|
-
};
|
|
3232
|
-
var applyStyle = (self, string) => {
|
|
3233
|
-
if (self.level <= 0 || !string) {
|
|
3234
|
-
return self[IS_EMPTY] ? "" : string;
|
|
3235
|
-
}
|
|
3236
|
-
let styler = self[STYLER];
|
|
3237
|
-
if (styler === undefined) {
|
|
3238
|
-
return string;
|
|
3239
|
-
}
|
|
3240
|
-
const { openAll, closeAll } = styler;
|
|
3241
|
-
if (string.includes("\x1B")) {
|
|
3242
|
-
while (styler !== undefined) {
|
|
3243
|
-
string = stringReplaceAll(string, styler.close, styler.open);
|
|
3244
|
-
styler = styler.parent;
|
|
3245
|
-
}
|
|
3246
|
-
}
|
|
3247
|
-
const lfIndex = string.indexOf(`
|
|
3248
|
-
`);
|
|
3249
|
-
if (lfIndex !== -1) {
|
|
3250
|
-
string = stringEncaseCRLFWithFirstIndex(string, closeAll, openAll, lfIndex);
|
|
3251
|
-
}
|
|
3252
|
-
return openAll + string + closeAll;
|
|
3253
|
-
};
|
|
3254
|
-
Object.defineProperties(createChalk.prototype, styles2);
|
|
3255
|
-
var chalk = createChalk();
|
|
3256
|
-
var chalkStderr = createChalk({ level: stderrColor ? stderrColor.level : 0 });
|
|
3257
|
-
var source_default = chalk;
|
|
3258
|
-
|
|
3259
|
-
// src/cli/index.ts
|
|
3260
|
-
import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
|
|
3261
|
-
import { dirname as dirname3, join as join6 } from "path";
|
|
3262
|
-
import { fileURLToPath } from "url";
|
|
3263
|
-
import { spawnSync as spawnSync3 } from "child_process";
|
|
3264
|
-
|
|
3265
|
-
// src/store.ts
|
|
3266
|
-
import { createHash } from "crypto";
|
|
3267
|
-
|
|
3268
|
-
// src/database.ts
|
|
3269
|
-
import { Database } from "bun:sqlite";
|
|
3270
|
-
import { mkdirSync as mkdirSync2 } from "fs";
|
|
3271
|
-
import { dirname as dirname2 } from "path";
|
|
3272
|
-
|
|
3273
|
-
// src/config.ts
|
|
3274
|
-
import { existsSync as existsSync2, linkSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
3275
|
-
import { randomBytes } from "crypto";
|
|
3276
|
-
import { homedir as homedir2 } from "os";
|
|
3277
|
-
import { dirname, join as join2, resolve } from "path";
|
|
3278
|
-
var SERVICE_NAME = "shortlinks";
|
|
3279
|
-
var DEFAULT_DATA_DIR = join2(homedir2(), ".hasna", SERVICE_NAME);
|
|
3280
|
-
function getDataDir() {
|
|
3281
|
-
return resolve(process.env.SHORTLINKS_HOME || DEFAULT_DATA_DIR);
|
|
3282
|
-
}
|
|
3283
|
-
function ensureDataDir() {
|
|
3284
|
-
const dir = getDataDir();
|
|
3285
|
-
mkdirSync(dir, { recursive: true });
|
|
3286
|
-
return dir;
|
|
3287
|
-
}
|
|
3288
|
-
function getConfigPath() {
|
|
3289
|
-
return join2(ensureDataDir(), "config.json");
|
|
3290
|
-
}
|
|
3291
|
-
function getClickSaltPath() {
|
|
3292
|
-
return join2(ensureDataDir(), "click-salt");
|
|
3293
|
-
}
|
|
3294
|
-
function getDatabasePath(explicitPath) {
|
|
3295
|
-
if (explicitPath)
|
|
3296
|
-
return resolve(explicitPath);
|
|
3297
|
-
if (process.env.SHORTLINKS_DB)
|
|
3298
|
-
return resolve(process.env.SHORTLINKS_DB);
|
|
3299
|
-
return join2(ensureDataDir(), `${SERVICE_NAME}.db`);
|
|
3300
2979
|
}
|
|
3301
|
-
function
|
|
2980
|
+
function validateDestinationUrl2(url) {
|
|
2981
|
+
let parsed;
|
|
3302
2982
|
try {
|
|
3303
|
-
|
|
3304
|
-
return saved || null;
|
|
2983
|
+
parsed = new URL(url);
|
|
3305
2984
|
} catch {
|
|
3306
|
-
|
|
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);
|
|
2985
|
+
throw new Error(`Invalid destination URL: ${url}`);
|
|
3344
2986
|
}
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
const path = getConfigPath();
|
|
3348
|
-
if (!existsSync2(path))
|
|
3349
|
-
return {};
|
|
3350
|
-
try {
|
|
3351
|
-
const parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
3352
|
-
return parsed && typeof parsed === "object" ? parsed : {};
|
|
3353
|
-
} catch {
|
|
3354
|
-
return {};
|
|
2987
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
2988
|
+
throw new Error("Destination URL must start with http:// or https://.");
|
|
3355
2989
|
}
|
|
2990
|
+
return parsed.toString();
|
|
3356
2991
|
}
|
|
3357
|
-
function
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
}
|
|
3363
|
-
|
|
3364
|
-
const next = {
|
|
3365
|
-
...loadConfig(),
|
|
3366
|
-
...patch,
|
|
3367
|
-
cloudflare: {
|
|
3368
|
-
...loadConfig().cloudflare,
|
|
3369
|
-
...patch.cloudflare
|
|
3370
|
-
}
|
|
3371
|
-
};
|
|
3372
|
-
saveConfig(next);
|
|
3373
|
-
return next;
|
|
2992
|
+
function isoOrNull2(input) {
|
|
2993
|
+
if (!input)
|
|
2994
|
+
return null;
|
|
2995
|
+
const date = new Date(input);
|
|
2996
|
+
if (Number.isNaN(date.getTime()))
|
|
2997
|
+
throw new Error(`Invalid date: ${input}`);
|
|
2998
|
+
return date.toISOString();
|
|
3374
2999
|
}
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
let hostname;
|
|
3381
|
-
try {
|
|
3382
|
-
hostname = new URL(withProtocol).hostname;
|
|
3383
|
-
} catch {
|
|
3384
|
-
throw new Error(`Invalid domain: ${input}`);
|
|
3385
|
-
}
|
|
3386
|
-
hostname = hostname.replace(/\.$/, "");
|
|
3387
|
-
const labels = hostname.split(".");
|
|
3388
|
-
const labelsAreValid = labels.every((label) => label.length >= 1 && label.length <= 63 && /^[a-z0-9-]+$/.test(label) && !label.startsWith("-") && !label.endsWith("-"));
|
|
3389
|
-
if (hostname.length > 253 || !labelsAreValid) {
|
|
3390
|
-
throw new Error(`Invalid domain: ${input}`);
|
|
3000
|
+
|
|
3001
|
+
class ShortlinksStore {
|
|
3002
|
+
database;
|
|
3003
|
+
constructor(dbPath) {
|
|
3004
|
+
this.database = new ShortlinksDatabase(dbPath);
|
|
3391
3005
|
}
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
function formatShortUrl(hostname, slug, publicBaseUrl) {
|
|
3395
|
-
if (publicBaseUrl) {
|
|
3396
|
-
const base = publicBaseUrl.endsWith("/") ? publicBaseUrl : `${publicBaseUrl}/`;
|
|
3397
|
-
return new URL(slug, base).toString();
|
|
3398
|
-
}
|
|
3399
|
-
return `https://${hostname}/${slug}`;
|
|
3400
|
-
}
|
|
3401
|
-
|
|
3402
|
-
// src/database.ts
|
|
3403
|
-
function now2() {
|
|
3404
|
-
return new Date().toISOString();
|
|
3405
|
-
}
|
|
3406
|
-
function makeId(prefix) {
|
|
3407
|
-
const bytes = crypto.getRandomValues(new Uint8Array(12));
|
|
3408
|
-
const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
3409
|
-
return `${prefix}_${hex}`;
|
|
3410
|
-
}
|
|
3411
|
-
var SQLITE_MIGRATIONS = [
|
|
3412
|
-
`
|
|
3413
|
-
CREATE TABLE IF NOT EXISTS domains (
|
|
3414
|
-
id TEXT PRIMARY KEY,
|
|
3415
|
-
hostname TEXT NOT NULL UNIQUE,
|
|
3416
|
-
provider TEXT NOT NULL DEFAULT 'manual',
|
|
3417
|
-
default_domain INTEGER NOT NULL DEFAULT 0,
|
|
3418
|
-
cloudflare_zone_id TEXT,
|
|
3419
|
-
cloudflare_account_id TEXT,
|
|
3420
|
-
cloudflare_worker_name TEXT,
|
|
3421
|
-
origin_url TEXT,
|
|
3422
|
-
notes TEXT,
|
|
3423
|
-
metadata TEXT NOT NULL DEFAULT '{}',
|
|
3424
|
-
machine_id TEXT,
|
|
3425
|
-
synced_at TEXT,
|
|
3426
|
-
created_at TEXT NOT NULL,
|
|
3427
|
-
updated_at TEXT NOT NULL
|
|
3428
|
-
);
|
|
3429
|
-
|
|
3430
|
-
CREATE TABLE IF NOT EXISTS links (
|
|
3431
|
-
id TEXT PRIMARY KEY,
|
|
3432
|
-
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
3433
|
-
slug TEXT NOT NULL,
|
|
3434
|
-
destination_url TEXT NOT NULL,
|
|
3435
|
-
title TEXT,
|
|
3436
|
-
active INTEGER NOT NULL DEFAULT 1,
|
|
3437
|
-
expires_at TEXT,
|
|
3438
|
-
metadata TEXT NOT NULL DEFAULT '{}',
|
|
3439
|
-
machine_id TEXT,
|
|
3440
|
-
synced_at TEXT,
|
|
3441
|
-
created_at TEXT NOT NULL,
|
|
3442
|
-
updated_at TEXT NOT NULL,
|
|
3443
|
-
UNIQUE(domain_id, slug)
|
|
3444
|
-
);
|
|
3445
|
-
|
|
3446
|
-
CREATE TABLE IF NOT EXISTS clicks (
|
|
3447
|
-
id TEXT PRIMARY KEY,
|
|
3448
|
-
link_id TEXT NOT NULL REFERENCES links(id) ON DELETE CASCADE,
|
|
3449
|
-
domain_id TEXT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
|
3450
|
-
slug TEXT NOT NULL,
|
|
3451
|
-
clicked_at TEXT NOT NULL,
|
|
3452
|
-
ip_hash TEXT,
|
|
3453
|
-
user_agent TEXT,
|
|
3454
|
-
referer TEXT,
|
|
3455
|
-
country TEXT,
|
|
3456
|
-
city TEXT,
|
|
3457
|
-
metadata TEXT NOT NULL DEFAULT '{}',
|
|
3458
|
-
machine_id TEXT,
|
|
3459
|
-
synced_at TEXT,
|
|
3460
|
-
created_at TEXT NOT NULL,
|
|
3461
|
-
updated_at TEXT NOT NULL
|
|
3462
|
-
);
|
|
3463
|
-
|
|
3464
|
-
CREATE INDEX IF NOT EXISTS idx_domains_hostname ON domains(hostname);
|
|
3465
|
-
CREATE INDEX IF NOT EXISTS idx_domains_default ON domains(default_domain);
|
|
3466
|
-
CREATE INDEX IF NOT EXISTS idx_links_domain_slug ON links(domain_id, slug);
|
|
3467
|
-
CREATE INDEX IF NOT EXISTS idx_links_active ON links(active);
|
|
3468
|
-
CREATE INDEX IF NOT EXISTS idx_links_updated ON links(updated_at);
|
|
3469
|
-
CREATE INDEX IF NOT EXISTS idx_clicks_link ON clicks(link_id);
|
|
3470
|
-
CREATE INDEX IF NOT EXISTS idx_clicks_domain ON clicks(domain_id);
|
|
3471
|
-
CREATE INDEX IF NOT EXISTS idx_clicks_clicked_at ON clicks(clicked_at);
|
|
3472
|
-
CREATE INDEX IF NOT EXISTS idx_clicks_updated ON clicks(updated_at);
|
|
3473
|
-
`
|
|
3474
|
-
];
|
|
3475
|
-
|
|
3476
|
-
class ShortlinksDatabase {
|
|
3477
|
-
db;
|
|
3478
|
-
path;
|
|
3479
|
-
constructor(path) {
|
|
3480
|
-
this.path = getDatabasePath(path);
|
|
3481
|
-
mkdirSync2(dirname2(this.path), { recursive: true });
|
|
3482
|
-
this.db = new Database(this.path);
|
|
3483
|
-
this.db.exec("PRAGMA foreign_keys = ON;");
|
|
3484
|
-
this.applyMigrations();
|
|
3485
|
-
}
|
|
3486
|
-
close() {
|
|
3487
|
-
this.db.close();
|
|
3488
|
-
}
|
|
3489
|
-
applyMigrations() {
|
|
3490
|
-
this.db.exec(`
|
|
3491
|
-
CREATE TABLE IF NOT EXISTS _migrations (
|
|
3492
|
-
id INTEGER PRIMARY KEY,
|
|
3493
|
-
applied_at TEXT NOT NULL
|
|
3494
|
-
);
|
|
3495
|
-
`);
|
|
3496
|
-
for (let i = 0;i < SQLITE_MIGRATIONS.length; i += 1) {
|
|
3497
|
-
const id = i + 1;
|
|
3498
|
-
const applied = this.db.query("SELECT id FROM _migrations WHERE id = ?").get(id);
|
|
3499
|
-
if (applied)
|
|
3500
|
-
continue;
|
|
3501
|
-
const migration = SQLITE_MIGRATIONS[i];
|
|
3502
|
-
const apply = this.db.transaction(() => {
|
|
3503
|
-
this.db.exec(migration);
|
|
3504
|
-
this.db.query("INSERT INTO _migrations (id, applied_at) VALUES (?, ?)").run(id, now2());
|
|
3505
|
-
});
|
|
3506
|
-
apply();
|
|
3507
|
-
}
|
|
3508
|
-
}
|
|
3509
|
-
}
|
|
3510
|
-
|
|
3511
|
-
// src/machine.ts
|
|
3512
|
-
import { existsSync as existsSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
3513
|
-
import { hostname } from "os";
|
|
3514
|
-
import { join as join3 } from "path";
|
|
3515
|
-
|
|
3516
|
-
// src/slug.ts
|
|
3517
|
-
import { randomBytes as randomBytes2 } from "crypto";
|
|
3518
|
-
var SLUG_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
3519
|
-
var DEFAULT_SLUG_LENGTH = 7;
|
|
3520
|
-
function randomToken(length = DEFAULT_SLUG_LENGTH) {
|
|
3521
|
-
if (length < 1 || length > 128)
|
|
3522
|
-
throw new Error("Token length must be between 1 and 128.");
|
|
3523
|
-
const bytes = randomBytes2(length);
|
|
3524
|
-
let out = "";
|
|
3525
|
-
for (let i = 0;i < length; i += 1) {
|
|
3526
|
-
out += SLUG_ALPHABET[bytes[i] % SLUG_ALPHABET.length];
|
|
3527
|
-
}
|
|
3528
|
-
return out;
|
|
3529
|
-
}
|
|
3530
|
-
function normalizeSlug(slug) {
|
|
3531
|
-
const normalized = slug.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
|
3532
|
-
if (!normalized)
|
|
3533
|
-
throw new Error("Slug is required.");
|
|
3534
|
-
if (!/^[A-Za-z0-9_-]{1,96}$/.test(normalized)) {
|
|
3535
|
-
throw new Error("Slug can only contain letters, numbers, underscores, and dashes.");
|
|
3536
|
-
}
|
|
3537
|
-
return normalized;
|
|
3538
|
-
}
|
|
3539
|
-
|
|
3540
|
-
// src/machine.ts
|
|
3541
|
-
function getMachineId() {
|
|
3542
|
-
const path = join3(ensureDataDir(), "machine-id");
|
|
3543
|
-
if (existsSync3(path)) {
|
|
3544
|
-
const existing = readFileSync2(path, "utf-8").trim();
|
|
3545
|
-
if (existing)
|
|
3546
|
-
return existing;
|
|
3547
|
-
}
|
|
3548
|
-
const safeHost = hostname().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
3549
|
-
const id = `${safeHost || "machine"}-${randomToken(8).toLowerCase()}`;
|
|
3550
|
-
writeFileSync2(path, `${id}
|
|
3551
|
-
`);
|
|
3552
|
-
return id;
|
|
3553
|
-
}
|
|
3554
|
-
|
|
3555
|
-
// src/store.ts
|
|
3556
|
-
function parseJsonObject2(value) {
|
|
3557
|
-
if (!value)
|
|
3558
|
-
return {};
|
|
3559
|
-
try {
|
|
3560
|
-
const parsed = JSON.parse(value);
|
|
3561
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
3562
|
-
} catch {
|
|
3563
|
-
return {};
|
|
3564
|
-
}
|
|
3565
|
-
}
|
|
3566
|
-
function domainFromRow(row) {
|
|
3567
|
-
return {
|
|
3568
|
-
...row,
|
|
3569
|
-
default_domain: Boolean(row.default_domain),
|
|
3570
|
-
metadata: parseJsonObject2(row.metadata)
|
|
3571
|
-
};
|
|
3572
|
-
}
|
|
3573
|
-
function linkFromRow(row) {
|
|
3574
|
-
const config = loadConfig();
|
|
3575
|
-
const publicBaseUrl = config.defaultDomain === row.hostname ? config.publicBaseUrl : undefined;
|
|
3576
|
-
return {
|
|
3577
|
-
...row,
|
|
3578
|
-
active: Boolean(row.active),
|
|
3579
|
-
metadata: parseJsonObject2(row.metadata),
|
|
3580
|
-
short_url: formatShortUrl(row.hostname, row.slug, publicBaseUrl)
|
|
3581
|
-
};
|
|
3582
|
-
}
|
|
3583
|
-
function clickFromRow(row) {
|
|
3584
|
-
return {
|
|
3585
|
-
...row,
|
|
3586
|
-
metadata: parseJsonObject2(row.metadata)
|
|
3587
|
-
};
|
|
3588
|
-
}
|
|
3589
|
-
function validateDestinationUrl(url) {
|
|
3590
|
-
let parsed;
|
|
3591
|
-
try {
|
|
3592
|
-
parsed = new URL(url);
|
|
3593
|
-
} catch {
|
|
3594
|
-
throw new Error(`Invalid destination URL: ${url}`);
|
|
3595
|
-
}
|
|
3596
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
3597
|
-
throw new Error("Destination URL must start with http:// or https://.");
|
|
3598
|
-
}
|
|
3599
|
-
return parsed.toString();
|
|
3600
|
-
}
|
|
3601
|
-
function isoOrNull(input) {
|
|
3602
|
-
if (!input)
|
|
3603
|
-
return null;
|
|
3604
|
-
const date = new Date(input);
|
|
3605
|
-
if (Number.isNaN(date.getTime()))
|
|
3606
|
-
throw new Error(`Invalid date: ${input}`);
|
|
3607
|
-
return date.toISOString();
|
|
3608
|
-
}
|
|
3609
|
-
|
|
3610
|
-
class ShortlinksStore {
|
|
3611
|
-
database;
|
|
3612
|
-
constructor(dbPath) {
|
|
3613
|
-
this.database = new ShortlinksDatabase(dbPath);
|
|
3614
|
-
}
|
|
3615
|
-
close() {
|
|
3616
|
-
this.database.close();
|
|
3006
|
+
close() {
|
|
3007
|
+
this.database.close();
|
|
3617
3008
|
}
|
|
3618
3009
|
addDomain(input) {
|
|
3619
3010
|
const hostname2 = normalizeHostname(input.hostname);
|
|
3620
|
-
const timestamp =
|
|
3011
|
+
const timestamp = now();
|
|
3621
3012
|
const machineId = getMachineId();
|
|
3622
3013
|
const existing = this.getDomain(hostname2);
|
|
3623
3014
|
const id = existing?.id || makeId("dom");
|
|
@@ -3651,14 +3042,14 @@ class ShortlinksStore {
|
|
|
3651
3042
|
SELECT * FROM domains
|
|
3652
3043
|
ORDER BY default_domain DESC, hostname ASC
|
|
3653
3044
|
`).all();
|
|
3654
|
-
return rows.map(
|
|
3045
|
+
return rows.map(domainFromRow2);
|
|
3655
3046
|
}
|
|
3656
3047
|
getDomain(hostnameOrId) {
|
|
3657
3048
|
const normalized = hostnameOrId.includes(".") || hostnameOrId.includes("://") ? normalizeHostname(hostnameOrId) : hostnameOrId;
|
|
3658
3049
|
const row = this.database.db.query(`
|
|
3659
3050
|
SELECT * FROM domains WHERE hostname = ? OR id = ? LIMIT 1
|
|
3660
3051
|
`).get(normalized, hostnameOrId);
|
|
3661
|
-
return row ?
|
|
3052
|
+
return row ? domainFromRow2(row) : null;
|
|
3662
3053
|
}
|
|
3663
3054
|
getDefaultDomain() {
|
|
3664
3055
|
const config = loadConfig();
|
|
@@ -3670,17 +3061,17 @@ class ShortlinksStore {
|
|
|
3670
3061
|
const row = this.database.db.query(`
|
|
3671
3062
|
SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
|
|
3672
3063
|
`).get();
|
|
3673
|
-
return row ?
|
|
3064
|
+
return row ? domainFromRow2(row) : null;
|
|
3674
3065
|
}
|
|
3675
3066
|
createLink(input) {
|
|
3676
3067
|
const domain = input.domain ? this.getDomain(input.domain) : this.getDefaultDomain();
|
|
3677
3068
|
if (!domain) {
|
|
3678
3069
|
throw new Error("No domain configured. Run `shortlinks domain add <domain> --default` first.");
|
|
3679
3070
|
}
|
|
3680
|
-
const destinationUrl =
|
|
3681
|
-
const timestamp =
|
|
3071
|
+
const destinationUrl = validateDestinationUrl2(input.destinationUrl);
|
|
3072
|
+
const timestamp = now();
|
|
3682
3073
|
const machineId = getMachineId();
|
|
3683
|
-
const expiresAt =
|
|
3074
|
+
const expiresAt = isoOrNull2(input.expiresAt);
|
|
3684
3075
|
const slug = input.slug ? normalizeSlug(input.slug) : this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
|
|
3685
3076
|
try {
|
|
3686
3077
|
this.database.db.query(`
|
|
@@ -3718,7 +3109,7 @@ class ShortlinksStore {
|
|
|
3718
3109
|
ORDER BY l.created_at DESC
|
|
3719
3110
|
LIMIT ?
|
|
3720
3111
|
`).all(...params);
|
|
3721
|
-
return rows.map(
|
|
3112
|
+
return rows.map(linkFromRow2);
|
|
3722
3113
|
}
|
|
3723
3114
|
getLink(domainOrSlug, maybeSlug) {
|
|
3724
3115
|
const slug = normalizeSlug(maybeSlug || domainOrSlug);
|
|
@@ -3736,7 +3127,7 @@ class ShortlinksStore {
|
|
|
3736
3127
|
ORDER BY d.default_domain DESC, l.created_at ASC
|
|
3737
3128
|
LIMIT 1
|
|
3738
3129
|
`).get(...params);
|
|
3739
|
-
return row ?
|
|
3130
|
+
return row ? linkFromRow2(row) : null;
|
|
3740
3131
|
}
|
|
3741
3132
|
resolve(hostname2, slug) {
|
|
3742
3133
|
const normalizedSlug = normalizeSlug(slug);
|
|
@@ -3749,7 +3140,7 @@ class ShortlinksStore {
|
|
|
3749
3140
|
LIMIT 1
|
|
3750
3141
|
`).get(normalizedHost, normalizedSlug);
|
|
3751
3142
|
if (row)
|
|
3752
|
-
return
|
|
3143
|
+
return linkFromRow2(row);
|
|
3753
3144
|
const fallback = this.getDefaultDomain();
|
|
3754
3145
|
if (!fallback || fallback.hostname === normalizedHost)
|
|
3755
3146
|
return null;
|
|
@@ -3760,7 +3151,7 @@ class ShortlinksStore {
|
|
|
3760
3151
|
const link = typeof maybeSlugOrActive === "boolean" ? this.getLink(domainOrSlug) : this.getLink(domainOrSlug, maybeSlugOrActive);
|
|
3761
3152
|
if (!link)
|
|
3762
3153
|
throw new Error("Link not found.");
|
|
3763
|
-
const timestamp =
|
|
3154
|
+
const timestamp = now();
|
|
3764
3155
|
this.database.db.query(`
|
|
3765
3156
|
UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
|
|
3766
3157
|
`).run(active ? 1 : 0, timestamp, link.id);
|
|
@@ -3774,7 +3165,7 @@ class ShortlinksStore {
|
|
|
3774
3165
|
return link;
|
|
3775
3166
|
}
|
|
3776
3167
|
recordClick(link, input = {}) {
|
|
3777
|
-
const timestamp =
|
|
3168
|
+
const timestamp = now();
|
|
3778
3169
|
const machineId = getMachineId();
|
|
3779
3170
|
const ipHash = input.ip ? this.hashIp(input.ip) : null;
|
|
3780
3171
|
const id = makeId("clk");
|
|
@@ -3786,7 +3177,7 @@ class ShortlinksStore {
|
|
|
3786
3177
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
|
|
3787
3178
|
`).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);
|
|
3788
3179
|
const row = this.database.db.query("SELECT * FROM clicks WHERE id = ?").get(id);
|
|
3789
|
-
return
|
|
3180
|
+
return clickFromRow2(row);
|
|
3790
3181
|
}
|
|
3791
3182
|
getStats(domainOrSlug, maybeSlug) {
|
|
3792
3183
|
const link = maybeSlug ? this.getLink(domainOrSlug, maybeSlug) : this.getLink(domainOrSlug);
|
|
@@ -3840,562 +3231,1647 @@ class ShortlinksStore {
|
|
|
3840
3231
|
throw new Error("Could not generate an unused slug after 32 attempts.");
|
|
3841
3232
|
}
|
|
3842
3233
|
hashIp(ip) {
|
|
3843
|
-
return
|
|
3234
|
+
return createHash2("sha256").update(`${getClickSalt()}:${ip}`).digest("hex");
|
|
3844
3235
|
}
|
|
3845
3236
|
}
|
|
3846
3237
|
|
|
3847
|
-
// src/
|
|
3848
|
-
|
|
3849
|
-
function
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
3857
|
-
} catch {
|
|
3858
|
-
return {};
|
|
3859
|
-
}
|
|
3238
|
+
// src/server.ts
|
|
3239
|
+
var REDIRECT_ALLOW_HEADER = "GET, HEAD";
|
|
3240
|
+
function json(data, status = 200, headers) {
|
|
3241
|
+
const responseHeaders = new Headers(headers);
|
|
3242
|
+
responseHeaders.set("content-type", "application/json; charset=utf-8");
|
|
3243
|
+
return new Response(JSON.stringify(data, null, 2), {
|
|
3244
|
+
status,
|
|
3245
|
+
headers: responseHeaders
|
|
3246
|
+
});
|
|
3860
3247
|
}
|
|
3861
|
-
function
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
return
|
|
3248
|
+
function getHost(request, fallback) {
|
|
3249
|
+
const forwarded = request.headers.get("x-forwarded-host");
|
|
3250
|
+
const host = forwarded || request.headers.get("host") || fallback || "";
|
|
3251
|
+
return host.split(",")[0].trim().split(":")[0];
|
|
3865
3252
|
}
|
|
3866
|
-
function
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3253
|
+
function getClientIp(request) {
|
|
3254
|
+
const forwarded = request.headers.get("x-forwarded-for");
|
|
3255
|
+
if (forwarded)
|
|
3256
|
+
return forwarded.split(",")[0].trim();
|
|
3257
|
+
return request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip");
|
|
3870
3258
|
}
|
|
3871
|
-
function
|
|
3872
|
-
return
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3259
|
+
function isExpired(link) {
|
|
3260
|
+
return Boolean(link.expires_at && new Date(link.expires_at).getTime() <= Date.now());
|
|
3261
|
+
}
|
|
3262
|
+
function logRecordClickError(link) {
|
|
3263
|
+
console.error(`[shortlinks] Click analytics recording failed for ${link.hostname}/${link.slug}.`);
|
|
3264
|
+
}
|
|
3265
|
+
function createShortlinksHandler(options = {}) {
|
|
3266
|
+
const store = options.store || new ShortlinksStore(options.dbPath);
|
|
3267
|
+
const redirectStatus = options.redirectStatus || 302;
|
|
3268
|
+
return async (request) => {
|
|
3269
|
+
const url = new URL(request.url);
|
|
3270
|
+
if (url.pathname === "/healthz") {
|
|
3271
|
+
return json({ ok: true, service: "shortlinks", stats: await store.totalStats() });
|
|
3272
|
+
}
|
|
3273
|
+
if (url.pathname === "/" || url.pathname === "") {
|
|
3274
|
+
return json({ service: "shortlinks", ok: true });
|
|
3275
|
+
}
|
|
3276
|
+
let slug = "";
|
|
3277
|
+
try {
|
|
3278
|
+
slug = decodeURIComponent(url.pathname.replace(/^\/+/, "").split("/")[0] || "");
|
|
3279
|
+
} catch {
|
|
3280
|
+
return json({ error: "Invalid slug." }, 400);
|
|
3281
|
+
}
|
|
3282
|
+
if (!slug)
|
|
3283
|
+
return json({ error: "Missing slug." }, 404);
|
|
3284
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
3285
|
+
return json({ error: "Method not allowed." }, 405, { allow: REDIRECT_ALLOW_HEADER });
|
|
3286
|
+
}
|
|
3287
|
+
const host = getHost(request, options.defaultHost);
|
|
3288
|
+
if (!host)
|
|
3289
|
+
return json({ error: "Missing Host header." }, 400);
|
|
3290
|
+
let link = null;
|
|
3291
|
+
try {
|
|
3292
|
+
link = await store.resolve(host, slug);
|
|
3293
|
+
} catch {
|
|
3294
|
+
return json({ error: "Shortlink not found.", slug, host }, 404);
|
|
3295
|
+
}
|
|
3296
|
+
if (!link)
|
|
3297
|
+
return json({ error: "Shortlink not found.", slug, host }, 404);
|
|
3298
|
+
if (!link.active)
|
|
3299
|
+
return json({ error: "Shortlink is disabled.", slug, host }, 410);
|
|
3300
|
+
if (isExpired(link))
|
|
3301
|
+
return json({ error: "Shortlink is expired.", slug, host }, 410);
|
|
3302
|
+
if (request.method === "GET") {
|
|
3303
|
+
try {
|
|
3304
|
+
await store.recordClick(link, {
|
|
3305
|
+
ip: getClientIp(request),
|
|
3306
|
+
userAgent: request.headers.get("user-agent"),
|
|
3307
|
+
referer: request.headers.get("referer"),
|
|
3308
|
+
country: request.headers.get("cf-ipcountry"),
|
|
3309
|
+
metadata: {
|
|
3310
|
+
path: url.pathname,
|
|
3311
|
+
query: url.search
|
|
3312
|
+
}
|
|
3313
|
+
});
|
|
3314
|
+
} catch (error) {
|
|
3315
|
+
if (options.onRecordClickError) {
|
|
3316
|
+
try {
|
|
3317
|
+
await options.onRecordClickError(error, { link, request });
|
|
3318
|
+
} catch {
|
|
3319
|
+
logRecordClickError(link);
|
|
3320
|
+
}
|
|
3321
|
+
} else {
|
|
3322
|
+
logRecordClickError(link);
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
return Response.redirect(link.destination_url, redirectStatus);
|
|
3879
3327
|
};
|
|
3880
3328
|
}
|
|
3881
|
-
function
|
|
3329
|
+
function serveShortlinks(options = {}) {
|
|
3330
|
+
const host = options.host || "127.0.0.1";
|
|
3331
|
+
const port = options.port || 8787;
|
|
3332
|
+
const fetch2 = createShortlinksHandler(options);
|
|
3333
|
+
return Bun.serve({ hostname: host, port, fetch: fetch2 });
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
// src/cloudflare.ts
|
|
3337
|
+
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
3338
|
+
import { join as join3 } from "path";
|
|
3339
|
+
function createCloudflarePlan(input) {
|
|
3340
|
+
const hostname2 = normalizeHostname(input.hostname);
|
|
3341
|
+
const target = normalizeHostname(input.target);
|
|
3342
|
+
const workerName = input.workerName || "shortlinks";
|
|
3343
|
+
const proxied = input.proxied ?? true;
|
|
3882
3344
|
return {
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3345
|
+
hostname: hostname2,
|
|
3346
|
+
target,
|
|
3347
|
+
proxied,
|
|
3348
|
+
workerName,
|
|
3349
|
+
origin: input.origin,
|
|
3350
|
+
dnsRecord: {
|
|
3351
|
+
type: "CNAME",
|
|
3352
|
+
name: hostname2,
|
|
3353
|
+
content: target,
|
|
3354
|
+
proxied
|
|
3355
|
+
},
|
|
3356
|
+
wranglerCommand: `wrangler deploy cloudflare/${workerName}.js --name ${workerName}`
|
|
3891
3357
|
};
|
|
3892
3358
|
}
|
|
3893
|
-
function
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
3901
|
-
|
|
3359
|
+
function generateWorkerScript() {
|
|
3360
|
+
return `export default {
|
|
3361
|
+
async fetch(request, env) {
|
|
3362
|
+
const origin = env.SHORTLINKS_ORIGIN;
|
|
3363
|
+
if (!origin) {
|
|
3364
|
+
return new Response("SHORTLINKS_ORIGIN is not configured", { status: 500 });
|
|
3365
|
+
}
|
|
3366
|
+
|
|
3367
|
+
const incoming = new URL(request.url);
|
|
3368
|
+
const upstream = new URL(incoming.pathname + incoming.search, origin);
|
|
3369
|
+
const headers = new Headers(request.headers);
|
|
3370
|
+
headers.set("x-forwarded-host", incoming.host);
|
|
3371
|
+
headers.set("x-shortlinks-worker", "cloudflare");
|
|
3372
|
+
|
|
3373
|
+
return fetch(upstream.toString(), {
|
|
3374
|
+
method: request.method,
|
|
3375
|
+
headers,
|
|
3376
|
+
body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body,
|
|
3377
|
+
redirect: "manual"
|
|
3378
|
+
});
|
|
3902
3379
|
}
|
|
3903
|
-
|
|
3380
|
+
};
|
|
3381
|
+
`;
|
|
3904
3382
|
}
|
|
3905
|
-
function
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3383
|
+
function writeWorkerFiles(options = {}) {
|
|
3384
|
+
const outDir = options.outDir || "cloudflare";
|
|
3385
|
+
const workerName = options.workerName || "shortlinks";
|
|
3386
|
+
mkdirSync3(outDir, { recursive: true });
|
|
3387
|
+
const workerPath = join3(outDir, `${workerName}.js`);
|
|
3388
|
+
const wranglerPath = join3(outDir, "wrangler.example.toml");
|
|
3389
|
+
writeFileSync3(workerPath, generateWorkerScript());
|
|
3390
|
+
writeFileSync3(wranglerPath, `name = "${workerName}"
|
|
3391
|
+
main = "${workerName}.js"
|
|
3392
|
+
compatibility_date = "2026-05-01"
|
|
3393
|
+
|
|
3394
|
+
[vars]
|
|
3395
|
+
SHORTLINKS_ORIGIN = "${options.origin || "https://shortlinks.example.com"}"
|
|
3396
|
+
`);
|
|
3397
|
+
return { workerPath, wranglerPath };
|
|
3912
3398
|
}
|
|
3913
|
-
function
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3399
|
+
function cloudflareAuthHeaders(token) {
|
|
3400
|
+
const apiToken = token || process.env.CLOUDFLARE_API_TOKEN;
|
|
3401
|
+
if (apiToken)
|
|
3402
|
+
return { authorization: `Bearer ${apiToken}` };
|
|
3403
|
+
const apiKey = process.env.CLOUDFLARE_API_KEY;
|
|
3404
|
+
const email = process.env.CLOUDFLARE_EMAIL;
|
|
3405
|
+
if (apiKey && email) {
|
|
3406
|
+
return {
|
|
3407
|
+
"x-auth-key": apiKey,
|
|
3408
|
+
"x-auth-email": email
|
|
3409
|
+
};
|
|
3410
|
+
}
|
|
3411
|
+
throw new Error("Cloudflare auth is required: set CLOUDFLARE_API_TOKEN, or CLOUDFLARE_API_KEY plus CLOUDFLARE_EMAIL.");
|
|
3922
3412
|
}
|
|
3923
|
-
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3413
|
+
async function cloudflareRequest(token, path, init = {}) {
|
|
3414
|
+
const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, {
|
|
3415
|
+
...init,
|
|
3416
|
+
headers: {
|
|
3417
|
+
...cloudflareAuthHeaders(token),
|
|
3418
|
+
"content-type": "application/json",
|
|
3419
|
+
...init.headers || {}
|
|
3420
|
+
}
|
|
3421
|
+
});
|
|
3422
|
+
const body = await response.json();
|
|
3423
|
+
if (!response.ok || body.success === false) {
|
|
3424
|
+
const message = body.errors?.map((e) => e.message).join("; ") || response.statusText;
|
|
3425
|
+
throw new Error(`Cloudflare API failed: ${message}`);
|
|
3928
3426
|
}
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3427
|
+
return body.result;
|
|
3428
|
+
}
|
|
3429
|
+
function candidateZones(hostname2) {
|
|
3430
|
+
const parts = normalizeHostname(hostname2).split(".");
|
|
3431
|
+
const candidates = [];
|
|
3432
|
+
for (let i = 0;i < parts.length - 1; i += 1) {
|
|
3433
|
+
candidates.push(parts.slice(i).join("."));
|
|
3932
3434
|
}
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3435
|
+
return candidates;
|
|
3436
|
+
}
|
|
3437
|
+
async function findCloudflareZoneId(hostname2, token) {
|
|
3438
|
+
for (const zone of candidateZones(hostname2)) {
|
|
3439
|
+
const result = await cloudflareRequest(token, `/zones?name=${encodeURIComponent(zone)}`);
|
|
3440
|
+
if (result[0]?.id)
|
|
3441
|
+
return result[0].id;
|
|
3936
3442
|
}
|
|
3937
|
-
|
|
3938
|
-
|
|
3443
|
+
throw new Error(`Could not find a Cloudflare zone for ${hostname2}. Pass --zone-id explicitly.`);
|
|
3444
|
+
}
|
|
3445
|
+
async function upsertCloudflareDnsRecord(options) {
|
|
3446
|
+
const token = options.token || process.env.CLOUDFLARE_API_TOKEN;
|
|
3447
|
+
const plan = createCloudflarePlan({
|
|
3448
|
+
hostname: options.hostname,
|
|
3449
|
+
target: options.target,
|
|
3450
|
+
origin: process.env.SHORTLINKS_ORIGIN || "https://shortlinks.example.com",
|
|
3451
|
+
proxied: options.proxied
|
|
3452
|
+
});
|
|
3453
|
+
if (options.dryRun)
|
|
3454
|
+
return plan;
|
|
3455
|
+
const zoneId = options.zoneId || await findCloudflareZoneId(plan.hostname, token);
|
|
3456
|
+
const existing = await cloudflareRequest(token, `/zones/${zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(plan.hostname)}`);
|
|
3457
|
+
const payload = JSON.stringify(plan.dnsRecord);
|
|
3458
|
+
if (existing[0]?.id) {
|
|
3459
|
+
const updated = await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${existing[0].id}`, {
|
|
3460
|
+
method: "PUT",
|
|
3461
|
+
body: payload
|
|
3462
|
+
});
|
|
3463
|
+
return { id: updated.id, action: "updated" };
|
|
3939
3464
|
}
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3465
|
+
const created = await cloudflareRequest(token, `/zones/${zoneId}/dns_records`, {
|
|
3466
|
+
method: "POST",
|
|
3467
|
+
body: payload
|
|
3468
|
+
});
|
|
3469
|
+
return { id: created.id, action: "created" };
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
// node_modules/.pnpm/@hasna+events@0.1.13/node_modules/@hasna/events/dist/commander.js
|
|
3473
|
+
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
3474
|
+
import { existsSync as existsSync3 } from "fs";
|
|
3475
|
+
import { homedir as homedir2 } from "os";
|
|
3476
|
+
import { join as join4 } from "path";
|
|
3477
|
+
import { createHmac, timingSafeEqual } from "crypto";
|
|
3478
|
+
import { randomUUID } from "crypto";
|
|
3479
|
+
import { spawn } from "child_process";
|
|
3480
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
3481
|
+
function getPathValue(input, path) {
|
|
3482
|
+
return path.split(".").reduce((value, part) => {
|
|
3483
|
+
if (value && typeof value === "object" && part in value) {
|
|
3484
|
+
return value[part];
|
|
3485
|
+
}
|
|
3486
|
+
return;
|
|
3487
|
+
}, input);
|
|
3488
|
+
}
|
|
3489
|
+
function getFieldValues(input, path) {
|
|
3490
|
+
const values = [];
|
|
3491
|
+
const push = (value) => {
|
|
3492
|
+
if (!values.some((item) => Object.is(item, value)))
|
|
3493
|
+
values.push(value);
|
|
3494
|
+
};
|
|
3495
|
+
if (path.includes(".") && path in input)
|
|
3496
|
+
push(input[path]);
|
|
3497
|
+
const nestedValue = getPathValue(input, path);
|
|
3498
|
+
if (nestedValue !== undefined || !path.includes("."))
|
|
3499
|
+
push(nestedValue);
|
|
3500
|
+
return values;
|
|
3501
|
+
}
|
|
3502
|
+
function wildcardToRegExp(pattern, options = {}) {
|
|
3503
|
+
let body = "";
|
|
3504
|
+
for (let index = 0;index < pattern.length; index += 1) {
|
|
3505
|
+
const char = pattern[index];
|
|
3506
|
+
if (char === "*") {
|
|
3507
|
+
if (pattern[index + 1] === "*") {
|
|
3508
|
+
body += ".*";
|
|
3509
|
+
index += 1;
|
|
3510
|
+
} else {
|
|
3511
|
+
body += options.segmentSafe ? "[^/]*" : ".*";
|
|
3512
|
+
}
|
|
3513
|
+
} else {
|
|
3514
|
+
body += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
3948
3515
|
}
|
|
3949
|
-
await this.pg.run(`
|
|
3950
|
-
INSERT INTO domains (
|
|
3951
|
-
id, hostname, provider, default_domain, cloudflare_zone_id, cloudflare_account_id,
|
|
3952
|
-
cloudflare_worker_name, origin_url, notes, metadata, machine_id, synced_at, created_at, updated_at
|
|
3953
|
-
)
|
|
3954
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
|
|
3955
|
-
ON CONFLICT(hostname) DO UPDATE SET
|
|
3956
|
-
provider = excluded.provider,
|
|
3957
|
-
default_domain = excluded.default_domain,
|
|
3958
|
-
cloudflare_zone_id = COALESCE(excluded.cloudflare_zone_id, domains.cloudflare_zone_id),
|
|
3959
|
-
cloudflare_account_id = COALESCE(excluded.cloudflare_account_id, domains.cloudflare_account_id),
|
|
3960
|
-
cloudflare_worker_name = COALESCE(excluded.cloudflare_worker_name, domains.cloudflare_worker_name),
|
|
3961
|
-
origin_url = COALESCE(excluded.origin_url, domains.origin_url),
|
|
3962
|
-
notes = COALESCE(excluded.notes, domains.notes),
|
|
3963
|
-
metadata = excluded.metadata,
|
|
3964
|
-
machine_id = excluded.machine_id,
|
|
3965
|
-
synced_at = NULL,
|
|
3966
|
-
updated_at = excluded.updated_at
|
|
3967
|
-
`, 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);
|
|
3968
|
-
return await this.getDomain(hostname2);
|
|
3969
3516
|
}
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3517
|
+
return new RegExp(`^${body}$`);
|
|
3518
|
+
}
|
|
3519
|
+
function matchString(value, matcher, options = {}) {
|
|
3520
|
+
if (matcher === undefined)
|
|
3521
|
+
return true;
|
|
3522
|
+
if (value === undefined)
|
|
3523
|
+
return false;
|
|
3524
|
+
const matchers = Array.isArray(matcher) ? matcher : [matcher];
|
|
3525
|
+
return matchers.some((item) => wildcardToRegExp(item, options).test(value));
|
|
3526
|
+
}
|
|
3527
|
+
function matchRecord(input, matcher) {
|
|
3528
|
+
if (!matcher)
|
|
3529
|
+
return true;
|
|
3530
|
+
return Object.entries(matcher).every(([path, expected]) => {
|
|
3531
|
+
const actualValues = getFieldValues(input, path);
|
|
3532
|
+
return matchField(actualValues, expected, path);
|
|
3533
|
+
});
|
|
3534
|
+
}
|
|
3535
|
+
function matchField(actualValues, expected, path) {
|
|
3536
|
+
if (isNegativeMatcher(expected)) {
|
|
3537
|
+
return !actualValues.some((actual) => matchPositiveField(actual, expected.not, path));
|
|
3976
3538
|
}
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3539
|
+
return actualValues.some((actual) => matchPositiveField(actual, expected, path));
|
|
3540
|
+
}
|
|
3541
|
+
function matchPositiveField(actual, expected, path) {
|
|
3542
|
+
if (typeof expected === "string" || Array.isArray(expected)) {
|
|
3543
|
+
return stringCandidates(actual).some((candidate) => matchString(candidate, expected, {
|
|
3544
|
+
segmentSafe: path.endsWith("_path") || path.endsWith(".path")
|
|
3545
|
+
}));
|
|
3983
3546
|
}
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
SELECT * FROM domains ORDER BY default_domain DESC, created_at ASC LIMIT 1
|
|
3987
|
-
`);
|
|
3988
|
-
return row ? domainFromRow2(row) : null;
|
|
3547
|
+
if (Array.isArray(actual)) {
|
|
3548
|
+
return actual.some((item) => item === expected);
|
|
3989
3549
|
}
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
const machineId = getMachineId();
|
|
3998
|
-
const expiresAt = isoOrNull2(input.expiresAt);
|
|
3999
|
-
const slug = input.slug ? normalizeSlug(input.slug) : await this.generateAvailableSlug(domain.id, input.slugLength || DEFAULT_SLUG_LENGTH);
|
|
4000
|
-
try {
|
|
4001
|
-
await this.pg.run(`
|
|
4002
|
-
INSERT INTO links (
|
|
4003
|
-
id, domain_id, slug, destination_url, title, active, expires_at, metadata,
|
|
4004
|
-
machine_id, synced_at, created_at, updated_at
|
|
4005
|
-
)
|
|
4006
|
-
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NULL, ?, ?)
|
|
4007
|
-
`, makeId("lnk"), domain.id, slug, destinationUrl, input.title || null, expiresAt, JSON.stringify(input.metadata || {}), machineId, timestamp, timestamp);
|
|
4008
|
-
} catch (error) {
|
|
4009
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
4010
|
-
if (message.includes("unique") || message.includes("duplicate")) {
|
|
4011
|
-
throw new Error(`Slug already exists for ${domain.hostname}: ${slug}`);
|
|
4012
|
-
}
|
|
4013
|
-
throw error;
|
|
4014
|
-
}
|
|
4015
|
-
return await this.getLink(domain.hostname, slug);
|
|
3550
|
+
return actual === expected;
|
|
3551
|
+
}
|
|
3552
|
+
function stringCandidates(actual) {
|
|
3553
|
+
if (actual === undefined)
|
|
3554
|
+
return [];
|
|
3555
|
+
if (Array.isArray(actual)) {
|
|
3556
|
+
return actual.flatMap((item) => isPrimitiveFieldValue(item) ? [String(item)] : []);
|
|
4016
3557
|
}
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
3558
|
+
return [String(actual)];
|
|
3559
|
+
}
|
|
3560
|
+
function isPrimitiveFieldValue(value) {
|
|
3561
|
+
return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
|
|
3562
|
+
}
|
|
3563
|
+
function isNegativeMatcher(value) {
|
|
3564
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value) && "not" in value);
|
|
3565
|
+
}
|
|
3566
|
+
function eventMatchesFilter(event, filter) {
|
|
3567
|
+
return matchString(event.source, filter.source) && matchString(event.type, filter.type) && matchString(event.subject, filter.subject) && matchString(event.severity, filter.severity) && matchRecord(event.data, filter.data) && matchRecord(event.metadata, filter.metadata);
|
|
3568
|
+
}
|
|
3569
|
+
function channelMatchesEvent(channel, event) {
|
|
3570
|
+
if (!channel.enabled)
|
|
3571
|
+
return false;
|
|
3572
|
+
if (!channel.filters || channel.filters.length === 0)
|
|
3573
|
+
return true;
|
|
3574
|
+
return channel.filters.some((filter) => eventMatchesFilter(event, filter));
|
|
3575
|
+
}
|
|
3576
|
+
var HASNA_EVENTS_DIR_ENV = "HASNA_EVENTS_DIR";
|
|
3577
|
+
var HASNA_EVENTS_HOME_ENV = "HASNA_EVENTS_HOME";
|
|
3578
|
+
function getEventsDataDir(override) {
|
|
3579
|
+
return override || process.env[HASNA_EVENTS_DIR_ENV] || process.env[HASNA_EVENTS_HOME_ENV] || join4(homedir2(), ".hasna", "events");
|
|
3580
|
+
}
|
|
3581
|
+
function getActiveEventsDirEnv() {
|
|
3582
|
+
if (process.env[HASNA_EVENTS_DIR_ENV])
|
|
3583
|
+
return HASNA_EVENTS_DIR_ENV;
|
|
3584
|
+
if (process.env[HASNA_EVENTS_HOME_ENV])
|
|
3585
|
+
return HASNA_EVENTS_HOME_ENV;
|
|
3586
|
+
return null;
|
|
3587
|
+
}
|
|
3588
|
+
|
|
3589
|
+
class JsonEventsStore {
|
|
3590
|
+
dataDir;
|
|
3591
|
+
channelsPath;
|
|
3592
|
+
eventsPath;
|
|
3593
|
+
deliveriesPath;
|
|
3594
|
+
constructor(dataDir = getEventsDataDir()) {
|
|
3595
|
+
this.dataDir = dataDir;
|
|
3596
|
+
this.channelsPath = join4(dataDir, "channels.json");
|
|
3597
|
+
this.eventsPath = join4(dataDir, "events.json");
|
|
3598
|
+
this.deliveriesPath = join4(dataDir, "deliveries.json");
|
|
4036
3599
|
}
|
|
4037
|
-
async
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
|
|
3600
|
+
async init() {
|
|
3601
|
+
await mkdir(this.dataDir, { recursive: true, mode: 448 });
|
|
3602
|
+
await chmod(this.dataDir, 448).catch(() => {
|
|
3603
|
+
return;
|
|
3604
|
+
});
|
|
3605
|
+
await this.ensureArrayFile(this.channelsPath);
|
|
3606
|
+
await this.ensureArrayFile(this.eventsPath);
|
|
3607
|
+
await this.ensureArrayFile(this.deliveriesPath);
|
|
3608
|
+
}
|
|
3609
|
+
async addChannel(channel) {
|
|
3610
|
+
await this.init();
|
|
3611
|
+
const channels = await this.readJson(this.channelsPath, []);
|
|
3612
|
+
const index = channels.findIndex((item) => item.id === channel.id);
|
|
3613
|
+
if (index >= 0) {
|
|
3614
|
+
channels[index] = { ...channel, createdAt: channels[index].createdAt, updatedAt: new Date().toISOString() };
|
|
3615
|
+
} else {
|
|
3616
|
+
channels.push(channel);
|
|
4044
3617
|
}
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
FROM links l
|
|
4048
|
-
JOIN domains d ON d.id = l.domain_id
|
|
4049
|
-
WHERE l.slug = ? ${domainClause}
|
|
4050
|
-
ORDER BY d.default_domain DESC, l.created_at ASC
|
|
4051
|
-
LIMIT 1
|
|
4052
|
-
`, ...params);
|
|
4053
|
-
return row ? linkFromRow2(row) : null;
|
|
3618
|
+
await this.writeJson(this.channelsPath, channels);
|
|
3619
|
+
return index >= 0 ? channels[index] : channel;
|
|
4054
3620
|
}
|
|
4055
|
-
async
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
(SELECT COUNT(*)::int FROM domains) AS domains,
|
|
4059
|
-
(SELECT COUNT(*)::int FROM links) AS links,
|
|
4060
|
-
(SELECT COUNT(*)::int FROM clicks) AS clicks
|
|
4061
|
-
`);
|
|
4062
|
-
return row;
|
|
3621
|
+
async listChannels() {
|
|
3622
|
+
await this.init();
|
|
3623
|
+
return this.readJson(this.channelsPath, []);
|
|
4063
3624
|
}
|
|
4064
|
-
async
|
|
4065
|
-
const
|
|
4066
|
-
|
|
4067
|
-
const row = await this.pg.get(`
|
|
4068
|
-
SELECT l.*, d.hostname
|
|
4069
|
-
FROM links l
|
|
4070
|
-
JOIN domains d ON d.id = l.domain_id
|
|
4071
|
-
WHERE d.hostname = ? AND l.slug = ?
|
|
4072
|
-
LIMIT 1
|
|
4073
|
-
`, normalizedHost, normalizedSlug);
|
|
4074
|
-
if (row)
|
|
4075
|
-
return linkFromRow2(row);
|
|
4076
|
-
const fallback = await this.pg.get(`
|
|
4077
|
-
SELECT l.*, d.hostname
|
|
4078
|
-
FROM links l
|
|
4079
|
-
JOIN domains d ON d.id = l.domain_id
|
|
4080
|
-
WHERE d.default_domain = 1 AND l.slug = ?
|
|
4081
|
-
ORDER BY d.created_at ASC
|
|
4082
|
-
LIMIT 1
|
|
4083
|
-
`, normalizedSlug);
|
|
4084
|
-
return fallback ? linkFromRow2(fallback) : null;
|
|
3625
|
+
async getChannel(id) {
|
|
3626
|
+
const channels = await this.listChannels();
|
|
3627
|
+
return channels.find((channel) => channel.id === id);
|
|
4085
3628
|
}
|
|
4086
|
-
async
|
|
4087
|
-
|
|
4088
|
-
const
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
await this.pg.run(`
|
|
4093
|
-
UPDATE links SET active = ?, updated_at = ?, synced_at = NULL WHERE id = ?
|
|
4094
|
-
`, active ? 1 : 0, timestamp, link.id);
|
|
4095
|
-
return await this.getLink(link.hostname, link.slug);
|
|
3629
|
+
async removeChannel(id) {
|
|
3630
|
+
await this.init();
|
|
3631
|
+
const channels = await this.readJson(this.channelsPath, []);
|
|
3632
|
+
const next = channels.filter((channel) => channel.id !== id);
|
|
3633
|
+
await this.writeJson(this.channelsPath, next);
|
|
3634
|
+
return next.length !== channels.length;
|
|
4096
3635
|
}
|
|
4097
|
-
async
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
await this.
|
|
4102
|
-
return
|
|
3636
|
+
async appendEvent(event) {
|
|
3637
|
+
await this.init();
|
|
3638
|
+
const events = await this.readJson(this.eventsPath, []);
|
|
3639
|
+
events.push(event);
|
|
3640
|
+
await this.writeJson(this.eventsPath, events);
|
|
3641
|
+
return event;
|
|
4103
3642
|
}
|
|
4104
|
-
async
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
const ipHash = input.ip ? this.hashIp(input.ip) : null;
|
|
4108
|
-
const id = makeId("clk");
|
|
4109
|
-
await this.pg.run(`
|
|
4110
|
-
INSERT INTO clicks (
|
|
4111
|
-
id, link_id, domain_id, slug, clicked_at, ip_hash, user_agent, referer,
|
|
4112
|
-
country, city, metadata, machine_id, synced_at, created_at, updated_at
|
|
4113
|
-
)
|
|
4114
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
|
|
4115
|
-
`, 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);
|
|
4116
|
-
const row = await this.pg.get("SELECT * FROM clicks WHERE id = ?", id);
|
|
4117
|
-
return clickFromRow2(row);
|
|
3643
|
+
async listEvents() {
|
|
3644
|
+
await this.init();
|
|
3645
|
+
return this.readJson(this.eventsPath, []);
|
|
4118
3646
|
}
|
|
4119
|
-
async
|
|
4120
|
-
const
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
SELECT user_agent, COUNT(*)::int AS clicks
|
|
4136
|
-
FROM clicks
|
|
4137
|
-
WHERE link_id = ?
|
|
4138
|
-
GROUP BY user_agent
|
|
4139
|
-
ORDER BY clicks DESC
|
|
4140
|
-
LIMIT 10
|
|
4141
|
-
`, link.id);
|
|
3647
|
+
async findEventByIdentity(identity) {
|
|
3648
|
+
const events = await this.listEvents();
|
|
3649
|
+
return events.find((event) => identity.id !== undefined && event.id === identity.id || identity.dedupeKey !== undefined && event.dedupeKey === identity.dedupeKey);
|
|
3650
|
+
}
|
|
3651
|
+
async appendDelivery(result) {
|
|
3652
|
+
await this.init();
|
|
3653
|
+
const deliveries = await this.readJson(this.deliveriesPath, []);
|
|
3654
|
+
deliveries.push(result);
|
|
3655
|
+
await this.writeJson(this.deliveriesPath, deliveries);
|
|
3656
|
+
return result;
|
|
3657
|
+
}
|
|
3658
|
+
async listDeliveries() {
|
|
3659
|
+
await this.init();
|
|
3660
|
+
return this.readJson(this.deliveriesPath, []);
|
|
3661
|
+
}
|
|
3662
|
+
async exportData() {
|
|
4142
3663
|
return {
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4146
|
-
top_referrers: topReferrers,
|
|
4147
|
-
top_user_agents: topUserAgents
|
|
3664
|
+
channels: await this.listChannels(),
|
|
3665
|
+
events: await this.listEvents(),
|
|
3666
|
+
deliveries: await this.listDeliveries()
|
|
4148
3667
|
};
|
|
4149
3668
|
}
|
|
4150
|
-
|
|
4151
|
-
|
|
3669
|
+
async ensureArrayFile(path) {
|
|
3670
|
+
if (!existsSync3(path)) {
|
|
3671
|
+
await writeFile(path, `[]
|
|
3672
|
+
`, { encoding: "utf-8", mode: 384 });
|
|
3673
|
+
}
|
|
3674
|
+
await chmod(path, 384).catch(() => {
|
|
3675
|
+
return;
|
|
3676
|
+
});
|
|
4152
3677
|
}
|
|
4153
|
-
async
|
|
4154
|
-
|
|
4155
|
-
const
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
3678
|
+
async readJson(path, fallback) {
|
|
3679
|
+
try {
|
|
3680
|
+
const raw = await readFile(path, "utf-8");
|
|
3681
|
+
if (!raw.trim())
|
|
3682
|
+
return fallback;
|
|
3683
|
+
return JSON.parse(raw);
|
|
3684
|
+
} catch (error) {
|
|
3685
|
+
if (error.code === "ENOENT")
|
|
3686
|
+
return fallback;
|
|
3687
|
+
throw error;
|
|
4161
3688
|
}
|
|
4162
|
-
|
|
3689
|
+
}
|
|
3690
|
+
async writeJson(path, value) {
|
|
3691
|
+
const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
3692
|
+
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}
|
|
3693
|
+
`, { encoding: "utf-8", mode: 384 });
|
|
3694
|
+
await rename(tempPath, path);
|
|
3695
|
+
await chmod(path, 384).catch(() => {
|
|
3696
|
+
return;
|
|
3697
|
+
});
|
|
4163
3698
|
}
|
|
4164
3699
|
}
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
3700
|
+
async function getEventsStatus(dataDir) {
|
|
3701
|
+
const store = new JsonEventsStore(dataDir);
|
|
3702
|
+
await store.init();
|
|
3703
|
+
const [channels, events, deliveries] = await Promise.all([
|
|
3704
|
+
store.listChannels(),
|
|
3705
|
+
store.listEvents(),
|
|
3706
|
+
store.listDeliveries()
|
|
3707
|
+
]);
|
|
3708
|
+
const transports = channels.reduce((counts, channel) => {
|
|
3709
|
+
counts[channel.transport] = (counts[channel.transport] ?? 0) + 1;
|
|
3710
|
+
return counts;
|
|
3711
|
+
}, {});
|
|
3712
|
+
return {
|
|
3713
|
+
service: "events",
|
|
3714
|
+
schemaVersion: "1.0",
|
|
3715
|
+
dataDir: store.dataDir,
|
|
3716
|
+
env: {
|
|
3717
|
+
primary: HASNA_EVENTS_DIR_ENV,
|
|
3718
|
+
fallback: HASNA_EVENTS_HOME_ENV,
|
|
3719
|
+
active: getActiveEventsDirEnv()
|
|
3720
|
+
},
|
|
3721
|
+
files: {
|
|
3722
|
+
channels: statusFile(store.dataDir, "channels.json", channels.length),
|
|
3723
|
+
events: statusFile(store.dataDir, "events.json", events.length),
|
|
3724
|
+
deliveries: statusFile(store.dataDir, "deliveries.json", deliveries.length)
|
|
3725
|
+
},
|
|
3726
|
+
counts: {
|
|
3727
|
+
channels: channels.length,
|
|
3728
|
+
enabledChannels: channels.filter((channel) => channel.enabled).length,
|
|
3729
|
+
disabledChannels: channels.filter((channel) => !channel.enabled).length,
|
|
3730
|
+
events: events.length,
|
|
3731
|
+
deliveries: deliveries.length
|
|
3732
|
+
},
|
|
3733
|
+
transports,
|
|
3734
|
+
safety: {
|
|
3735
|
+
includesEventPayloads: false,
|
|
3736
|
+
includesWebhookSecrets: false,
|
|
3737
|
+
listOutputsRedactSecrets: true,
|
|
3738
|
+
statusOutputIsMetadataOnly: true
|
|
3739
|
+
}
|
|
3740
|
+
};
|
|
3741
|
+
}
|
|
3742
|
+
function statusFile(dataDir, fileName, records) {
|
|
3743
|
+
const path = join4(dataDir, fileName);
|
|
3744
|
+
return { path, exists: existsSync3(path), records };
|
|
3745
|
+
}
|
|
3746
|
+
var DEFAULT_SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
|
|
3747
|
+
function buildSignatureBase(timestamp, body) {
|
|
3748
|
+
return `${timestamp}.${body}`;
|
|
3749
|
+
}
|
|
3750
|
+
function signPayload(secret, timestamp, body) {
|
|
3751
|
+
const digest = createHmac("sha256", secret).update(buildSignatureBase(timestamp, body)).digest("hex");
|
|
3752
|
+
return `sha256=${digest}`;
|
|
3753
|
+
}
|
|
3754
|
+
function now2() {
|
|
3755
|
+
return new Date().toISOString();
|
|
3756
|
+
}
|
|
3757
|
+
function truncate(value, max = 4096) {
|
|
3758
|
+
return value.length > max ? `${value.slice(0, max)}...` : value;
|
|
3759
|
+
}
|
|
3760
|
+
function buildWebhookRequest(event, channel) {
|
|
3761
|
+
if (!channel.webhook)
|
|
3762
|
+
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
3763
|
+
const body = JSON.stringify(event);
|
|
3764
|
+
const timestamp = event.time;
|
|
3765
|
+
const headers = {
|
|
3766
|
+
"Content-Type": "application/json",
|
|
3767
|
+
"User-Agent": "@hasna/events",
|
|
3768
|
+
"X-Hasna-Event-Id": event.id,
|
|
3769
|
+
"X-Hasna-Event-Type": event.type,
|
|
3770
|
+
"X-Hasna-Timestamp": timestamp,
|
|
3771
|
+
...channel.webhook.headers
|
|
3772
|
+
};
|
|
3773
|
+
if (channel.webhook.secret) {
|
|
3774
|
+
headers["X-Hasna-Signature"] = signPayload(channel.webhook.secret, timestamp, body);
|
|
3775
|
+
}
|
|
3776
|
+
return { body, headers };
|
|
3777
|
+
}
|
|
3778
|
+
async function dispatchWebhook(event, channel, options = {}) {
|
|
3779
|
+
if (!channel.webhook)
|
|
3780
|
+
throw new Error(`Channel ${channel.id} has no webhook config`);
|
|
3781
|
+
const startedAt = now2();
|
|
3782
|
+
const { body, headers } = buildWebhookRequest(event, channel);
|
|
3783
|
+
const controller = new AbortController;
|
|
3784
|
+
const timeout = setTimeout(() => controller.abort(), channel.webhook.timeoutMs ?? 15000);
|
|
3785
|
+
try {
|
|
3786
|
+
const response = await (options.fetchImpl ?? fetch)(channel.webhook.url, {
|
|
3787
|
+
method: "POST",
|
|
3788
|
+
headers,
|
|
3789
|
+
body,
|
|
3790
|
+
signal: controller.signal
|
|
3791
|
+
});
|
|
3792
|
+
const responseBody = truncate(await response.text());
|
|
3793
|
+
return {
|
|
3794
|
+
attempt: 1,
|
|
3795
|
+
status: response.ok ? "success" : "failed",
|
|
3796
|
+
startedAt,
|
|
3797
|
+
completedAt: now2(),
|
|
3798
|
+
responseStatus: response.status,
|
|
3799
|
+
responseBody,
|
|
3800
|
+
error: response.ok ? undefined : `Webhook returned HTTP ${response.status}`
|
|
3801
|
+
};
|
|
3802
|
+
} catch (error) {
|
|
3803
|
+
return {
|
|
3804
|
+
attempt: 1,
|
|
3805
|
+
status: "failed",
|
|
3806
|
+
startedAt,
|
|
3807
|
+
completedAt: now2(),
|
|
3808
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3809
|
+
};
|
|
3810
|
+
} finally {
|
|
3811
|
+
clearTimeout(timeout);
|
|
3812
|
+
}
|
|
3813
|
+
}
|
|
3814
|
+
async function dispatchCommand(event, channel) {
|
|
3815
|
+
if (!channel.command)
|
|
3816
|
+
throw new Error(`Channel ${channel.id} has no command config`);
|
|
3817
|
+
const startedAt = now2();
|
|
3818
|
+
const eventJson = JSON.stringify(event);
|
|
3819
|
+
const env = {
|
|
3820
|
+
...process.env,
|
|
3821
|
+
...channel.command.env,
|
|
3822
|
+
HASNA_CHANNEL_ID: channel.id,
|
|
3823
|
+
HASNA_EVENT_ID: event.id,
|
|
3824
|
+
HASNA_EVENT_TYPE: event.type,
|
|
3825
|
+
HASNA_EVENT_SOURCE: event.source,
|
|
3826
|
+
HASNA_EVENT_SUBJECT: event.subject ?? "",
|
|
3827
|
+
HASNA_EVENT_SEVERITY: event.severity,
|
|
3828
|
+
HASNA_EVENT_TIME: event.time,
|
|
3829
|
+
HASNA_EVENT_DEDUPE_KEY: event.dedupeKey ?? "",
|
|
3830
|
+
HASNA_EVENT_SCHEMA_VERSION: event.schemaVersion,
|
|
3831
|
+
HASNA_EVENT_JSON: eventJson
|
|
3832
|
+
};
|
|
3833
|
+
return new Promise((resolve2) => {
|
|
3834
|
+
const child = spawn(channel.command.command, channel.command.args ?? [], {
|
|
3835
|
+
cwd: channel.command.cwd,
|
|
3836
|
+
env,
|
|
3837
|
+
stdio: ["pipe", "pipe", "pipe"]
|
|
3838
|
+
});
|
|
3839
|
+
let stdout = "";
|
|
3840
|
+
let stderr = "";
|
|
3841
|
+
const timeout = setTimeout(() => child.kill("SIGTERM"), channel.command.timeoutMs ?? 15000);
|
|
3842
|
+
child.stdin.end(eventJson);
|
|
3843
|
+
child.stdout.on("data", (chunk) => {
|
|
3844
|
+
stdout += chunk.toString();
|
|
3845
|
+
});
|
|
3846
|
+
child.stderr.on("data", (chunk) => {
|
|
3847
|
+
stderr += chunk.toString();
|
|
3848
|
+
});
|
|
3849
|
+
child.on("error", (error) => {
|
|
3850
|
+
clearTimeout(timeout);
|
|
3851
|
+
resolve2({
|
|
3852
|
+
attempt: 1,
|
|
3853
|
+
status: "failed",
|
|
3854
|
+
startedAt,
|
|
3855
|
+
completedAt: now2(),
|
|
3856
|
+
stdout: truncate(stdout),
|
|
3857
|
+
stderr: truncate(stderr),
|
|
3858
|
+
error: error.message
|
|
3859
|
+
});
|
|
3860
|
+
});
|
|
3861
|
+
child.on("close", (code, signal) => {
|
|
3862
|
+
clearTimeout(timeout);
|
|
3863
|
+
const success = code === 0;
|
|
3864
|
+
resolve2({
|
|
3865
|
+
attempt: 1,
|
|
3866
|
+
status: success ? "success" : "failed",
|
|
3867
|
+
startedAt,
|
|
3868
|
+
completedAt: now2(),
|
|
3869
|
+
stdout: truncate(stdout),
|
|
3870
|
+
stderr: truncate(stderr),
|
|
3871
|
+
error: success ? undefined : `Command exited with ${signal ? `signal ${signal}` : `code ${code}`}`
|
|
3872
|
+
});
|
|
3873
|
+
});
|
|
3874
|
+
});
|
|
3875
|
+
}
|
|
3876
|
+
async function dispatchChannel(event, channel, options = {}) {
|
|
3877
|
+
if (channel.transport === "webhook")
|
|
3878
|
+
return dispatchWebhook(event, channel, options);
|
|
3879
|
+
if (channel.transport === "command")
|
|
3880
|
+
return dispatchCommand(event, channel);
|
|
3881
|
+
return {
|
|
3882
|
+
attempt: 1,
|
|
3883
|
+
status: "skipped",
|
|
3884
|
+
startedAt: now2(),
|
|
3885
|
+
completedAt: now2(),
|
|
3886
|
+
error: `Unsupported transport: ${channel.transport}`
|
|
3887
|
+
};
|
|
3888
|
+
}
|
|
3889
|
+
function createDeliveryResult(event, channel, attempts) {
|
|
3890
|
+
const status = attempts.some((attempt) => attempt.status === "success") ? "success" : attempts.every((attempt) => attempt.status === "skipped") ? "skipped" : "failed";
|
|
3891
|
+
return {
|
|
3892
|
+
id: randomUUID(),
|
|
3893
|
+
eventId: event.id,
|
|
3894
|
+
channelId: channel.id,
|
|
3895
|
+
transport: channel.transport,
|
|
4172
3896
|
status,
|
|
4173
|
-
|
|
3897
|
+
attempts,
|
|
3898
|
+
createdAt: attempts[0]?.startedAt ?? now2(),
|
|
3899
|
+
completedAt: attempts.at(-1)?.completedAt ?? now2()
|
|
3900
|
+
};
|
|
3901
|
+
}
|
|
3902
|
+
function createEvent(input) {
|
|
3903
|
+
return {
|
|
3904
|
+
id: input.id ?? randomUUID2(),
|
|
3905
|
+
source: input.source,
|
|
3906
|
+
type: input.type,
|
|
3907
|
+
time: normalizeTime(input.time),
|
|
3908
|
+
subject: input.subject,
|
|
3909
|
+
severity: input.severity ?? "info",
|
|
3910
|
+
data: input.data ?? {},
|
|
3911
|
+
message: input.message,
|
|
3912
|
+
dedupeKey: input.dedupeKey,
|
|
3913
|
+
schemaVersion: input.schemaVersion ?? "1.0",
|
|
3914
|
+
metadata: input.metadata ?? {}
|
|
3915
|
+
};
|
|
3916
|
+
}
|
|
3917
|
+
|
|
3918
|
+
class EventsClient {
|
|
3919
|
+
store;
|
|
3920
|
+
redactors;
|
|
3921
|
+
transportOptions;
|
|
3922
|
+
constructor(options = {}) {
|
|
3923
|
+
this.store = options.store ?? new JsonEventsStore(options.dataDir);
|
|
3924
|
+
this.redactors = options.redactors ?? [];
|
|
3925
|
+
this.transportOptions = { fetchImpl: options.fetchImpl };
|
|
3926
|
+
}
|
|
3927
|
+
async addChannel(input) {
|
|
3928
|
+
const timestamp = new Date().toISOString();
|
|
3929
|
+
return this.store.addChannel({
|
|
3930
|
+
...input,
|
|
3931
|
+
createdAt: input.createdAt ?? timestamp,
|
|
3932
|
+
updatedAt: input.updatedAt ?? timestamp
|
|
3933
|
+
});
|
|
3934
|
+
}
|
|
3935
|
+
async listChannels() {
|
|
3936
|
+
return this.store.listChannels();
|
|
3937
|
+
}
|
|
3938
|
+
async removeChannel(id) {
|
|
3939
|
+
return this.store.removeChannel(id);
|
|
3940
|
+
}
|
|
3941
|
+
async emit(input, options = {}) {
|
|
3942
|
+
const event = options.redactSensitiveData === false ? createEvent(input) : redactSensitiveKeys(createEvent(input));
|
|
3943
|
+
if (options.dedupe !== false) {
|
|
3944
|
+
const existing = await this.store.findEventByIdentity({ id: input.id, dedupeKey: event.dedupeKey });
|
|
3945
|
+
if (existing) {
|
|
3946
|
+
return { event: existing, deliveries: [], deduped: true };
|
|
3947
|
+
}
|
|
3948
|
+
}
|
|
3949
|
+
await this.store.appendEvent(event);
|
|
3950
|
+
const deliveries = options.deliver === false ? [] : await this.deliver(event);
|
|
3951
|
+
return { event, deliveries, deduped: false };
|
|
3952
|
+
}
|
|
3953
|
+
async listEvents() {
|
|
3954
|
+
return this.store.listEvents();
|
|
3955
|
+
}
|
|
3956
|
+
async listDeliveries() {
|
|
3957
|
+
return this.store.listDeliveries();
|
|
3958
|
+
}
|
|
3959
|
+
async deliver(event) {
|
|
3960
|
+
const channels = await this.store.listChannels();
|
|
3961
|
+
const selected = channels.filter((channel) => channelMatchesEvent(channel, event));
|
|
3962
|
+
const deliveries = [];
|
|
3963
|
+
for (const channel of selected) {
|
|
3964
|
+
const eventForChannel = await this.applyRedaction(event, channel);
|
|
3965
|
+
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
3966
|
+
await this.store.appendDelivery(result);
|
|
3967
|
+
deliveries.push(result);
|
|
3968
|
+
}
|
|
3969
|
+
return deliveries;
|
|
3970
|
+
}
|
|
3971
|
+
async matchChannel(id, input = {}) {
|
|
3972
|
+
const channel = await this.store.getChannel(id);
|
|
3973
|
+
if (!channel)
|
|
3974
|
+
throw new Error(`Channel not found: ${id}`);
|
|
3975
|
+
const event = createEvent({
|
|
3976
|
+
source: input.source ?? "hasna.events",
|
|
3977
|
+
type: input.type ?? "events.test",
|
|
3978
|
+
subject: input.subject ?? id,
|
|
3979
|
+
severity: input.severity ?? "info",
|
|
3980
|
+
data: input.data ?? { test: true },
|
|
3981
|
+
message: input.message ?? "Hasna events test delivery",
|
|
3982
|
+
dedupeKey: input.dedupeKey,
|
|
3983
|
+
schemaVersion: input.schemaVersion,
|
|
3984
|
+
metadata: input.metadata,
|
|
3985
|
+
time: input.time,
|
|
3986
|
+
id: input.id
|
|
3987
|
+
});
|
|
3988
|
+
const matched = channelMatchesEvent(channel, event);
|
|
3989
|
+
return {
|
|
3990
|
+
channelId: channel.id,
|
|
3991
|
+
matched,
|
|
3992
|
+
event,
|
|
3993
|
+
filters: channel.filters,
|
|
3994
|
+
reason: matched ? undefined : channel.enabled ? "event did not match channel filters" : "channel is disabled"
|
|
3995
|
+
};
|
|
3996
|
+
}
|
|
3997
|
+
async testChannel(id, input = {}, options = {}) {
|
|
3998
|
+
const channel = await this.store.getChannel(id);
|
|
3999
|
+
if (!channel)
|
|
4000
|
+
throw new Error(`Channel not found: ${id}`);
|
|
4001
|
+
const match = await this.matchChannel(id, input);
|
|
4002
|
+
const event = match.event;
|
|
4003
|
+
if (options.honorFilters && !match.matched) {
|
|
4004
|
+
const timestamp = new Date().toISOString();
|
|
4005
|
+
const result2 = createDeliveryResult(event, channel, [{
|
|
4006
|
+
attempt: 1,
|
|
4007
|
+
status: "skipped",
|
|
4008
|
+
startedAt: timestamp,
|
|
4009
|
+
completedAt: timestamp,
|
|
4010
|
+
error: match.reason
|
|
4011
|
+
}]);
|
|
4012
|
+
result2.metadata = { reason: "filter_mismatch" };
|
|
4013
|
+
await this.store.appendDelivery(result2);
|
|
4014
|
+
return result2;
|
|
4015
|
+
}
|
|
4016
|
+
const eventForChannel = await this.applyRedaction(event, channel);
|
|
4017
|
+
const result = await this.deliverWithRetry(eventForChannel, channel);
|
|
4018
|
+
await this.store.appendDelivery(result);
|
|
4019
|
+
return result;
|
|
4020
|
+
}
|
|
4021
|
+
async replay(options = {}) {
|
|
4022
|
+
const events = (await this.store.listEvents()).filter((event) => {
|
|
4023
|
+
if (options.eventId && event.id !== options.eventId)
|
|
4024
|
+
return false;
|
|
4025
|
+
if (options.source && event.source !== options.source)
|
|
4026
|
+
return false;
|
|
4027
|
+
if (options.type && event.type !== options.type)
|
|
4028
|
+
return false;
|
|
4029
|
+
return true;
|
|
4030
|
+
});
|
|
4031
|
+
if (options.dryRun)
|
|
4032
|
+
return { events, deliveries: [] };
|
|
4033
|
+
const deliveries = [];
|
|
4034
|
+
for (const event of events) {
|
|
4035
|
+
deliveries.push(...await this.deliver(event));
|
|
4036
|
+
}
|
|
4037
|
+
return { events, deliveries };
|
|
4038
|
+
}
|
|
4039
|
+
async applyRedaction(event, channel) {
|
|
4040
|
+
let next = redactPaths(event, channel.redact?.paths ?? [], channel.redact?.replacement ?? "[REDACTED]");
|
|
4041
|
+
for (const redactor of this.redactors) {
|
|
4042
|
+
next = await redactor(next, channel);
|
|
4043
|
+
}
|
|
4044
|
+
return next;
|
|
4045
|
+
}
|
|
4046
|
+
async deliverWithRetry(event, channel) {
|
|
4047
|
+
const policy = normalizeRetryPolicy(channel.retry);
|
|
4048
|
+
const attempts = [];
|
|
4049
|
+
for (let index = 0;index < policy.maxAttempts; index += 1) {
|
|
4050
|
+
const attempt = await dispatchChannel(event, channel, this.transportOptions);
|
|
4051
|
+
attempt.attempt = index + 1;
|
|
4052
|
+
if (attempt.status === "failed" && index + 1 < policy.maxAttempts) {
|
|
4053
|
+
attempt.nextBackoffMs = Math.round(policy.backoffMs * policy.multiplier ** index);
|
|
4054
|
+
}
|
|
4055
|
+
attempts.push(attempt);
|
|
4056
|
+
if (attempt.status !== "failed")
|
|
4057
|
+
break;
|
|
4058
|
+
if (attempt.nextBackoffMs)
|
|
4059
|
+
await Bun.sleep(attempt.nextBackoffMs);
|
|
4060
|
+
}
|
|
4061
|
+
return createDeliveryResult(event, channel, attempts);
|
|
4062
|
+
}
|
|
4063
|
+
}
|
|
4064
|
+
function redactPaths(event, paths, replacement = "[REDACTED]") {
|
|
4065
|
+
if (paths.length === 0)
|
|
4066
|
+
return event;
|
|
4067
|
+
const copy = structuredClone(event);
|
|
4068
|
+
for (const path of paths) {
|
|
4069
|
+
setPath(copy, path, replacement);
|
|
4070
|
+
}
|
|
4071
|
+
return copy;
|
|
4072
|
+
}
|
|
4073
|
+
function sanitizeChannelForOutput(channel) {
|
|
4074
|
+
const copy = structuredClone(channel);
|
|
4075
|
+
if (copy.webhook?.secret)
|
|
4076
|
+
copy.webhook.secret = "[REDACTED]";
|
|
4077
|
+
if (copy.command?.env) {
|
|
4078
|
+
copy.command.env = Object.fromEntries(Object.entries(copy.command.env).map(([key, value]) => [key, shouldRedactKey(key) ? "[REDACTED]" : value]));
|
|
4079
|
+
}
|
|
4080
|
+
return copy;
|
|
4081
|
+
}
|
|
4082
|
+
function sanitizeChannelsForOutput(channels) {
|
|
4083
|
+
return channels.map(sanitizeChannelForOutput);
|
|
4084
|
+
}
|
|
4085
|
+
function redactSensitiveKeys(event, replacement = "[REDACTED]") {
|
|
4086
|
+
return redactValue(event, replacement);
|
|
4087
|
+
}
|
|
4088
|
+
function shouldRedactKey(key) {
|
|
4089
|
+
return /secret|token|password|api[_-]?key|authorization/i.test(key);
|
|
4090
|
+
}
|
|
4091
|
+
function redactValue(value, replacement) {
|
|
4092
|
+
if (Array.isArray(value))
|
|
4093
|
+
return value.map((item) => redactValue(item, replacement));
|
|
4094
|
+
if (!value || typeof value !== "object")
|
|
4095
|
+
return value;
|
|
4096
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [
|
|
4097
|
+
key,
|
|
4098
|
+
shouldRedactKey(key) ? replacement : redactValue(item, replacement)
|
|
4099
|
+
]));
|
|
4100
|
+
}
|
|
4101
|
+
function setPath(input, path, replacement) {
|
|
4102
|
+
const parts = path.split(".");
|
|
4103
|
+
let cursor = input;
|
|
4104
|
+
for (const part of parts.slice(0, -1)) {
|
|
4105
|
+
const next = cursor[part];
|
|
4106
|
+
if (!next || typeof next !== "object")
|
|
4107
|
+
return;
|
|
4108
|
+
cursor = next;
|
|
4109
|
+
}
|
|
4110
|
+
const last = parts.at(-1);
|
|
4111
|
+
if (last && last in cursor)
|
|
4112
|
+
cursor[last] = replacement;
|
|
4113
|
+
}
|
|
4114
|
+
function normalizeTime(value) {
|
|
4115
|
+
if (!value)
|
|
4116
|
+
return new Date().toISOString();
|
|
4117
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
4118
|
+
}
|
|
4119
|
+
function normalizeRetryPolicy(policy) {
|
|
4120
|
+
return {
|
|
4121
|
+
maxAttempts: Math.max(1, policy?.maxAttempts ?? 1),
|
|
4122
|
+
backoffMs: Math.max(0, policy?.backoffMs ?? 250),
|
|
4123
|
+
multiplier: Math.max(1, policy?.multiplier ?? 2)
|
|
4124
|
+
};
|
|
4125
|
+
}
|
|
4126
|
+
function parseFieldMatchers(values, label, typed = false) {
|
|
4127
|
+
if (!values?.length)
|
|
4128
|
+
return;
|
|
4129
|
+
const result = {};
|
|
4130
|
+
for (const value of values) {
|
|
4131
|
+
const parsed = parseMatcherExpression(value, label);
|
|
4132
|
+
const path = parsed.path;
|
|
4133
|
+
if (path in result)
|
|
4134
|
+
throw new Error(`Duplicate ${label} filter path: ${path}`);
|
|
4135
|
+
const matcherValue = typed ? parseTypedMatcherValue(parsed.rawValue, label) : parsed.rawValue;
|
|
4136
|
+
result[path] = parsed.negated ? { not: matcherValue } : matcherValue;
|
|
4137
|
+
}
|
|
4138
|
+
return result;
|
|
4139
|
+
}
|
|
4140
|
+
function parseFilterOptions(options) {
|
|
4141
|
+
const filter2 = {};
|
|
4142
|
+
if (options.source)
|
|
4143
|
+
filter2.source = options.source;
|
|
4144
|
+
if (options.type)
|
|
4145
|
+
filter2.type = options.type;
|
|
4146
|
+
if (options.subject)
|
|
4147
|
+
filter2.subject = options.subject;
|
|
4148
|
+
if (options.severity)
|
|
4149
|
+
filter2.severity = options.severity;
|
|
4150
|
+
const data = mergeMatchers(parseFieldMatchers(options.data, "data"), parseFieldMatchers(options.dataJson, "data-json", true));
|
|
4151
|
+
const metadata = mergeMatchers(parseFieldMatchers(options.metadata, "metadata"), parseFieldMatchers(options.metadataJson, "metadata-json", true));
|
|
4152
|
+
if (Object.keys(data).length > 0)
|
|
4153
|
+
filter2.data = data;
|
|
4154
|
+
if (Object.keys(metadata).length > 0)
|
|
4155
|
+
filter2.metadata = metadata;
|
|
4156
|
+
return Object.keys(filter2).length > 0 ? [filter2] : undefined;
|
|
4157
|
+
}
|
|
4158
|
+
function mergeMatchers(...records) {
|
|
4159
|
+
const result = {};
|
|
4160
|
+
for (const record of records) {
|
|
4161
|
+
if (!record)
|
|
4162
|
+
continue;
|
|
4163
|
+
for (const [path, value] of Object.entries(record)) {
|
|
4164
|
+
if (path in result)
|
|
4165
|
+
throw new Error(`Duplicate filter path: ${path}`);
|
|
4166
|
+
result[path] = value;
|
|
4167
|
+
}
|
|
4168
|
+
}
|
|
4169
|
+
return result;
|
|
4170
|
+
}
|
|
4171
|
+
function parseTypedMatcherValue(value, label) {
|
|
4172
|
+
const parsed = JSON.parse(value);
|
|
4173
|
+
if (parsed === null || typeof parsed === "string" || typeof parsed === "number" || typeof parsed === "boolean" || Array.isArray(parsed) && parsed.every((item) => typeof item === "string")) {
|
|
4174
|
+
return parsed;
|
|
4175
|
+
}
|
|
4176
|
+
throw new Error(`${label} filter JSON values must be string, string[], number, boolean, or null`);
|
|
4177
|
+
}
|
|
4178
|
+
function parseMatcherExpression(value, label) {
|
|
4179
|
+
const negativeSeparator = value.indexOf("!=");
|
|
4180
|
+
if (negativeSeparator > 0) {
|
|
4181
|
+
return {
|
|
4182
|
+
path: value.slice(0, negativeSeparator),
|
|
4183
|
+
rawValue: value.slice(negativeSeparator + 2),
|
|
4184
|
+
negated: true
|
|
4185
|
+
};
|
|
4186
|
+
}
|
|
4187
|
+
const separator = value.indexOf("=");
|
|
4188
|
+
if (separator <= 0)
|
|
4189
|
+
throw new Error(`Invalid ${label} filter, expected path=value or path!=value: ${value}`);
|
|
4190
|
+
return {
|
|
4191
|
+
path: value.slice(0, separator),
|
|
4192
|
+
rawValue: value.slice(separator + 1),
|
|
4193
|
+
negated: false
|
|
4194
|
+
};
|
|
4195
|
+
}
|
|
4196
|
+
function parseJsonObject3(value, fallback) {
|
|
4197
|
+
if (!value)
|
|
4198
|
+
return fallback;
|
|
4199
|
+
const parsed = JSON.parse(value);
|
|
4200
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
4201
|
+
throw new Error("Expected a JSON object");
|
|
4202
|
+
}
|
|
4203
|
+
return parsed;
|
|
4204
|
+
}
|
|
4205
|
+
function parseHeaders(values) {
|
|
4206
|
+
if (!values?.length)
|
|
4207
|
+
return;
|
|
4208
|
+
const headers = {};
|
|
4209
|
+
for (const value of values) {
|
|
4210
|
+
const separator = value.indexOf("=");
|
|
4211
|
+
if (separator === -1)
|
|
4212
|
+
throw new Error(`Invalid header, expected name=value: ${value}`);
|
|
4213
|
+
headers[value.slice(0, separator)] = value.slice(separator + 1);
|
|
4214
|
+
}
|
|
4215
|
+
return headers;
|
|
4216
|
+
}
|
|
4217
|
+
function createClient(options) {
|
|
4218
|
+
if (options.createClient)
|
|
4219
|
+
return options.createClient();
|
|
4220
|
+
return new EventsClient({ store: new JsonEventsStore(options.dataDir) });
|
|
4221
|
+
}
|
|
4222
|
+
function print(value, json2, text) {
|
|
4223
|
+
if (json2)
|
|
4224
|
+
console.log(JSON.stringify(value, null, 2));
|
|
4225
|
+
else
|
|
4226
|
+
console.log(text);
|
|
4227
|
+
}
|
|
4228
|
+
function hasJsonOption(options) {
|
|
4229
|
+
return Boolean(options?.json || options?.opts?.().json || options?.optsWithGlobals?.().json || options?.parent?.opts?.().json || options?.parent?.optsWithGlobals?.().json);
|
|
4230
|
+
}
|
|
4231
|
+
function wantsJson(actionOptions, command) {
|
|
4232
|
+
return hasJsonOption(actionOptions) || hasJsonOption(command);
|
|
4233
|
+
}
|
|
4234
|
+
function registerChannelCommands(program, options) {
|
|
4235
|
+
const channels = program.command(options.channelsCommandName ?? "channels").description("Manage Hasna event channels");
|
|
4236
|
+
channels.command("add").description("Add or replace a channel").argument("<target>", "Webhook URL or command binary").requiredOption("--id <id>", "Channel identifier").option("--transport <kind>", "Transport kind: webhook or command", "webhook").option("--name <name>", "Display name").option("--type <pattern>", "Event type filter, e.g. todos.task.*").option("--source <pattern>", "Event source filter").option("--subject <pattern>", "Event subject filter").option("--severity <pattern>", "Event severity filter").option("--data <path=value...>", "Event data field filter; string values, path!=value negatives, array-member matching, dot paths, * segment wildcard, ** recursive wildcard", collectValues, []).option("--metadata <path=value...>", "Event metadata field filter; string values, path!=value negatives, array-member matching, dot paths, * segment wildcard, ** recursive wildcard", collectValues, []).option("--data-json <path=json...>", "Event data field filter with typed JSON value; path!=json negatives supported", collectValues, []).option("--metadata-json <path=json...>", "Event metadata field filter with typed JSON value; path!=json negatives supported", collectValues, []).option("--secret <secret>", "Webhook HMAC secret").option("--header <name=value...>", "Webhook header", collectValues, []).option("--arg <arg...>", "Command argument", collectValues, []).option("--timeout-ms <ms>", "Transport timeout in milliseconds", parseNumber).option("--retry-attempts <n>", "Maximum delivery attempts", parseNumber).option("--retry-backoff-ms <ms>", "Initial retry backoff in milliseconds", parseNumber).option("--redact <path...>", "Event field path to redact before delivery", collectValues, []).option("--disabled", "Create channel disabled", false).option("-j, --json", "Print JSON output", false).action(async (target, actionOptions, command) => {
|
|
4237
|
+
const timestamp = new Date().toISOString();
|
|
4238
|
+
const channel = {
|
|
4239
|
+
id: actionOptions.id,
|
|
4240
|
+
name: actionOptions.name,
|
|
4241
|
+
enabled: !actionOptions.disabled,
|
|
4242
|
+
transport: actionOptions.transport,
|
|
4243
|
+
filters: parseFilterOptions(actionOptions),
|
|
4244
|
+
retry: actionOptions.retryAttempts || actionOptions.retryBackoffMs ? { maxAttempts: actionOptions.retryAttempts, backoffMs: actionOptions.retryBackoffMs } : undefined,
|
|
4245
|
+
redact: actionOptions.redact?.length ? { paths: actionOptions.redact } : undefined,
|
|
4246
|
+
createdAt: timestamp,
|
|
4247
|
+
updatedAt: timestamp
|
|
4248
|
+
};
|
|
4249
|
+
if (actionOptions.transport === "webhook") {
|
|
4250
|
+
channel.webhook = { url: target, secret: actionOptions.secret, headers: parseHeaders(actionOptions.header), timeoutMs: actionOptions.timeoutMs };
|
|
4251
|
+
} else if (actionOptions.transport === "command") {
|
|
4252
|
+
channel.command = { command: target, args: actionOptions.arg ?? [], timeoutMs: actionOptions.timeoutMs };
|
|
4253
|
+
} else {
|
|
4254
|
+
throw new Error(`Transport ${actionOptions.transport} is reserved for future use and cannot be added yet`);
|
|
4255
|
+
}
|
|
4256
|
+
const saved = await createClient(options).addChannel(channel);
|
|
4257
|
+
print(sanitizeChannelForOutput(saved), wantsJson(actionOptions, command), `Added ${saved.transport} channel ${saved.id}`);
|
|
4258
|
+
});
|
|
4259
|
+
channels.command("list").description("List configured channels").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
|
|
4260
|
+
const channels2 = await createClient(options).listChannels();
|
|
4261
|
+
if (wantsJson(actionOptions, command)) {
|
|
4262
|
+
console.log(JSON.stringify(sanitizeChannelsForOutput(channels2), null, 2));
|
|
4263
|
+
return;
|
|
4264
|
+
}
|
|
4265
|
+
if (!channels2.length) {
|
|
4266
|
+
console.log("No channels configured.");
|
|
4267
|
+
return;
|
|
4268
|
+
}
|
|
4269
|
+
for (const channel of channels2) {
|
|
4270
|
+
console.log(`${channel.id} ${channel.enabled ? "enabled" : "disabled"} ${channel.transport} ${channel.webhook?.url ?? channel.command?.command ?? channel.transport}`);
|
|
4271
|
+
}
|
|
4272
|
+
});
|
|
4273
|
+
channels.command("status").description("Show events channel storage status").option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
|
|
4274
|
+
const status = await getEventsStatus(options.dataDir);
|
|
4275
|
+
print(status, wantsJson(actionOptions, command), `events dataDir: ${status.dataDir}`);
|
|
4276
|
+
});
|
|
4277
|
+
channels.command("remove").description("Remove a channel").argument("<id>", "Channel identifier").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
|
|
4278
|
+
const removed = await createClient(options).removeChannel(id);
|
|
4279
|
+
print({ removed }, wantsJson(actionOptions, command), removed ? `Removed ${id}` : `Channel not found: ${id}`);
|
|
4280
|
+
});
|
|
4281
|
+
channels.command("test").description("Send a test event to one channel").argument("<id>", "Channel identifier").option("--source <source>", "Event source override").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events test delivery").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("--honor-filters", "Skip delivery when the sample event does not match channel filters", false).option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
|
|
4282
|
+
const result = await createClient(options).testChannel(id, {
|
|
4283
|
+
source: actionOptions.source ?? options.source,
|
|
4284
|
+
type: actionOptions.type,
|
|
4285
|
+
subject: actionOptions.subject ?? id,
|
|
4286
|
+
message: actionOptions.message,
|
|
4287
|
+
data: parseJsonObject3(actionOptions.data, { test: true }),
|
|
4288
|
+
metadata: parseJsonObject3(actionOptions.metadata, {})
|
|
4289
|
+
}, { honorFilters: actionOptions.honorFilters });
|
|
4290
|
+
print(result, wantsJson(actionOptions, command), `${result.status}: ${result.channelId}`);
|
|
4291
|
+
});
|
|
4292
|
+
channels.command("match").description("Check whether a sample event matches one channel without delivering").argument("<id>", "Channel identifier").option("--source <source>", "Event source override").option("--type <type>", "Event type", "events.test").option("--subject <subject>", "Event subject").option("--message <message>", "Event message", "Hasna events match preview").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("-j, --json", "Print JSON output", false).action(async (id, actionOptions, command) => {
|
|
4293
|
+
const result = await createClient(options).matchChannel(id, {
|
|
4294
|
+
source: actionOptions.source ?? options.source,
|
|
4295
|
+
type: actionOptions.type,
|
|
4296
|
+
subject: actionOptions.subject ?? id,
|
|
4297
|
+
message: actionOptions.message,
|
|
4298
|
+
data: parseJsonObject3(actionOptions.data, { test: true }),
|
|
4299
|
+
metadata: parseJsonObject3(actionOptions.metadata, {})
|
|
4300
|
+
});
|
|
4301
|
+
print(result, wantsJson(actionOptions, command), `${result.matched ? "matched" : "skipped"}: ${result.channelId}`);
|
|
4302
|
+
});
|
|
4303
|
+
return channels;
|
|
4304
|
+
}
|
|
4305
|
+
function registerEventCommands(program, options) {
|
|
4306
|
+
const events = program.command(options.eventsCommandName ?? "events").description("Emit, list, and replay Hasna events");
|
|
4307
|
+
events.command("emit").description("Emit an event from this app").argument("<type>", "Event type").option("--source <source>", "Event source override").option("--subject <subject>", "Event subject").option("--severity <severity>", "Event severity", "info").option("--message <message>", "Event message").option("--dedupe-key <key>", "Dedupe key").option("--data <json>", "Event data JSON object").option("--metadata <json>", "Event metadata JSON object").option("--no-deliver", "Record without delivering").option("--no-dedupe", "Allow duplicate id/dedupeKey events").option("-j, --json", "Print JSON output", false).action(async (type, actionOptions, command) => {
|
|
4308
|
+
const result = await createClient(options).emit({
|
|
4309
|
+
source: actionOptions.source ?? options.source,
|
|
4310
|
+
type,
|
|
4311
|
+
subject: actionOptions.subject,
|
|
4312
|
+
severity: actionOptions.severity,
|
|
4313
|
+
message: actionOptions.message,
|
|
4314
|
+
dedupeKey: actionOptions.dedupeKey,
|
|
4315
|
+
data: parseJsonObject3(actionOptions.data, {}),
|
|
4316
|
+
metadata: parseJsonObject3(actionOptions.metadata, {})
|
|
4317
|
+
}, { deliver: actionOptions.deliver, dedupe: actionOptions.dedupe });
|
|
4318
|
+
print(result, wantsJson(actionOptions, command), `${result.deduped ? "Deduped" : "Emitted"} ${result.event.id} to ${result.deliveries.length} channel(s)`);
|
|
4319
|
+
});
|
|
4320
|
+
events.command("list").description("List recorded events").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--limit <n>", "Limit results", parseNumber).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
|
|
4321
|
+
let rows = await createClient(options).listEvents();
|
|
4322
|
+
if (actionOptions.source)
|
|
4323
|
+
rows = rows.filter((event) => event.source === actionOptions.source);
|
|
4324
|
+
if (actionOptions.type)
|
|
4325
|
+
rows = rows.filter((event) => event.type === actionOptions.type);
|
|
4326
|
+
if (actionOptions.limit)
|
|
4327
|
+
rows = rows.slice(-actionOptions.limit);
|
|
4328
|
+
if (wantsJson(actionOptions, command)) {
|
|
4329
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
4330
|
+
return;
|
|
4331
|
+
}
|
|
4332
|
+
if (!rows.length) {
|
|
4333
|
+
console.log("No events recorded.");
|
|
4334
|
+
return;
|
|
4335
|
+
}
|
|
4336
|
+
for (const event of rows)
|
|
4337
|
+
console.log(`${event.time} ${event.id} ${event.source} ${event.type} ${event.severity}`);
|
|
4338
|
+
});
|
|
4339
|
+
events.command("replay").description("Replay recorded events").option("--id <id>", "Replay one event id").option("--source <source>", "Filter by source").option("--type <type>", "Filter by type").option("--dry-run", "Preview without delivery", false).option("-j, --json", "Print JSON output", false).action(async (actionOptions, command) => {
|
|
4340
|
+
const result = await createClient(options).replay({
|
|
4341
|
+
eventId: actionOptions.id,
|
|
4342
|
+
source: actionOptions.source,
|
|
4343
|
+
type: actionOptions.type,
|
|
4344
|
+
dryRun: actionOptions.dryRun
|
|
4345
|
+
});
|
|
4346
|
+
print(result, wantsJson(actionOptions, command), `Replayed ${result.events.length} event(s), ${result.deliveries.length} delivery result(s)`);
|
|
4347
|
+
});
|
|
4348
|
+
return events;
|
|
4349
|
+
}
|
|
4350
|
+
function registerEventsCommands(program, options) {
|
|
4351
|
+
registerChannelCommands(program, options);
|
|
4352
|
+
registerEventCommands(program, options);
|
|
4353
|
+
}
|
|
4354
|
+
function parseNumber(value) {
|
|
4355
|
+
const parsed = Number(value);
|
|
4356
|
+
if (!Number.isFinite(parsed))
|
|
4357
|
+
throw new Error(`Expected a number, got ${value}`);
|
|
4358
|
+
return parsed;
|
|
4359
|
+
}
|
|
4360
|
+
function collectValues(value, previous) {
|
|
4361
|
+
previous.push(value);
|
|
4362
|
+
return previous;
|
|
4363
|
+
}
|
|
4364
|
+
|
|
4365
|
+
// node_modules/.pnpm/commander@13.1.0/node_modules/commander/esm.mjs
|
|
4366
|
+
var import__ = __toESM(require_commander(), 1);
|
|
4367
|
+
var {
|
|
4368
|
+
program,
|
|
4369
|
+
createCommand,
|
|
4370
|
+
createArgument,
|
|
4371
|
+
createOption,
|
|
4372
|
+
CommanderError,
|
|
4373
|
+
InvalidArgumentError,
|
|
4374
|
+
InvalidOptionArgumentError,
|
|
4375
|
+
Command,
|
|
4376
|
+
Argument,
|
|
4377
|
+
Option,
|
|
4378
|
+
Help
|
|
4379
|
+
} = import__.default;
|
|
4380
|
+
|
|
4381
|
+
// node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/ansi-styles/index.js
|
|
4382
|
+
var ANSI_BACKGROUND_OFFSET = 10;
|
|
4383
|
+
var wrapAnsi16 = (offset = 0) => (code) => `\x1B[${code + offset}m`;
|
|
4384
|
+
var wrapAnsi256 = (offset = 0) => (code) => `\x1B[${38 + offset};5;${code}m`;
|
|
4385
|
+
var wrapAnsi16m = (offset = 0) => (red, green, blue) => `\x1B[${38 + offset};2;${red};${green};${blue}m`;
|
|
4386
|
+
var styles = {
|
|
4387
|
+
modifier: {
|
|
4388
|
+
reset: [0, 0],
|
|
4389
|
+
bold: [1, 22],
|
|
4390
|
+
dim: [2, 22],
|
|
4391
|
+
italic: [3, 23],
|
|
4392
|
+
underline: [4, 24],
|
|
4393
|
+
overline: [53, 55],
|
|
4394
|
+
inverse: [7, 27],
|
|
4395
|
+
hidden: [8, 28],
|
|
4396
|
+
strikethrough: [9, 29]
|
|
4397
|
+
},
|
|
4398
|
+
color: {
|
|
4399
|
+
black: [30, 39],
|
|
4400
|
+
red: [31, 39],
|
|
4401
|
+
green: [32, 39],
|
|
4402
|
+
yellow: [33, 39],
|
|
4403
|
+
blue: [34, 39],
|
|
4404
|
+
magenta: [35, 39],
|
|
4405
|
+
cyan: [36, 39],
|
|
4406
|
+
white: [37, 39],
|
|
4407
|
+
blackBright: [90, 39],
|
|
4408
|
+
gray: [90, 39],
|
|
4409
|
+
grey: [90, 39],
|
|
4410
|
+
redBright: [91, 39],
|
|
4411
|
+
greenBright: [92, 39],
|
|
4412
|
+
yellowBright: [93, 39],
|
|
4413
|
+
blueBright: [94, 39],
|
|
4414
|
+
magentaBright: [95, 39],
|
|
4415
|
+
cyanBright: [96, 39],
|
|
4416
|
+
whiteBright: [97, 39]
|
|
4417
|
+
},
|
|
4418
|
+
bgColor: {
|
|
4419
|
+
bgBlack: [40, 49],
|
|
4420
|
+
bgRed: [41, 49],
|
|
4421
|
+
bgGreen: [42, 49],
|
|
4422
|
+
bgYellow: [43, 49],
|
|
4423
|
+
bgBlue: [44, 49],
|
|
4424
|
+
bgMagenta: [45, 49],
|
|
4425
|
+
bgCyan: [46, 49],
|
|
4426
|
+
bgWhite: [47, 49],
|
|
4427
|
+
bgBlackBright: [100, 49],
|
|
4428
|
+
bgGray: [100, 49],
|
|
4429
|
+
bgGrey: [100, 49],
|
|
4430
|
+
bgRedBright: [101, 49],
|
|
4431
|
+
bgGreenBright: [102, 49],
|
|
4432
|
+
bgYellowBright: [103, 49],
|
|
4433
|
+
bgBlueBright: [104, 49],
|
|
4434
|
+
bgMagentaBright: [105, 49],
|
|
4435
|
+
bgCyanBright: [106, 49],
|
|
4436
|
+
bgWhiteBright: [107, 49]
|
|
4437
|
+
}
|
|
4438
|
+
};
|
|
4439
|
+
var modifierNames = Object.keys(styles.modifier);
|
|
4440
|
+
var foregroundColorNames = Object.keys(styles.color);
|
|
4441
|
+
var backgroundColorNames = Object.keys(styles.bgColor);
|
|
4442
|
+
var colorNames = [...foregroundColorNames, ...backgroundColorNames];
|
|
4443
|
+
function assembleStyles() {
|
|
4444
|
+
const codes = new Map;
|
|
4445
|
+
for (const [groupName, group] of Object.entries(styles)) {
|
|
4446
|
+
for (const [styleName, style] of Object.entries(group)) {
|
|
4447
|
+
styles[styleName] = {
|
|
4448
|
+
open: `\x1B[${style[0]}m`,
|
|
4449
|
+
close: `\x1B[${style[1]}m`
|
|
4450
|
+
};
|
|
4451
|
+
group[styleName] = styles[styleName];
|
|
4452
|
+
codes.set(style[0], style[1]);
|
|
4453
|
+
}
|
|
4454
|
+
Object.defineProperty(styles, groupName, {
|
|
4455
|
+
value: group,
|
|
4456
|
+
enumerable: false
|
|
4457
|
+
});
|
|
4458
|
+
}
|
|
4459
|
+
Object.defineProperty(styles, "codes", {
|
|
4460
|
+
value: codes,
|
|
4461
|
+
enumerable: false
|
|
4462
|
+
});
|
|
4463
|
+
styles.color.close = "\x1B[39m";
|
|
4464
|
+
styles.bgColor.close = "\x1B[49m";
|
|
4465
|
+
styles.color.ansi = wrapAnsi16();
|
|
4466
|
+
styles.color.ansi256 = wrapAnsi256();
|
|
4467
|
+
styles.color.ansi16m = wrapAnsi16m();
|
|
4468
|
+
styles.bgColor.ansi = wrapAnsi16(ANSI_BACKGROUND_OFFSET);
|
|
4469
|
+
styles.bgColor.ansi256 = wrapAnsi256(ANSI_BACKGROUND_OFFSET);
|
|
4470
|
+
styles.bgColor.ansi16m = wrapAnsi16m(ANSI_BACKGROUND_OFFSET);
|
|
4471
|
+
Object.defineProperties(styles, {
|
|
4472
|
+
rgbToAnsi256: {
|
|
4473
|
+
value(red, green, blue) {
|
|
4474
|
+
if (red === green && green === blue) {
|
|
4475
|
+
if (red < 8) {
|
|
4476
|
+
return 16;
|
|
4477
|
+
}
|
|
4478
|
+
if (red > 248) {
|
|
4479
|
+
return 231;
|
|
4480
|
+
}
|
|
4481
|
+
return Math.round((red - 8) / 247 * 24) + 232;
|
|
4482
|
+
}
|
|
4483
|
+
return 16 + 36 * Math.round(red / 255 * 5) + 6 * Math.round(green / 255 * 5) + Math.round(blue / 255 * 5);
|
|
4484
|
+
},
|
|
4485
|
+
enumerable: false
|
|
4486
|
+
},
|
|
4487
|
+
hexToRgb: {
|
|
4488
|
+
value(hex) {
|
|
4489
|
+
const matches = /[a-f\d]{6}|[a-f\d]{3}/i.exec(hex.toString(16));
|
|
4490
|
+
if (!matches) {
|
|
4491
|
+
return [0, 0, 0];
|
|
4492
|
+
}
|
|
4493
|
+
let [colorString] = matches;
|
|
4494
|
+
if (colorString.length === 3) {
|
|
4495
|
+
colorString = [...colorString].map((character) => character + character).join("");
|
|
4496
|
+
}
|
|
4497
|
+
const integer = Number.parseInt(colorString, 16);
|
|
4498
|
+
return [
|
|
4499
|
+
integer >> 16 & 255,
|
|
4500
|
+
integer >> 8 & 255,
|
|
4501
|
+
integer & 255
|
|
4502
|
+
];
|
|
4503
|
+
},
|
|
4504
|
+
enumerable: false
|
|
4505
|
+
},
|
|
4506
|
+
hexToAnsi256: {
|
|
4507
|
+
value: (hex) => styles.rgbToAnsi256(...styles.hexToRgb(hex)),
|
|
4508
|
+
enumerable: false
|
|
4509
|
+
},
|
|
4510
|
+
ansi256ToAnsi: {
|
|
4511
|
+
value(code) {
|
|
4512
|
+
if (code < 8) {
|
|
4513
|
+
return 30 + code;
|
|
4514
|
+
}
|
|
4515
|
+
if (code < 16) {
|
|
4516
|
+
return 90 + (code - 8);
|
|
4517
|
+
}
|
|
4518
|
+
let red;
|
|
4519
|
+
let green;
|
|
4520
|
+
let blue;
|
|
4521
|
+
if (code >= 232) {
|
|
4522
|
+
red = ((code - 232) * 10 + 8) / 255;
|
|
4523
|
+
green = red;
|
|
4524
|
+
blue = red;
|
|
4525
|
+
} else {
|
|
4526
|
+
code -= 16;
|
|
4527
|
+
const remainder = code % 36;
|
|
4528
|
+
red = Math.floor(code / 36) / 5;
|
|
4529
|
+
green = Math.floor(remainder / 6) / 5;
|
|
4530
|
+
blue = remainder % 6 / 5;
|
|
4531
|
+
}
|
|
4532
|
+
const value = Math.max(red, green, blue) * 2;
|
|
4533
|
+
if (value === 0) {
|
|
4534
|
+
return 30;
|
|
4535
|
+
}
|
|
4536
|
+
let result = 30 + (Math.round(blue) << 2 | Math.round(green) << 1 | Math.round(red));
|
|
4537
|
+
if (value === 2) {
|
|
4538
|
+
result += 60;
|
|
4539
|
+
}
|
|
4540
|
+
return result;
|
|
4541
|
+
},
|
|
4542
|
+
enumerable: false
|
|
4543
|
+
},
|
|
4544
|
+
rgbToAnsi: {
|
|
4545
|
+
value: (red, green, blue) => styles.ansi256ToAnsi(styles.rgbToAnsi256(red, green, blue)),
|
|
4546
|
+
enumerable: false
|
|
4547
|
+
},
|
|
4548
|
+
hexToAnsi: {
|
|
4549
|
+
value: (hex) => styles.ansi256ToAnsi(styles.hexToAnsi256(hex)),
|
|
4550
|
+
enumerable: false
|
|
4551
|
+
}
|
|
4174
4552
|
});
|
|
4553
|
+
return styles;
|
|
4175
4554
|
}
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4555
|
+
var ansiStyles = assembleStyles();
|
|
4556
|
+
var ansi_styles_default = ansiStyles;
|
|
4557
|
+
|
|
4558
|
+
// node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/vendor/supports-color/index.js
|
|
4559
|
+
import process2 from "process";
|
|
4560
|
+
import os from "os";
|
|
4561
|
+
import tty from "tty";
|
|
4562
|
+
function hasFlag(flag, argv = globalThis.Deno ? globalThis.Deno.args : process2.argv) {
|
|
4563
|
+
const prefix = flag.startsWith("-") ? "" : flag.length === 1 ? "-" : "--";
|
|
4564
|
+
const position = argv.indexOf(prefix + flag);
|
|
4565
|
+
const terminatorPosition = argv.indexOf("--");
|
|
4566
|
+
return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition);
|
|
4180
4567
|
}
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4568
|
+
var { env } = process2;
|
|
4569
|
+
var flagForceColor;
|
|
4570
|
+
if (hasFlag("no-color") || hasFlag("no-colors") || hasFlag("color=false") || hasFlag("color=never")) {
|
|
4571
|
+
flagForceColor = 0;
|
|
4572
|
+
} else if (hasFlag("color") || hasFlag("colors") || hasFlag("color=true") || hasFlag("color=always")) {
|
|
4573
|
+
flagForceColor = 1;
|
|
4186
4574
|
}
|
|
4187
|
-
function
|
|
4188
|
-
|
|
4575
|
+
function envForceColor() {
|
|
4576
|
+
if ("FORCE_COLOR" in env) {
|
|
4577
|
+
if (env.FORCE_COLOR === "true") {
|
|
4578
|
+
return 1;
|
|
4579
|
+
}
|
|
4580
|
+
if (env.FORCE_COLOR === "false") {
|
|
4581
|
+
return 0;
|
|
4582
|
+
}
|
|
4583
|
+
return env.FORCE_COLOR.length === 0 ? 1 : Math.min(Number.parseInt(env.FORCE_COLOR, 10), 3);
|
|
4584
|
+
}
|
|
4189
4585
|
}
|
|
4190
|
-
function
|
|
4191
|
-
|
|
4586
|
+
function translateLevel(level) {
|
|
4587
|
+
if (level === 0) {
|
|
4588
|
+
return false;
|
|
4589
|
+
}
|
|
4590
|
+
return {
|
|
4591
|
+
level,
|
|
4592
|
+
hasBasic: true,
|
|
4593
|
+
has256: level >= 2,
|
|
4594
|
+
has16m: level >= 3
|
|
4595
|
+
};
|
|
4192
4596
|
}
|
|
4193
|
-
function
|
|
4194
|
-
const
|
|
4195
|
-
|
|
4196
|
-
|
|
4197
|
-
|
|
4198
|
-
|
|
4199
|
-
|
|
4597
|
+
function _supportsColor(haveStream, { streamIsTTY, sniffFlags = true } = {}) {
|
|
4598
|
+
const noFlagForceColor = envForceColor();
|
|
4599
|
+
if (noFlagForceColor !== undefined) {
|
|
4600
|
+
flagForceColor = noFlagForceColor;
|
|
4601
|
+
}
|
|
4602
|
+
const forceColor = sniffFlags ? flagForceColor : noFlagForceColor;
|
|
4603
|
+
if (forceColor === 0) {
|
|
4604
|
+
return 0;
|
|
4605
|
+
}
|
|
4606
|
+
if (sniffFlags) {
|
|
4607
|
+
if (hasFlag("color=16m") || hasFlag("color=full") || hasFlag("color=truecolor")) {
|
|
4608
|
+
return 3;
|
|
4200
4609
|
}
|
|
4201
|
-
if (
|
|
4202
|
-
return
|
|
4610
|
+
if (hasFlag("color=256")) {
|
|
4611
|
+
return 2;
|
|
4203
4612
|
}
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
|
|
4208
|
-
|
|
4613
|
+
}
|
|
4614
|
+
if ("TF_BUILD" in env && "AGENT_NAME" in env) {
|
|
4615
|
+
return 1;
|
|
4616
|
+
}
|
|
4617
|
+
if (haveStream && !streamIsTTY && forceColor === undefined) {
|
|
4618
|
+
return 0;
|
|
4619
|
+
}
|
|
4620
|
+
const min = forceColor || 0;
|
|
4621
|
+
if (env.TERM === "dumb") {
|
|
4622
|
+
return min;
|
|
4623
|
+
}
|
|
4624
|
+
if (process2.platform === "win32") {
|
|
4625
|
+
const osRelease = os.release().split(".");
|
|
4626
|
+
if (Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) {
|
|
4627
|
+
return Number(osRelease[2]) >= 14931 ? 3 : 2;
|
|
4209
4628
|
}
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4629
|
+
return 1;
|
|
4630
|
+
}
|
|
4631
|
+
if ("CI" in env) {
|
|
4632
|
+
if (["GITHUB_ACTIONS", "GITEA_ACTIONS", "CIRCLECI"].some((key) => (key in env))) {
|
|
4633
|
+
return 3;
|
|
4214
4634
|
}
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
return json({ error: "Missing Host header." }, 400);
|
|
4218
|
-
let link = null;
|
|
4219
|
-
try {
|
|
4220
|
-
link = await store.resolve(host, slug);
|
|
4221
|
-
} catch {
|
|
4222
|
-
return json({ error: "Shortlink not found.", slug, host }, 404);
|
|
4635
|
+
if (["TRAVIS", "APPVEYOR", "GITLAB_CI", "BUILDKITE", "DRONE"].some((sign) => (sign in env)) || env.CI_NAME === "codeship") {
|
|
4636
|
+
return 1;
|
|
4223
4637
|
}
|
|
4224
|
-
|
|
4225
|
-
|
|
4226
|
-
|
|
4227
|
-
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
} else {
|
|
4250
|
-
logRecordClickError(link);
|
|
4251
|
-
}
|
|
4638
|
+
return min;
|
|
4639
|
+
}
|
|
4640
|
+
if ("TEAMCITY_VERSION" in env) {
|
|
4641
|
+
return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0;
|
|
4642
|
+
}
|
|
4643
|
+
if (env.COLORTERM === "truecolor") {
|
|
4644
|
+
return 3;
|
|
4645
|
+
}
|
|
4646
|
+
if (env.TERM === "xterm-kitty") {
|
|
4647
|
+
return 3;
|
|
4648
|
+
}
|
|
4649
|
+
if (env.TERM === "xterm-ghostty") {
|
|
4650
|
+
return 3;
|
|
4651
|
+
}
|
|
4652
|
+
if (env.TERM === "wezterm") {
|
|
4653
|
+
return 3;
|
|
4654
|
+
}
|
|
4655
|
+
if ("TERM_PROGRAM" in env) {
|
|
4656
|
+
const version = Number.parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
|
|
4657
|
+
switch (env.TERM_PROGRAM) {
|
|
4658
|
+
case "iTerm.app": {
|
|
4659
|
+
return version >= 3 ? 3 : 2;
|
|
4660
|
+
}
|
|
4661
|
+
case "Apple_Terminal": {
|
|
4662
|
+
return 2;
|
|
4252
4663
|
}
|
|
4253
4664
|
}
|
|
4254
|
-
|
|
4255
|
-
|
|
4665
|
+
}
|
|
4666
|
+
if (/-256(color)?$/i.test(env.TERM)) {
|
|
4667
|
+
return 2;
|
|
4668
|
+
}
|
|
4669
|
+
if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) {
|
|
4670
|
+
return 1;
|
|
4671
|
+
}
|
|
4672
|
+
if ("COLORTERM" in env) {
|
|
4673
|
+
return 1;
|
|
4674
|
+
}
|
|
4675
|
+
return min;
|
|
4256
4676
|
}
|
|
4257
|
-
function
|
|
4258
|
-
const
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4677
|
+
function createSupportsColor(stream, options = {}) {
|
|
4678
|
+
const level = _supportsColor(stream, {
|
|
4679
|
+
streamIsTTY: stream && stream.isTTY,
|
|
4680
|
+
...options
|
|
4681
|
+
});
|
|
4682
|
+
return translateLevel(level);
|
|
4262
4683
|
}
|
|
4684
|
+
var supportsColor = {
|
|
4685
|
+
stdout: createSupportsColor({ isTTY: tty.isatty(1) }),
|
|
4686
|
+
stderr: createSupportsColor({ isTTY: tty.isatty(2) })
|
|
4687
|
+
};
|
|
4688
|
+
var supports_color_default = supportsColor;
|
|
4263
4689
|
|
|
4264
|
-
//
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
const
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4690
|
+
// node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/utilities.js
|
|
4691
|
+
function stringReplaceAll(string, substring, replacer) {
|
|
4692
|
+
let index = string.indexOf(substring);
|
|
4693
|
+
if (index === -1) {
|
|
4694
|
+
return string;
|
|
4695
|
+
}
|
|
4696
|
+
const substringLength = substring.length;
|
|
4697
|
+
let endIndex = 0;
|
|
4698
|
+
let returnValue = "";
|
|
4699
|
+
do {
|
|
4700
|
+
returnValue += string.slice(endIndex, index) + substring + replacer;
|
|
4701
|
+
endIndex = index + substringLength;
|
|
4702
|
+
index = string.indexOf(substring, endIndex);
|
|
4703
|
+
} while (index !== -1);
|
|
4704
|
+
returnValue += string.slice(endIndex);
|
|
4705
|
+
return returnValue;
|
|
4706
|
+
}
|
|
4707
|
+
function stringEncaseCRLFWithFirstIndex(string, prefix, postfix, index) {
|
|
4708
|
+
let endIndex = 0;
|
|
4709
|
+
let returnValue = "";
|
|
4710
|
+
do {
|
|
4711
|
+
const gotCR = string[index - 1] === "\r";
|
|
4712
|
+
returnValue += string.slice(endIndex, gotCR ? index - 1 : index) + prefix + (gotCR ? `\r
|
|
4713
|
+
` : `
|
|
4714
|
+
`) + postfix;
|
|
4715
|
+
endIndex = index + 1;
|
|
4716
|
+
index = string.indexOf(`
|
|
4717
|
+
`, endIndex);
|
|
4718
|
+
} while (index !== -1);
|
|
4719
|
+
returnValue += string.slice(endIndex);
|
|
4720
|
+
return returnValue;
|
|
4286
4721
|
}
|
|
4287
|
-
function generateWorkerScript() {
|
|
4288
|
-
return `export default {
|
|
4289
|
-
async fetch(request, env) {
|
|
4290
|
-
const origin = env.SHORTLINKS_ORIGIN;
|
|
4291
|
-
if (!origin) {
|
|
4292
|
-
return new Response("SHORTLINKS_ORIGIN is not configured", { status: 500 });
|
|
4293
|
-
}
|
|
4294
|
-
|
|
4295
|
-
const incoming = new URL(request.url);
|
|
4296
|
-
const upstream = new URL(incoming.pathname + incoming.search, origin);
|
|
4297
|
-
const headers = new Headers(request.headers);
|
|
4298
|
-
headers.set("x-forwarded-host", incoming.host);
|
|
4299
|
-
headers.set("x-shortlinks-worker", "cloudflare");
|
|
4300
4722
|
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
|
|
4723
|
+
// node_modules/.pnpm/chalk@5.6.2/node_modules/chalk/source/index.js
|
|
4724
|
+
var { stdout: stdoutColor, stderr: stderrColor } = supports_color_default;
|
|
4725
|
+
var GENERATOR = Symbol("GENERATOR");
|
|
4726
|
+
var STYLER = Symbol("STYLER");
|
|
4727
|
+
var IS_EMPTY = Symbol("IS_EMPTY");
|
|
4728
|
+
var levelMapping = [
|
|
4729
|
+
"ansi",
|
|
4730
|
+
"ansi",
|
|
4731
|
+
"ansi256",
|
|
4732
|
+
"ansi16m"
|
|
4733
|
+
];
|
|
4734
|
+
var styles2 = Object.create(null);
|
|
4735
|
+
var applyOptions = (object, options = {}) => {
|
|
4736
|
+
if (options.level && !(Number.isInteger(options.level) && options.level >= 0 && options.level <= 3)) {
|
|
4737
|
+
throw new Error("The `level` option should be an integer from 0 to 3");
|
|
4307
4738
|
}
|
|
4739
|
+
const colorLevel = stdoutColor ? stdoutColor.level : 0;
|
|
4740
|
+
object.level = options.level === undefined ? colorLevel : options.level;
|
|
4308
4741
|
};
|
|
4309
|
-
|
|
4742
|
+
var chalkFactory = (options) => {
|
|
4743
|
+
const chalk = (...strings) => strings.join(" ");
|
|
4744
|
+
applyOptions(chalk, options);
|
|
4745
|
+
Object.setPrototypeOf(chalk, createChalk.prototype);
|
|
4746
|
+
return chalk;
|
|
4747
|
+
};
|
|
4748
|
+
function createChalk(options) {
|
|
4749
|
+
return chalkFactory(options);
|
|
4310
4750
|
}
|
|
4311
|
-
|
|
4312
|
-
|
|
4313
|
-
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4317
|
-
|
|
4318
|
-
|
|
4319
|
-
|
|
4320
|
-
compatibility_date = "2026-05-01"
|
|
4321
|
-
|
|
4322
|
-
[vars]
|
|
4323
|
-
SHORTLINKS_ORIGIN = "${options.origin || "https://shortlinks.example.com"}"
|
|
4324
|
-
`);
|
|
4325
|
-
return { workerPath, wranglerPath };
|
|
4751
|
+
Object.setPrototypeOf(createChalk.prototype, Function.prototype);
|
|
4752
|
+
for (const [styleName, style] of Object.entries(ansi_styles_default)) {
|
|
4753
|
+
styles2[styleName] = {
|
|
4754
|
+
get() {
|
|
4755
|
+
const builder = createBuilder(this, createStyler(style.open, style.close, this[STYLER]), this[IS_EMPTY]);
|
|
4756
|
+
Object.defineProperty(this, styleName, { value: builder });
|
|
4757
|
+
return builder;
|
|
4758
|
+
}
|
|
4759
|
+
};
|
|
4326
4760
|
}
|
|
4327
|
-
|
|
4328
|
-
|
|
4329
|
-
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
const email = process.env.CLOUDFLARE_EMAIL;
|
|
4333
|
-
if (apiKey && email) {
|
|
4334
|
-
return {
|
|
4335
|
-
"x-auth-key": apiKey,
|
|
4336
|
-
"x-auth-email": email
|
|
4337
|
-
};
|
|
4761
|
+
styles2.visible = {
|
|
4762
|
+
get() {
|
|
4763
|
+
const builder = createBuilder(this, this[STYLER], true);
|
|
4764
|
+
Object.defineProperty(this, "visible", { value: builder });
|
|
4765
|
+
return builder;
|
|
4338
4766
|
}
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
headers: {
|
|
4345
|
-
...cloudflareAuthHeaders(token),
|
|
4346
|
-
"content-type": "application/json",
|
|
4347
|
-
...init.headers || {}
|
|
4767
|
+
};
|
|
4768
|
+
var getModelAnsi = (model, level, type, ...arguments_) => {
|
|
4769
|
+
if (model === "rgb") {
|
|
4770
|
+
if (level === "ansi16m") {
|
|
4771
|
+
return ansi_styles_default[type].ansi16m(...arguments_);
|
|
4348
4772
|
}
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
throw new Error(`Cloudflare API failed: ${message}`);
|
|
4773
|
+
if (level === "ansi256") {
|
|
4774
|
+
return ansi_styles_default[type].ansi256(ansi_styles_default.rgbToAnsi256(...arguments_));
|
|
4775
|
+
}
|
|
4776
|
+
return ansi_styles_default[type].ansi(ansi_styles_default.rgbToAnsi(...arguments_));
|
|
4354
4777
|
}
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
function candidateZones(hostname2) {
|
|
4358
|
-
const parts = normalizeHostname(hostname2).split(".");
|
|
4359
|
-
const candidates = [];
|
|
4360
|
-
for (let i = 0;i < parts.length - 1; i += 1) {
|
|
4361
|
-
candidates.push(parts.slice(i).join("."));
|
|
4778
|
+
if (model === "hex") {
|
|
4779
|
+
return getModelAnsi("rgb", level, type, ...ansi_styles_default.hexToRgb(...arguments_));
|
|
4362
4780
|
}
|
|
4363
|
-
return
|
|
4781
|
+
return ansi_styles_default[type][model](...arguments_);
|
|
4782
|
+
};
|
|
4783
|
+
var usedModels = ["rgb", "hex", "ansi256"];
|
|
4784
|
+
for (const model of usedModels) {
|
|
4785
|
+
styles2[model] = {
|
|
4786
|
+
get() {
|
|
4787
|
+
const { level } = this;
|
|
4788
|
+
return function(...arguments_) {
|
|
4789
|
+
const styler = createStyler(getModelAnsi(model, levelMapping[level], "color", ...arguments_), ansi_styles_default.color.close, this[STYLER]);
|
|
4790
|
+
return createBuilder(this, styler, this[IS_EMPTY]);
|
|
4791
|
+
};
|
|
4792
|
+
}
|
|
4793
|
+
};
|
|
4794
|
+
const bgModel = "bg" + model[0].toUpperCase() + model.slice(1);
|
|
4795
|
+
styles2[bgModel] = {
|
|
4796
|
+
get() {
|
|
4797
|
+
const { level } = this;
|
|
4798
|
+
return function(...arguments_) {
|
|
4799
|
+
const styler = createStyler(getModelAnsi(model, levelMapping[level], "bgColor", ...arguments_), ansi_styles_default.bgColor.close, this[STYLER]);
|
|
4800
|
+
return createBuilder(this, styler, this[IS_EMPTY]);
|
|
4801
|
+
};
|
|
4802
|
+
}
|
|
4803
|
+
};
|
|
4364
4804
|
}
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
|
|
4805
|
+
var proto = Object.defineProperties(() => {}, {
|
|
4806
|
+
...styles2,
|
|
4807
|
+
level: {
|
|
4808
|
+
enumerable: true,
|
|
4809
|
+
get() {
|
|
4810
|
+
return this[GENERATOR].level;
|
|
4811
|
+
},
|
|
4812
|
+
set(level) {
|
|
4813
|
+
this[GENERATOR].level = level;
|
|
4814
|
+
}
|
|
4370
4815
|
}
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
if (options.dryRun)
|
|
4382
|
-
return plan;
|
|
4383
|
-
const zoneId = options.zoneId || await findCloudflareZoneId(plan.hostname, token);
|
|
4384
|
-
const existing = await cloudflareRequest(token, `/zones/${zoneId}/dns_records?type=CNAME&name=${encodeURIComponent(plan.hostname)}`);
|
|
4385
|
-
const payload = JSON.stringify(plan.dnsRecord);
|
|
4386
|
-
if (existing[0]?.id) {
|
|
4387
|
-
const updated = await cloudflareRequest(token, `/zones/${zoneId}/dns_records/${existing[0].id}`, {
|
|
4388
|
-
method: "PUT",
|
|
4389
|
-
body: payload
|
|
4390
|
-
});
|
|
4391
|
-
return { id: updated.id, action: "updated" };
|
|
4816
|
+
});
|
|
4817
|
+
var createStyler = (open, close, parent) => {
|
|
4818
|
+
let openAll;
|
|
4819
|
+
let closeAll;
|
|
4820
|
+
if (parent === undefined) {
|
|
4821
|
+
openAll = open;
|
|
4822
|
+
closeAll = close;
|
|
4823
|
+
} else {
|
|
4824
|
+
openAll = parent.openAll + open;
|
|
4825
|
+
closeAll = close + parent.closeAll;
|
|
4392
4826
|
}
|
|
4393
|
-
|
|
4394
|
-
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
|
|
4398
|
-
|
|
4827
|
+
return {
|
|
4828
|
+
open,
|
|
4829
|
+
close,
|
|
4830
|
+
openAll,
|
|
4831
|
+
closeAll,
|
|
4832
|
+
parent
|
|
4833
|
+
};
|
|
4834
|
+
};
|
|
4835
|
+
var createBuilder = (self, _styler, _isEmpty) => {
|
|
4836
|
+
const builder = (...arguments_) => applyStyle(builder, arguments_.length === 1 ? "" + arguments_[0] : arguments_.join(" "));
|
|
4837
|
+
Object.setPrototypeOf(builder, proto);
|
|
4838
|
+
builder[GENERATOR] = self;
|
|
4839
|
+
builder[STYLER] = _styler;
|
|
4840
|
+
builder[IS_EMPTY] = _isEmpty;
|
|
4841
|
+
return builder;
|
|
4842
|
+
};
|
|
4843
|
+
var applyStyle = (self, string) => {
|
|
4844
|
+
if (self.level <= 0 || !string) {
|
|
4845
|
+
return self[IS_EMPTY] ? "" : string;
|
|
4846
|
+
}
|
|
4847
|
+
let styler = self[STYLER];
|
|
4848
|
+
if (styler === undefined) {
|
|
4849
|
+
return string;
|
|
4850
|
+
}
|
|
4851
|
+
const { openAll, closeAll } = styler;
|
|
4852
|
+
if (string.includes("\x1B")) {
|
|
4853
|
+
while (styler !== undefined) {
|
|
4854
|
+
string = stringReplaceAll(string, styler.close, styler.open);
|
|
4855
|
+
styler = styler.parent;
|
|
4856
|
+
}
|
|
4857
|
+
}
|
|
4858
|
+
const lfIndex = string.indexOf(`
|
|
4859
|
+
`);
|
|
4860
|
+
if (lfIndex !== -1) {
|
|
4861
|
+
string = stringEncaseCRLFWithFirstIndex(string, closeAll, openAll, lfIndex);
|
|
4862
|
+
}
|
|
4863
|
+
return openAll + string + closeAll;
|
|
4864
|
+
};
|
|
4865
|
+
Object.defineProperties(createChalk.prototype, styles2);
|
|
4866
|
+
var chalk = createChalk();
|
|
4867
|
+
var chalkStderr = createChalk({ level: stderrColor ? stderrColor.level : 0 });
|
|
4868
|
+
var source_default = chalk;
|
|
4869
|
+
|
|
4870
|
+
// src/cli/index.ts
|
|
4871
|
+
import { existsSync as existsSync4, readFileSync as readFileSync3 } from "fs";
|
|
4872
|
+
import { dirname as dirname3, join as join6 } from "path";
|
|
4873
|
+
import { fileURLToPath } from "url";
|
|
4874
|
+
import { spawnSync as spawnSync3 } from "child_process";
|
|
4399
4875
|
|
|
4400
4876
|
// src/domains-cli.ts
|
|
4401
4877
|
import { spawnSync } from "child_process";
|
|
@@ -4578,14 +5054,20 @@ function withStore(fn) {
|
|
|
4578
5054
|
}
|
|
4579
5055
|
function storeMode() {
|
|
4580
5056
|
const opts = program2.opts();
|
|
4581
|
-
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
5057
|
+
return parseShortlinksStoreMode(opts.store || process.env.HASNA_SHORTLINKS_STORE || process.env.SHORTLINKS_STORE || "local");
|
|
5058
|
+
}
|
|
5059
|
+
function runtimeEnv() {
|
|
5060
|
+
const opts = program2.opts();
|
|
5061
|
+
return opts.store ? { ...process.env, HASNA_SHORTLINKS_STORE: opts.store } : process.env;
|
|
5062
|
+
}
|
|
5063
|
+
function localStatsIfDatabaseExists(dbPath) {
|
|
5064
|
+
if (!existsSync4(dbPath))
|
|
5065
|
+
return null;
|
|
5066
|
+
return withStore((store) => store.totalStats());
|
|
4585
5067
|
}
|
|
4586
5068
|
async function withRuntimeStore(fn) {
|
|
4587
|
-
if (storeMode() === "
|
|
4588
|
-
const store2 = await PgShortlinksStore.
|
|
5069
|
+
if (storeMode() === "postgres") {
|
|
5070
|
+
const store2 = await PgShortlinksStore.fromEnv();
|
|
4589
5071
|
try {
|
|
4590
5072
|
return await fn(store2);
|
|
4591
5073
|
} finally {
|
|
@@ -4606,7 +5088,7 @@ function commandExists(command) {
|
|
|
4606
5088
|
const result = spawnSync3("which", [command], { encoding: "utf-8" });
|
|
4607
5089
|
return result.status === 0;
|
|
4608
5090
|
}
|
|
4609
|
-
program2.name("shortlinks").description("CLI-only shortlink manager with custom domains, click tracking, Cloudflare helpers, and
|
|
5091
|
+
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");
|
|
4610
5092
|
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) => {
|
|
4611
5093
|
try {
|
|
4612
5094
|
const result = await withRuntimeStore(async (store) => {
|
|
@@ -4866,9 +5348,9 @@ program2.command("stats [slug]").description("Show overall stats or stats for a
|
|
|
4866
5348
|
handleError(error);
|
|
4867
5349
|
}
|
|
4868
5350
|
});
|
|
4869
|
-
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").
|
|
5351
|
+
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) => {
|
|
4870
5352
|
try {
|
|
4871
|
-
const store =
|
|
5353
|
+
const store = storeMode() === "postgres" ? await PgShortlinksStore.fromEnv() : undefined;
|
|
4872
5354
|
const server = serveShortlinks({
|
|
4873
5355
|
store,
|
|
4874
5356
|
dbPath: program2.opts().db,
|
|
@@ -4876,7 +5358,7 @@ program2.command("serve").description("Run the redirect server that records clic
|
|
|
4876
5358
|
port: Number(opts.port),
|
|
4877
5359
|
defaultHost: opts.defaultHost
|
|
4878
5360
|
});
|
|
4879
|
-
const mode = store ? "
|
|
5361
|
+
const mode = store ? "postgres" : "local";
|
|
4880
5362
|
console.log(source_default.green(`shortlinks redirect server listening on http://${server.hostname}:${server.port} (${mode})`));
|
|
4881
5363
|
} catch (error) {
|
|
4882
5364
|
handleError(error);
|
|
@@ -4922,65 +5404,68 @@ cfCmd.command("dns <hostname>").description("Create or update the Cloudflare CNA
|
|
|
4922
5404
|
handleError(error);
|
|
4923
5405
|
}
|
|
4924
5406
|
});
|
|
4925
|
-
var
|
|
4926
|
-
|
|
5407
|
+
var postgresCmd = program2.command("postgres").description("Shortlinks-owned PostgreSQL runtime helpers");
|
|
5408
|
+
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) => {
|
|
4927
5409
|
try {
|
|
4928
|
-
const
|
|
4929
|
-
|
|
4930
|
-
|
|
5410
|
+
const conn = opts.connectionString || getShortlinksDatabaseUrl();
|
|
5411
|
+
if (!conn)
|
|
5412
|
+
throw new Error("HASNA_SHORTLINKS_DATABASE_URL or --connection-string is required.");
|
|
5413
|
+
const ssl = opts.ssl === false ? false : getShortlinksDatabaseSsl();
|
|
5414
|
+
if (opts.dryRun) {
|
|
5415
|
+
const result2 = {
|
|
5416
|
+
service: "shortlinks",
|
|
5417
|
+
dry_run: true,
|
|
5418
|
+
no_network: true,
|
|
5419
|
+
database: {
|
|
5420
|
+
configured: true,
|
|
5421
|
+
redacted_url: redactDatabaseUrl(conn),
|
|
5422
|
+
ssl
|
|
5423
|
+
},
|
|
5424
|
+
migrations: PG_MIGRATIONS.length
|
|
5425
|
+
};
|
|
5426
|
+
print2(result2, opts, () => console.log(JSON.stringify(result2, null, 2)));
|
|
5427
|
+
return;
|
|
5428
|
+
}
|
|
5429
|
+
const result = await applyPostgresMigrations(conn, PG_MIGRATIONS, { ssl });
|
|
4931
5430
|
print2(result, opts, () => console.log(JSON.stringify(result, null, 2)));
|
|
4932
5431
|
} catch (error) {
|
|
4933
5432
|
handleError(error);
|
|
4934
5433
|
}
|
|
4935
5434
|
});
|
|
4936
|
-
|
|
4937
|
-
const {
|
|
4938
|
-
getCloudConfig,
|
|
4939
|
-
getConnectionString,
|
|
4940
|
-
SqliteAdapter,
|
|
4941
|
-
PgAdapterAsync,
|
|
4942
|
-
listSqliteTables,
|
|
4943
|
-
listPgTables,
|
|
4944
|
-
syncPush,
|
|
4945
|
-
syncPull
|
|
4946
|
-
} = await import("@hasna/cloud");
|
|
4947
|
-
const config = getCloudConfig();
|
|
4948
|
-
if (config.mode === "local")
|
|
4949
|
-
throw new Error("Cloud mode is local. Run `cloud setup` first.");
|
|
4950
|
-
const local = new SqliteAdapter(getDatabasePath(program2.opts().db));
|
|
4951
|
-
const remote = new PgAdapterAsync(getConnectionString("shortlinks"));
|
|
5435
|
+
postgresCmd.command("status").description("Show local and PostgreSQL runtime configuration health without opening network connections").option("-j, --json", "Output JSON").action((opts) => {
|
|
4952
5436
|
try {
|
|
4953
|
-
const
|
|
4954
|
-
const
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
}
|
|
4964
|
-
print2({ service: "shortlinks", results }, opts, () => console.log(JSON.stringify({ service: "shortlinks", results }, null, 2)));
|
|
4965
|
-
} finally {
|
|
4966
|
-
local.close?.();
|
|
4967
|
-
await remote.close?.();
|
|
5437
|
+
const dbPath = getDatabasePath(program2.opts().db);
|
|
5438
|
+
const data = {
|
|
5439
|
+
...getShortlinksRuntimeStatus(runtimeEnv()),
|
|
5440
|
+
service: "shortlinks",
|
|
5441
|
+
db_path: dbPath,
|
|
5442
|
+
db_exists: existsSync4(dbPath)
|
|
5443
|
+
};
|
|
5444
|
+
print2(data, opts, () => console.log(JSON.stringify(data, null, 2)));
|
|
5445
|
+
} catch (error) {
|
|
5446
|
+
handleError(error);
|
|
4968
5447
|
}
|
|
4969
|
-
}
|
|
4970
|
-
|
|
4971
|
-
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));
|
|
4972
|
-
}
|
|
4973
|
-
cloudCmd.command("status").description("Show local and cloud configuration health").option("-j, --json", "Output JSON").action(async (opts) => {
|
|
5448
|
+
});
|
|
5449
|
+
postgresCmd.command("plan").description("Render a dry-run PostgreSQL setup plan").option("--schema-sql", "Include migration SQL").option("-j, --json", "Output JSON").action((opts) => {
|
|
4974
5450
|
try {
|
|
4975
|
-
const
|
|
4976
|
-
const stats = withStore((store) => store.totalStats());
|
|
4977
|
-
const config = getCloudConfig();
|
|
5451
|
+
const status = getShortlinksRuntimeStatus(runtimeEnv());
|
|
4978
5452
|
const data = {
|
|
5453
|
+
ok: status.ok,
|
|
4979
5454
|
service: "shortlinks",
|
|
4980
|
-
|
|
4981
|
-
|
|
4982
|
-
|
|
4983
|
-
|
|
5455
|
+
dry_run: true,
|
|
5456
|
+
no_network: true,
|
|
5457
|
+
status,
|
|
5458
|
+
postgres: {
|
|
5459
|
+
required: status.mode === "postgres",
|
|
5460
|
+
configured: status.database.configured,
|
|
5461
|
+
schema_sql: opts.schemaSql ? PG_MIGRATIONS : []
|
|
5462
|
+
},
|
|
5463
|
+
steps: [
|
|
5464
|
+
"Read local SQLite state",
|
|
5465
|
+
status.mode === "postgres" ? "Prepare direct shortlinks Postgres runtime" : "Keep serving from local SQLite",
|
|
5466
|
+
"Run migrations with shortlinks postgres migrate before serving from Postgres",
|
|
5467
|
+
"Report planned changes without opening network connections"
|
|
5468
|
+
]
|
|
4984
5469
|
};
|
|
4985
5470
|
print2(data, opts, () => console.log(JSON.stringify(data, null, 2)));
|
|
4986
5471
|
} catch (error) {
|
|
@@ -5025,23 +5510,26 @@ localCmd.command("setup <domain>").description("Record local domain mapping with
|
|
|
5025
5510
|
});
|
|
5026
5511
|
program2.command("doctor").description("Check local shortlinks tooling and integration readiness").option("-j, --json", "Output JSON").action(async (opts) => {
|
|
5027
5512
|
try {
|
|
5028
|
-
const
|
|
5029
|
-
const
|
|
5513
|
+
const runtime = getShortlinksRuntimeStatus(runtimeEnv());
|
|
5514
|
+
const dbPath = getDatabasePath(program2.opts().db);
|
|
5030
5515
|
const data = {
|
|
5031
5516
|
service: "shortlinks",
|
|
5032
|
-
|
|
5517
|
+
ok: runtime.ok,
|
|
5518
|
+
store: runtime.mode,
|
|
5033
5519
|
data_dir: getDataDir(),
|
|
5034
5520
|
config_path: getConfigPath(),
|
|
5035
|
-
db_path:
|
|
5036
|
-
db_exists: existsSync4(
|
|
5037
|
-
stats,
|
|
5521
|
+
db_path: dbPath,
|
|
5522
|
+
db_exists: existsSync4(dbPath),
|
|
5523
|
+
stats: localStatsIfDatabaseExists(dbPath),
|
|
5524
|
+
runtime,
|
|
5525
|
+
no_network: true,
|
|
5038
5526
|
commands: {
|
|
5039
5527
|
domains: commandExists("domains"),
|
|
5040
|
-
cloud: commandExists("cloud"),
|
|
5041
5528
|
wrangler: commandExists("wrangler"),
|
|
5042
5529
|
secrets: commandExists("secrets")
|
|
5043
5530
|
},
|
|
5044
5531
|
environment: {
|
|
5532
|
+
shortlinks_database_url_present: Boolean(getShortlinksDatabaseUrl(runtimeEnv())),
|
|
5045
5533
|
cloudflare_api_token_present: Boolean(process.env.CLOUDFLARE_API_TOKEN),
|
|
5046
5534
|
cloudflare_api_key_present: Boolean(process.env.CLOUDFLARE_API_KEY),
|
|
5047
5535
|
cloudflare_email_present: Boolean(process.env.CLOUDFLARE_EMAIL),
|