@naviyra/cloud-panel 0.0.3-beta → 0.0.3-beta3

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.
@@ -9,16 +9,27 @@ export async function agent(route, options = {}) {
9
9
  throw Error("Remote agents require HTTPS");
10
10
  if ((process.env.AGENT_TOKEN?.length || 0) < 32)
11
11
  throw Error("Configure AGENT_TOKEN");
12
+ const { signal, raw, ...rest } = options;
12
13
  const response = await fetch(base + route, {
13
- ...options,
14
+ ...rest,
14
15
  headers: {
15
16
  Authorization: `Bearer ${process.env.AGENT_TOKEN}`,
16
17
  "Content-Type": "application/json",
17
18
  ...options.headers,
18
19
  },
19
20
  cache: "no-store",
20
- signal: AbortSignal.timeout(30000),
21
+ signal: signal ?? AbortSignal.timeout(30000),
21
22
  });
23
+ if (raw) {
24
+ if (!response.ok) {
25
+ const body = await response.json().catch(() => ({}));
26
+ throw Object.assign(Error(body.error || "Agent request failed"), {
27
+ status: response.status,
28
+ });
29
+ }
30
+ await db.query("UPDATE agents SET last_seen=now() WHERE name='primary'");
31
+ return response;
32
+ }
22
33
  const body = await response.json();
23
34
  if (!response.ok)
24
35
  throw Object.assign(Error(body.error || "Agent request failed"), {
@@ -124,6 +124,126 @@ function view(row, parts, password) {
124
124
  };
125
125
  }
126
126
 
127
+ const DB_NAME = /^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/;
128
+ const SECRET = /password|passwd|token|secret|credential|api_key/i;
129
+
130
+ function assertDbName(value) {
131
+ const name = String(value || "");
132
+ if (!DB_NAME.test(name)) throw fail("Database not found", 404);
133
+ return name;
134
+ }
135
+
136
+ function cell(value) {
137
+ if (value == null) return null;
138
+ if (typeof value === "number" || typeof value === "boolean") return value;
139
+ if (value instanceof Date) return value.toISOString();
140
+ if (Buffer.isBuffer(value)) return "[binary]";
141
+ const text =
142
+ typeof value === "object" ? JSON.stringify(value) : String(value);
143
+ return text.length > 500 ? `${text.slice(0, 500)}…` : text;
144
+ }
145
+
146
+ async function connectDatabase(name) {
147
+ const { url } = connectionParts();
148
+ const admin = new URL(url.href);
149
+ admin.pathname = `/${name}`;
150
+ const client = new pg.Client({ connectionString: admin.href });
151
+ await client.connect();
152
+ await client.query("SET statement_timeout = '15s'");
153
+ await client.query("SET default_transaction_read_only = on");
154
+ return client;
155
+ }
156
+
157
+ export async function listNativeDatabases() {
158
+ const parts = connectionParts();
159
+ const databases = await withAdmin(async (client) => {
160
+ const { rows } = await client.query(
161
+ `SELECT datname AS name, pg_database_size(datname)::bigint AS bytes
162
+ FROM pg_database
163
+ WHERE NOT datistemplate AND datname LIKE 'nv\\_%' ESCAPE '\\'
164
+ ORDER BY datname`,
165
+ );
166
+ return rows
167
+ .filter((row) => row.name !== parts.database)
168
+ .map((row) => ({ name: row.name, bytes: Number(row.bytes) }));
169
+ });
170
+ return { host: parts.host, port: parts.port, databases };
171
+ }
172
+
173
+ export async function browseNativeDatabase(database, table) {
174
+ const name = assertDbName(database);
175
+ if (!name.startsWith("nv_")) throw fail("Database not found", 404);
176
+ if (process.env.DATABASE_URL && name === connectionParts().database)
177
+ throw fail("Database not found", 404);
178
+ const exists = await withAdmin(async (client) => {
179
+ const found = await client.query(
180
+ "SELECT 1 FROM pg_database WHERE datname=$1 AND NOT datistemplate",
181
+ [name],
182
+ );
183
+ return found.rowCount > 0;
184
+ });
185
+ if (!exists) throw fail("Database not found", 404);
186
+ const client = await connectDatabase(name);
187
+ try {
188
+ const tables = (
189
+ await client.query(
190
+ `SELECT c.relname AS name
191
+ FROM pg_class c
192
+ JOIN pg_namespace n ON n.oid = c.relnamespace
193
+ WHERE n.nspname = 'public' AND c.relkind = 'r'
194
+ ORDER BY c.relname`,
195
+ )
196
+ ).rows.map((row) => row.name);
197
+ if (!table) return { database: name, tables, columns: [], rows: [] };
198
+ if (!DB_NAME.test(table) || !tables.includes(table))
199
+ throw fail("Table not found", 404);
200
+ const columns = (
201
+ await client.query(
202
+ `SELECT column_name, data_type
203
+ FROM information_schema.columns
204
+ WHERE table_schema = 'public' AND table_name = $1
205
+ ORDER BY ordinal_position`,
206
+ [table],
207
+ )
208
+ ).rows;
209
+ const statement = (
210
+ await client.query(
211
+ "SELECT format('SELECT * FROM %I LIMIT 50', $1::text) AS statement",
212
+ [table],
213
+ )
214
+ ).rows[0]?.statement;
215
+ const allowed = new Set([
216
+ `SELECT * FROM ${table} LIMIT 50`,
217
+ `SELECT * FROM "${table}" LIMIT 50`,
218
+ ]);
219
+ if (!allowed.has(statement)) throw fail("Could not read the table", 500);
220
+ const result = await client.query(statement);
221
+ const hidden = new Set(
222
+ columns
223
+ .filter((column) => SECRET.test(column.column_name))
224
+ .map((column) => column.column_name),
225
+ );
226
+ return {
227
+ database: name,
228
+ table,
229
+ tables,
230
+ columns: columns.map((column) => ({
231
+ name: column.column_name,
232
+ type: column.data_type,
233
+ hidden: hidden.has(column.column_name),
234
+ })),
235
+ rows: result.rows.map((row) => {
236
+ const out = {};
237
+ for (const [key, value] of Object.entries(row))
238
+ out[key] = hidden.has(key) ? "[hidden]" : cell(value);
239
+ return out;
240
+ }),
241
+ };
242
+ } finally {
243
+ await client.end();
244
+ }
245
+ }
246
+
127
247
  export async function listDatabases(userId) {
128
248
  const rows = (
129
249
  await db.query(
@@ -0,0 +1,29 @@
1
+ const LOOPBACK = new Set(["localhost", "127.0.0.1", "::1"]);
2
+
3
+ function parseOrigin(value) {
4
+ if (!value || typeof value !== "string") return null;
5
+ try {
6
+ const url = new URL(value);
7
+ if (url.origin === "null") return null;
8
+ return url;
9
+ } catch {
10
+ return null;
11
+ }
12
+ }
13
+
14
+ function hostname(url) {
15
+ return url.hostname.replace(/^\[|\]$/g, "");
16
+ }
17
+
18
+ function sameSite(left, right) {
19
+ if (left.origin === right.origin) return true;
20
+ if (left.protocol !== right.protocol || left.port !== right.port) return false;
21
+ return LOOPBACK.has(hostname(left)) && LOOPBACK.has(hostname(right));
22
+ }
23
+
24
+ export function originAllowed(originHeader, expected) {
25
+ const origin = parseOrigin(originHeader);
26
+ const target = parseOrigin(expected);
27
+ if (!origin || !target) return false;
28
+ return sameSite(origin, target);
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naviyra/cloud-panel",
3
- "version": "0.0.3-beta",
3
+ "version": "0.0.3-beta3",
4
4
  "description": "Self-hosted Naviyra panel for projects, databases, DNS, and email routing.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,6 +14,31 @@ const relativePath = z
14
14
  /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[a-zA-Z0-9_./-]+$/,
15
15
  "Use a relative project path without ..",
16
16
  );
17
+ const optionalPath = z.union([z.literal(""), relativePath]);
18
+ export const reservedEnv = new Set([
19
+ "PATH",
20
+ "NODE_OPTIONS",
21
+ "NODE_PATH",
22
+ "LD_PRELOAD",
23
+ "LD_LIBRARY_PATH",
24
+ "DYLD_INSERT_LIBRARIES",
25
+ "HOME",
26
+ "USERPROFILE",
27
+ "SYSTEMROOT",
28
+ "WINDIR",
29
+ "COMSPEC",
30
+ "PATHEXT",
31
+ "TEMP",
32
+ "TMP",
33
+ "APPDATA",
34
+ "LOCALAPPDATA",
35
+ "PORT",
36
+ "HOST",
37
+ "HOSTNAME",
38
+ "NODE_ENV",
39
+ "GIT_TERMINAL_PROMPT",
40
+ "PYTHONUNBUFFERED",
41
+ ]);
17
42
  export const runtimeConfig = z
18
43
  .object({
19
44
  python_server: z.enum(["asgi", "wsgi"]).default("asgi"),
@@ -28,6 +53,27 @@ export const runtimeConfig = z
28
53
  .regex(/^[a-zA-Z0-9_.-]*$/)
29
54
  .default(""),
30
55
  args: z.array(z.string().max(1000)).max(32).default([]),
56
+ startup_file: optionalPath.default(""),
57
+ working_dir: relativePath.default("."),
58
+ env: z
59
+ .record(
60
+ z.string().regex(/^[A-Za-z_][A-Za-z0-9_]{0,63}$/),
61
+ z.string().max(500),
62
+ )
63
+ .refine((value) => Object.keys(value).length <= 30, {
64
+ message: "Use at most 30 environment variables",
65
+ })
66
+ .default({}),
67
+ })
68
+ .superRefine((config, ctx) => {
69
+ for (const key of Object.keys(config.env || {})) {
70
+ if (reservedEnv.has(key.toUpperCase()) || key === "Path")
71
+ ctx.addIssue({
72
+ code: "custom",
73
+ path: ["env", key],
74
+ message: `${key} is reserved and cannot be set here`,
75
+ });
76
+ }
31
77
  })
32
78
  .default({
33
79
  python_server: "asgi",
@@ -35,6 +81,9 @@ export const runtimeConfig = z
35
81
  public_dir: ".",
36
82
  executable: "",
37
83
  args: [],
84
+ startup_file: "",
85
+ working_dir: ".",
86
+ env: {},
38
87
  });
39
88
  export const projectInput = z
40
89
  .object({