@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.
@@ -3,24 +3,27 @@ import { randomUUID } from "node:crypto";
3
3
  import { cookies } from "next/headers";
4
4
  import { db } from "../../../lib/db.mjs";
5
5
  import { session, signIn } from "../../../lib/auth.mjs";
6
+ import { originAllowed } from "../../../lib/request-origin.mjs";
6
7
  import { agent, reconcile } from "../../../lib/agent.mjs";
7
- import { projectInput, idSchema, MAX_ZIP_BYTES } from "@naviyra/shared";
8
+ import { projectInput, runtimeConfig, idSchema } from "@naviyra/shared";
8
9
  import {
9
10
  listDatabases,
10
11
  createDatabase,
11
12
  deleteDatabase,
12
13
  resetDatabasePassword,
14
+ listNativeDatabases,
15
+ browseNativeDatabase,
13
16
  } from "../../../lib/databases.mjs";
14
17
  export const runtime = "nodejs";
15
18
  export const dynamic = "force-dynamic";
16
19
  const attempts = (globalThis.naviyraAttempts ||= new Map());
17
20
  const json = (data, status = 200) =>
18
21
  Response.json(data, { status, headers: { "Cache-Control": "no-store" } });
19
- async function readJson(req) {
20
- if (Number(req.headers.get("content-length")) > 8192)
22
+ async function readJson(req, limit = 8192) {
23
+ if (Number(req.headers.get("content-length")) > limit)
21
24
  throw Object.assign(Error("Request too large"), { status: 413 });
22
25
  const text = await req.text();
23
- if (text.length > 8192)
26
+ if (text.length > limit)
24
27
  throw Object.assign(Error("Request too large"), { status: 413 });
25
28
  return JSON.parse(text);
26
29
  }
@@ -31,7 +34,7 @@ async function handle(req, { params }) {
31
34
  const method = req.method;
32
35
  if (method !== "GET") {
33
36
  const expected = process.env.PUBLIC_ORIGIN || new URL(req.url).origin;
34
- if (req.headers.get("origin") !== expected)
37
+ if (!originAllowed(req.headers.get("origin"), expected))
35
38
  return json({ error: "Invalid request origin" }, 403);
36
39
  }
37
40
  if (route === "auth/login" && method === "POST") {
@@ -90,6 +93,15 @@ async function handle(req, { params }) {
90
93
  }
91
94
  if (route === "databases" && method === "GET")
92
95
  return json(await listDatabases(user.id));
96
+ if (parts[0] === "databases" && parts[1] === "native") {
97
+ if (user.role !== "admin")
98
+ return json({ error: "Administrator access required" }, 403);
99
+ if (method !== "GET") return json({ error: "Method not allowed" }, 405);
100
+ if (parts.length === 2) return json(await listNativeDatabases());
101
+ if (parts.length === 3 || parts.length === 4)
102
+ return json(await browseNativeDatabase(parts[2], parts[3]));
103
+ return json({ error: "Not found" }, 404);
104
+ }
93
105
  if (route === "databases" && method === "POST") {
94
106
  const body = await readJson(req);
95
107
  const created = await createDatabase(
@@ -312,6 +324,82 @@ async function handle(req, { params }) {
312
324
  ])
313
325
  ).rows[0];
314
326
  if (!project) return json({ error: "Project not found" }, 404);
327
+ if (action === "files") {
328
+ const search = new URL(req.url).search || "";
329
+ if (method === "GET" && new URL(req.url).searchParams.get("download") === "1") {
330
+ const response = await agent(`/projects/${id}/files${search}`, {
331
+ raw: true,
332
+ });
333
+ return new Response(response.body, {
334
+ status: 200,
335
+ headers: {
336
+ "Content-Type": "application/octet-stream",
337
+ "Content-Disposition":
338
+ response.headers.get("content-disposition") || "attachment",
339
+ "Cache-Control": "no-store",
340
+ },
341
+ });
342
+ }
343
+ if (method === "GET")
344
+ return json(await agent(`/projects/${id}/files${search}`));
345
+ if (method === "POST") {
346
+ const length = Number(req.headers.get("content-length") || 0);
347
+ if (length > 32 * 1024 * 1024)
348
+ return json({ error: "File is larger than 32 MB" }, 413);
349
+ return json(
350
+ await agent(`/projects/${id}/files${search}`, {
351
+ method: "POST",
352
+ headers: { "Content-Type": "application/octet-stream" },
353
+ body: req.body,
354
+ duplex: "half",
355
+ signal: AbortSignal.timeout(10 * 60 * 1000),
356
+ }),
357
+ );
358
+ }
359
+ if (method === "PUT") {
360
+ const body = await readJson(req, 2 * 1024 * 1024);
361
+ return json(
362
+ await agent(`/projects/${id}/files`, {
363
+ method: "PUT",
364
+ body: JSON.stringify(body),
365
+ }),
366
+ );
367
+ }
368
+ if (method === "DELETE")
369
+ return json(
370
+ await agent(`/projects/${id}/files${search}`, { method: "DELETE" }),
371
+ );
372
+ return json({ error: "Method not allowed" }, 405);
373
+ }
374
+ if (method === "PUT" && action === "config") {
375
+ const body = await readJson(req, 32768);
376
+ const types = new Set([
377
+ "nodejs",
378
+ "python",
379
+ "php",
380
+ "go",
381
+ "static",
382
+ "custom",
383
+ ]);
384
+ const type = types.has(body.type) ? body.type : project.type;
385
+ const config = runtimeConfig.parse({
386
+ ...(project.runtime_config || {}),
387
+ ...(body.runtime_config || {}),
388
+ });
389
+ await db.query(
390
+ "UPDATE projects SET type=$2, runtime_config=$3, updated_at=now() WHERE id=$1",
391
+ [id, type, JSON.stringify(config)],
392
+ );
393
+ await agent("/projects", {
394
+ method: "POST",
395
+ body: JSON.stringify({ ...project, type, runtime_config: config }),
396
+ });
397
+ await db.query(
398
+ "INSERT INTO audit_logs(user_id,project_id,action) VALUES($1,$2,$3)",
399
+ [user.id, id, "project.config"],
400
+ );
401
+ return json({ type, runtime_config: config });
402
+ }
315
403
  if (method === "GET" && !action) {
316
404
  let current = project;
317
405
  try {
@@ -332,14 +420,6 @@ async function handle(req, { params }) {
332
420
  if (method === "PUT" && action === "source") {
333
421
  if (project.source_type !== "zip")
334
422
  return json({ error: "This project uses Git" }, 400);
335
- const chunks = [];
336
- let size = 0;
337
- for await (const chunk of req.body) {
338
- size += chunk.byteLength;
339
- if (size > MAX_ZIP_BYTES)
340
- return json({ error: "ZIP limit is 25 MB" }, 413);
341
- chunks.push(Buffer.from(chunk));
342
- }
343
423
  await agent("/projects", {
344
424
  method: "POST",
345
425
  body: JSON.stringify(project),
@@ -348,7 +428,9 @@ async function handle(req, { params }) {
348
428
  await agent(`/projects/${id}/source`, {
349
429
  method: "PUT",
350
430
  headers: { "Content-Type": "application/zip" },
351
- body: Buffer.concat(chunks),
431
+ body: req.body,
432
+ duplex: "half",
433
+ signal: AbortSignal.timeout(24 * 60 * 60 * 1000),
352
434
  }),
353
435
  );
354
436
  }
@@ -976,6 +976,84 @@ footer > span:last-child {
976
976
  color: #227757;
977
977
  border: 1px solid #d6eadc;
978
978
  }
979
+ .toast-stack {
980
+ position: fixed;
981
+ top: 22px;
982
+ right: 22px;
983
+ z-index: 70;
984
+ display: flex;
985
+ flex-direction: column;
986
+ gap: 10px;
987
+ width: min(380px, calc(100vw - 32px));
988
+ pointer-events: none;
989
+ }
990
+ .toast {
991
+ pointer-events: auto;
992
+ border-radius: 8px;
993
+ border: 1px solid transparent;
994
+ box-shadow: 0 14px 36px #142f3c24;
995
+ overflow: hidden;
996
+ }
997
+ .toast.success {
998
+ background: #eaf7ef;
999
+ color: #1d6b4a;
1000
+ border-color: #d6eadc;
1001
+ }
1002
+ .toast.progress {
1003
+ background: #fff6e8;
1004
+ color: #8a5a12;
1005
+ border-color: #f0e0c2;
1006
+ }
1007
+ .toast.failed {
1008
+ background: #fff0ed;
1009
+ color: #b85243;
1010
+ border-color: #f3d9d4;
1011
+ }
1012
+ .toast-row {
1013
+ display: flex;
1014
+ align-items: flex-start;
1015
+ justify-content: space-between;
1016
+ gap: 12px;
1017
+ padding: 14px 14px 12px;
1018
+ }
1019
+ .toast-row p {
1020
+ margin: 0;
1021
+ font-size: 13px;
1022
+ line-height: 1.5;
1023
+ }
1024
+ .toast-row button {
1025
+ background: transparent;
1026
+ color: inherit;
1027
+ padding: 0;
1028
+ }
1029
+ .toast-track {
1030
+ height: 5px;
1031
+ background: #00000014;
1032
+ }
1033
+ .toast.success .toast-progress {
1034
+ background: #1d6b4a;
1035
+ }
1036
+ .toast.progress .toast-progress {
1037
+ background: #c4842a;
1038
+ }
1039
+ .toast.failed .toast-progress {
1040
+ background: #b85243;
1041
+ }
1042
+ .toast-progress {
1043
+ display: block;
1044
+ height: 100%;
1045
+ width: 100%;
1046
+ transform-origin: left center;
1047
+ animation: toast-countdown 10s linear forwards;
1048
+ }
1049
+ @keyframes toast-countdown {
1050
+ from {
1051
+ transform: scaleX(1);
1052
+ }
1053
+ to {
1054
+ transform: scaleX(0);
1055
+ }
1056
+ }
979
1057
  .dismissible {
980
1058
  display: flex;
981
1059
  justify-content: space-between;
@@ -1011,6 +1089,7 @@ footer > span:last-child {
1011
1089
  }
1012
1090
  .tabs {
1013
1091
  display: flex;
1092
+ flex-wrap: wrap;
1014
1093
  gap: 25px;
1015
1094
  border-bottom: 1px solid #dce4e7;
1016
1095
  margin-bottom: 22px;
@@ -1033,7 +1112,7 @@ footer > span:last-child {
1033
1112
  }
1034
1113
  dl {
1035
1114
  display: grid;
1036
- grid-template-columns: 120px minmax(0, 1fr);
1115
+ grid-template-columns: 140px minmax(0, 1fr);
1037
1116
  gap: 19px 15px;
1038
1117
  padding: 24px;
1039
1118
  margin: 0;
@@ -1280,6 +1359,35 @@ label small {
1280
1359
  margin-top: 24px;
1281
1360
  padding-top: 20px;
1282
1361
  }
1362
+ .alert-backdrop {
1363
+ z-index: 80;
1364
+ }
1365
+ .alert-modal {
1366
+ width: 420px;
1367
+ }
1368
+ .alert-modal h2 {
1369
+ margin-bottom: 8px;
1370
+ }
1371
+ .alert-modal > p {
1372
+ color: var(--muted);
1373
+ line-height: 1.6;
1374
+ margin-bottom: 0;
1375
+ }
1376
+ .alert-confirm {
1377
+ color: white;
1378
+ background: #b65c58;
1379
+ border: 1px solid #b65c58;
1380
+ border-radius: 6px;
1381
+ padding: 11px 16px;
1382
+ min-height: 38px;
1383
+ font-size: 11px;
1384
+ font-weight: 550;
1385
+ }
1386
+ .alert-confirm:focus-visible,
1387
+ .alert-modal .secondary:focus-visible {
1388
+ outline: 2px solid var(--green);
1389
+ outline-offset: 2px;
1390
+ }
1283
1391
  .login {
1284
1392
  min-height: 100vh;
1285
1393
  display: grid;
@@ -1709,27 +1817,105 @@ html[data-theme="dark"] .danger-text {
1709
1817
  .database-screen {
1710
1818
  margin-bottom: 10px;
1711
1819
  }
1712
- .database-create {
1820
+ .native-browser {
1821
+ padding-bottom: 8px;
1822
+ }
1823
+ .native-browser .section-heading {
1824
+ padding: 18px 23px 0;
1825
+ }
1826
+ .db-pills {
1713
1827
  display: flex;
1714
1828
  flex-wrap: wrap;
1715
- align-items: flex-end;
1829
+ gap: 8px;
1830
+ padding: 16px 23px;
1831
+ }
1832
+ .db-pills button {
1833
+ border: 1px solid #dde4e7;
1834
+ border-radius: 6px;
1835
+ background: transparent;
1836
+ color: #3e5360;
1837
+ font-size: 12px;
1838
+ padding: 7px 10px;
1839
+ }
1840
+ .db-pills button.active {
1841
+ border-color: var(--green);
1842
+ color: var(--green);
1843
+ }
1844
+ .browser-note {
1845
+ padding: 0 23px 16px;
1846
+ }
1847
+ .database-create {
1848
+ display: grid;
1849
+ grid-template-columns: minmax(220px, 1fr) auto;
1850
+ align-items: end;
1716
1851
  gap: 12px 16px;
1717
1852
  padding: 18px 23px 20px;
1718
1853
  }
1719
1854
  .database-create label {
1720
- flex: 1 1 220px;
1721
- max-width: 460px;
1722
1855
  margin-bottom: 0;
1856
+ min-width: 0;
1723
1857
  }
1724
1858
  .database-create small {
1725
1859
  margin-top: 4px;
1860
+ line-height: 1.45;
1726
1861
  }
1727
- .database-create input {
1862
+ .database-create input,
1863
+ .database-create select {
1728
1864
  height: 40px;
1729
1865
  margin-top: 8px;
1866
+ box-sizing: border-box;
1867
+ }
1868
+ .database-create select {
1869
+ display: block;
1870
+ width: 100%;
1871
+ padding: 0 36px 0 12px;
1872
+ border: 1px solid #dde4e7;
1873
+ border-radius: 6px;
1874
+ background-color: var(--surface);
1875
+ color: #3e5360;
1876
+ font-size: 12px;
1877
+ appearance: none;
1878
+ background-image:
1879
+ linear-gradient(45deg, transparent 50%, var(--muted) 50%),
1880
+ linear-gradient(135deg, var(--muted) 50%, transparent 50%);
1881
+ background-position:
1882
+ calc(100% - 18px) center,
1883
+ calc(100% - 13px) center;
1884
+ background-size:
1885
+ 5px 5px,
1886
+ 5px 5px;
1887
+ background-repeat: no-repeat;
1730
1888
  }
1731
1889
  .database-create .primary {
1732
1890
  height: 40px;
1891
+ min-height: 40px;
1892
+ }
1893
+ html[data-theme="dark"] .db-pills button {
1894
+ border-color: #31464c;
1895
+ color: #d5e4ea;
1896
+ }
1897
+ html[data-theme="dark"] .database-create select {
1898
+ background-color: var(--surface);
1899
+ color: var(--ink);
1900
+ border-color: var(--line);
1901
+ background-image:
1902
+ linear-gradient(45deg, transparent 50%, var(--muted) 50%),
1903
+ linear-gradient(135deg, var(--muted) 50%, transparent 50%);
1904
+ background-position:
1905
+ calc(100% - 18px) center,
1906
+ calc(100% - 13px) center;
1907
+ background-size:
1908
+ 5px 5px,
1909
+ 5px 5px;
1910
+ background-repeat: no-repeat;
1911
+ }
1912
+ @media (max-width: 720px) {
1913
+ .database-create {
1914
+ grid-template-columns: 1fr;
1915
+ }
1916
+ .database-create .primary {
1917
+ justify-self: start;
1918
+ }
1733
1919
  }
1734
1920
  .mail-address {
1735
1921
  display: flex;
@@ -2016,11 +2202,37 @@ html[data-theme="dark"] .badge.queued {
2016
2202
  background: #3a321c;
2017
2203
  color: #efd392;
2018
2204
  }
2019
- html[data-theme="dark"] .notice {
2205
+ html[data-theme="dark"] .notice,
2206
+ html[data-theme="dark"] .toast.success {
2020
2207
  background: #17352c;
2021
2208
  color: #b7efe4;
2022
2209
  border-color: #245246;
2023
2210
  }
2211
+ html[data-theme="dark"] .toast {
2212
+ box-shadow: 0 14px 36px #00000066;
2213
+ }
2214
+ html[data-theme="dark"] .toast.progress {
2215
+ background: #3a321c;
2216
+ color: #efd392;
2217
+ border-color: #5a4a2e;
2218
+ }
2219
+ html[data-theme="dark"] .toast.failed {
2220
+ background: #3a2422;
2221
+ color: #f0b2a8;
2222
+ border-color: #5a332e;
2223
+ }
2224
+ html[data-theme="dark"] .toast-track {
2225
+ background: #ffffff18;
2226
+ }
2227
+ html[data-theme="dark"] .toast.success .toast-progress {
2228
+ background: #8ee0d0;
2229
+ }
2230
+ html[data-theme="dark"] .toast.progress .toast-progress {
2231
+ background: #efd392;
2232
+ }
2233
+ html[data-theme="dark"] .toast.failed .toast-progress {
2234
+ background: #f0b2a8;
2235
+ }
2024
2236
  html[data-theme="dark"] .quick-start {
2025
2237
  background: linear-gradient(110deg, #17342e, #172126);
2026
2238
  }
@@ -2055,3 +2267,97 @@ html[data-theme="dark"] .stat,
2055
2267
  html[data-theme="dark"] .panel {
2056
2268
  border-color: var(--line);
2057
2269
  }
2270
+ .file-crumbs,
2271
+ .file-actions,
2272
+ .env-row,
2273
+ .app-fields {
2274
+ display: flex;
2275
+ flex-wrap: wrap;
2276
+ gap: 8px;
2277
+ align-items: center;
2278
+ margin: 12px 0;
2279
+ }
2280
+ .file-crumbs button,
2281
+ .file-row > button,
2282
+ .file-row-actions button,
2283
+ .file-row-actions a {
2284
+ background: transparent;
2285
+ color: var(--ink);
2286
+ padding: 0;
2287
+ }
2288
+ .file-crumbs button + button::before {
2289
+ content: "/";
2290
+ margin-right: 8px;
2291
+ color: var(--muted);
2292
+ }
2293
+ .file-list {
2294
+ display: grid;
2295
+ gap: 6px;
2296
+ }
2297
+ .file-row {
2298
+ display: flex;
2299
+ justify-content: space-between;
2300
+ align-items: center;
2301
+ gap: 12px;
2302
+ padding: 8px 10px;
2303
+ border: 1px solid var(--line);
2304
+ border-radius: 8px;
2305
+ background: var(--surface);
2306
+ }
2307
+ .file-row > button {
2308
+ display: flex;
2309
+ justify-content: space-between;
2310
+ flex: 1;
2311
+ text-align: left;
2312
+ gap: 12px;
2313
+ }
2314
+ .file-row small {
2315
+ color: var(--muted);
2316
+ }
2317
+ .file-row-actions {
2318
+ display: flex;
2319
+ gap: 10px;
2320
+ align-items: center;
2321
+ }
2322
+ .file-upload input {
2323
+ display: none;
2324
+ }
2325
+ .file-editor {
2326
+ width: 100%;
2327
+ min-height: 280px;
2328
+ margin: 8px 0 12px;
2329
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
2330
+ font-size: 13px;
2331
+ }
2332
+ .app-fields {
2333
+ align-items: end;
2334
+ }
2335
+ .app-fields label {
2336
+ display: grid;
2337
+ gap: 6px;
2338
+ min-width: 180px;
2339
+ font-size: 13px;
2340
+ }
2341
+ .env-row input {
2342
+ min-width: 140px;
2343
+ }
2344
+ .connection-box,
2345
+ .db-mini {
2346
+ margin: 14px 0;
2347
+ }
2348
+ .connection-box code {
2349
+ display: block;
2350
+ margin: 8px 0;
2351
+ overflow-wrap: anywhere;
2352
+ }
2353
+ .db-mini {
2354
+ list-style: none;
2355
+ padding: 0;
2356
+ }
2357
+ .db-mini li {
2358
+ display: flex;
2359
+ justify-content: space-between;
2360
+ gap: 12px;
2361
+ padding: 8px 0;
2362
+ border-bottom: 1px solid var(--line);
2363
+ }
@@ -1,5 +1,6 @@
1
1
  import "./globals.css";
2
2
  import { ThemeSync } from "./ui/theme";
3
+ import { ToastProvider } from "./ui/toast";
3
4
  export const metadata = {
4
5
  title: "Naviyra · Cloudflare hosting",
5
6
  description: "Your applications. Your infrastructure. One panel.",
@@ -11,7 +12,7 @@ export default function Layout({ children }) {
11
12
  <body>
12
13
  <script dangerouslySetInnerHTML={{ __html: themeScript }} />
13
14
  <ThemeSync />
14
- {children}
15
+ <ToastProvider>{children}</ToastProvider>
15
16
  </body>
16
17
  </html>
17
18
  );
@@ -2,15 +2,15 @@
2
2
  import { useEffect, useState } from "react";
3
3
  import { ArrowRight, ShieldCheck, Cloud, Terminal } from "lucide-react";
4
4
  import { ThemeToggle } from "../ui/theme";
5
+ import { useToast } from "../ui/toast";
5
6
  export default function Login() {
6
- const [error, setError] = useState("");
7
+ const toast = useToast();
7
8
  const [busy, setBusy] = useState(false);
8
9
  const [ready, setReady] = useState(false);
9
10
  useEffect(() => setReady(true), []);
10
11
  async function submit(event) {
11
12
  event.preventDefault();
12
13
  setBusy(true);
13
- setError("");
14
14
  const values = Object.fromEntries(new FormData(event.currentTarget));
15
15
  try {
16
16
  const response = await fetch("/api/auth/login", {
@@ -22,7 +22,7 @@ export default function Login() {
22
22
  if (!response.ok) throw Error(body.error);
23
23
  window.location.href = "/";
24
24
  } catch (error) {
25
- setError(error.message);
25
+ toast.failed(error.message);
26
26
  setBusy(false);
27
27
  }
28
28
  }
@@ -86,11 +86,6 @@ export default function Login() {
86
86
  required
87
87
  />
88
88
  </label>
89
- {error && (
90
- <div role="alert" className="error">
91
- {error}
92
- </div>
93
- )}
94
89
  <button className="primary" disabled={busy || !ready}>
95
90
  {busy ? "Signing in…" : "Sign in"}
96
91
  <ArrowRight size={17} />
@@ -1,6 +1,7 @@
1
1
  import { redirect } from "next/navigation";
2
2
  import { session } from "../lib/auth.mjs";
3
3
  import Dashboard from "./ui/dashboard";
4
+ import { ConfirmProvider } from "./ui/confirm";
4
5
  export const dynamic = "force-dynamic";
5
6
  const views = new Set([
6
7
  "projects",
@@ -10,7 +11,14 @@ const views = new Set([
10
11
  "dns",
11
12
  "email",
12
13
  ]);
13
- const tabs = new Set(["overview", "logs", "deployments"]);
14
+ const tabs = new Set([
15
+ "overview",
16
+ "files",
17
+ "application",
18
+ "database",
19
+ "logs",
20
+ "deployments",
21
+ ]);
14
22
  const projectId = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
23
  function one(value) {
16
24
  return (Array.isArray(value) ? value[0] : value) || "";
@@ -23,11 +31,13 @@ export default async function Page({ searchParams }) {
23
31
  const project = projectId.test(one(params?.project)) ? one(params.project) : null;
24
32
  const tab = tabs.has(one(params?.tab)) ? one(params.tab) : "overview";
25
33
  return (
26
- <Dashboard
27
- user={user}
28
- initialView={view}
29
- initialProject={project}
30
- initialTab={tab}
31
- />
34
+ <ConfirmProvider>
35
+ <Dashboard
36
+ user={user}
37
+ initialView={view}
38
+ initialProject={project}
39
+ initialTab={tab}
40
+ />
41
+ </ConfirmProvider>
32
42
  );
33
43
  }