showpane 0.4.1 → 0.4.2

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.
Files changed (106) hide show
  1. package/README.md +14 -1
  2. package/bundle/meta/scaffold-manifest.json +73 -0
  3. package/bundle/scaffold/VERSION +1 -0
  4. package/bundle/scaffold/__dot__env.example +24 -0
  5. package/bundle/scaffold/__dot__gitignore +41 -0
  6. package/bundle/scaffold/docker/Caddyfile +3 -0
  7. package/bundle/scaffold/docker/Dockerfile +30 -0
  8. package/bundle/scaffold/docker-compose.yml +53 -0
  9. package/bundle/scaffold/next.config.ts +20 -0
  10. package/bundle/scaffold/package-lock.json +5843 -0
  11. package/bundle/scaffold/package.json +42 -0
  12. package/bundle/scaffold/postcss.config.js +6 -0
  13. package/bundle/scaffold/prisma/migrations/20260408000000_init/migration.sql +143 -0
  14. package/bundle/scaffold/prisma/migrations/20260408010000_add_visitor_tracking/migration.sql +6 -0
  15. package/bundle/scaffold/prisma/migrations/20260409040000_add_portal_file_checksum/migration.sql +2 -0
  16. package/bundle/scaffold/prisma/migrations/migration_lock.toml +3 -0
  17. package/bundle/scaffold/prisma/schema.local.prisma +131 -0
  18. package/bundle/scaffold/prisma/schema.prisma +128 -0
  19. package/bundle/scaffold/prisma/seed.ts +49 -0
  20. package/bundle/scaffold/public/example-avatar.svg +4 -0
  21. package/bundle/scaffold/public/example-logo.svg +4 -0
  22. package/bundle/scaffold/public/robots.txt +2 -0
  23. package/bundle/scaffold/scripts/backup.sh +19 -0
  24. package/bundle/scaffold/scripts/e2e-verify.sh +487 -0
  25. package/bundle/scaffold/scripts/prisma-db-push.mjs +7 -0
  26. package/bundle/scaffold/scripts/prisma-generate.mjs +3 -0
  27. package/bundle/scaffold/scripts/prisma-schema.mjs +74 -0
  28. package/bundle/scaffold/scripts/restore.sh +31 -0
  29. package/bundle/scaffold/src/__tests__/client-portals.test.ts +80 -0
  30. package/bundle/scaffold/src/__tests__/portal-contracts.test.ts +32 -0
  31. package/bundle/scaffold/src/app/(portal)/client/[slug]/page.tsx +79 -0
  32. package/bundle/scaffold/src/app/(portal)/client/[slug]/s/[token]/route.ts +22 -0
  33. package/bundle/scaffold/src/app/(portal)/client/example/example-client.tsx +372 -0
  34. package/bundle/scaffold/src/app/(portal)/client/example/page.tsx +5 -0
  35. package/bundle/scaffold/src/app/(portal)/client/layout.tsx +7 -0
  36. package/bundle/scaffold/src/app/(portal)/client/page.tsx +18 -0
  37. package/bundle/scaffold/src/app/api/client-auth/route.ts +82 -0
  38. package/bundle/scaffold/src/app/api/client-auth/share/route.ts +30 -0
  39. package/bundle/scaffold/src/app/api/client-events/route.ts +87 -0
  40. package/bundle/scaffold/src/app/api/client-files/[...path]/route.ts +80 -0
  41. package/bundle/scaffold/src/app/api/client-files/client-upload/route.ts +118 -0
  42. package/bundle/scaffold/src/app/api/client-files/route.ts +37 -0
  43. package/bundle/scaffold/src/app/api/client-files/upload/route.ts +131 -0
  44. package/bundle/scaffold/src/app/api/health/route.ts +19 -0
  45. package/bundle/scaffold/src/app/globals.css +7 -0
  46. package/bundle/scaffold/src/app/layout.tsx +25 -0
  47. package/bundle/scaffold/src/app/page.tsx +171 -0
  48. package/bundle/scaffold/src/components/portal-login.tsx +169 -0
  49. package/bundle/scaffold/src/components/portal-shell.tsx +373 -0
  50. package/bundle/scaffold/src/lib/abuse-controls.ts +43 -0
  51. package/bundle/scaffold/src/lib/branding.ts +50 -0
  52. package/bundle/scaffold/src/lib/client-auth.ts +98 -0
  53. package/bundle/scaffold/src/lib/client-portals.ts +134 -0
  54. package/bundle/scaffold/src/lib/control-plane.ts +100 -0
  55. package/bundle/scaffold/src/lib/db.ts +7 -0
  56. package/bundle/scaffold/src/lib/files.ts +124 -0
  57. package/bundle/scaffold/src/lib/load-app-env.ts +42 -0
  58. package/bundle/scaffold/src/lib/portal-contracts.ts +69 -0
  59. package/bundle/scaffold/src/lib/prisma-client.ts +5 -0
  60. package/bundle/scaffold/src/lib/runtime-state.ts +69 -0
  61. package/bundle/scaffold/src/lib/storage.ts +204 -0
  62. package/bundle/scaffold/src/lib/token.ts +186 -0
  63. package/bundle/scaffold/src/lib/utils.ts +6 -0
  64. package/bundle/scaffold/src/middleware.ts +61 -0
  65. package/bundle/scaffold/tailwind.config.ts +15 -0
  66. package/bundle/scaffold/tests/__dot__gitkeep +0 -0
  67. package/bundle/scaffold/tsconfig.json +23 -0
  68. package/bundle/scaffold/vitest.config.ts +13 -0
  69. package/bundle/toolchain/VERSION +1 -0
  70. package/bundle/toolchain/bin/check-slug.ts +59 -0
  71. package/bundle/toolchain/bin/create-deploy-bundle.ts +93 -0
  72. package/bundle/toolchain/bin/create-portal.ts +71 -0
  73. package/bundle/toolchain/bin/delete-portal.ts +48 -0
  74. package/bundle/toolchain/bin/export-file-manifest.ts +84 -0
  75. package/bundle/toolchain/bin/export-runtime-state.ts +90 -0
  76. package/bundle/toolchain/bin/generate-share-link.ts +68 -0
  77. package/bundle/toolchain/bin/list-portals.ts +53 -0
  78. package/bundle/toolchain/bin/materialize-file.ts +35 -0
  79. package/bundle/toolchain/bin/query-analytics.ts +88 -0
  80. package/bundle/toolchain/bin/rotate-credentials.ts +57 -0
  81. package/bundle/toolchain/bin/showpane-config +63 -0
  82. package/bundle/toolchain/bin/tsconfig.json +13 -0
  83. package/bundle/toolchain/skills/VERSION +1 -0
  84. package/bundle/toolchain/skills/portal-analytics/SKILL.md +263 -0
  85. package/bundle/toolchain/skills/portal-create/SKILL.md +341 -0
  86. package/bundle/toolchain/skills/portal-credentials/SKILL.md +274 -0
  87. package/bundle/toolchain/skills/portal-delete/SKILL.md +265 -0
  88. package/bundle/toolchain/skills/portal-deploy/SKILL.md +721 -0
  89. package/bundle/toolchain/skills/portal-dev/SKILL.md +301 -0
  90. package/bundle/toolchain/skills/portal-list/SKILL.md +253 -0
  91. package/bundle/toolchain/skills/portal-onboard/SKILL.md +277 -0
  92. package/bundle/toolchain/skills/portal-preview/SKILL.md +257 -0
  93. package/bundle/toolchain/skills/portal-setup/SKILL.md +309 -0
  94. package/bundle/toolchain/skills/portal-share/SKILL.md +234 -0
  95. package/bundle/toolchain/skills/portal-status/SKILL.md +268 -0
  96. package/bundle/toolchain/skills/portal-update/SKILL.md +348 -0
  97. package/bundle/toolchain/skills/portal-upgrade/SKILL.md +235 -0
  98. package/bundle/toolchain/skills/portal-verify/SKILL.md +265 -0
  99. package/bundle/toolchain/skills/shared/bin/check-portal-guard.sh +49 -0
  100. package/bundle/toolchain/skills/shared/platform-constraints.md +33 -0
  101. package/bundle/toolchain/skills/shared/preamble.md +137 -0
  102. package/bundle/toolchain/templates/consulting/consulting-client.tsx +205 -0
  103. package/bundle/toolchain/templates/onboarding/onboarding-client.tsx +237 -0
  104. package/bundle/toolchain/templates/sales-followup/sales-followup-client.tsx +283 -0
  105. package/dist/index.js +873 -166
  106. package/package.json +3 -2
@@ -0,0 +1,82 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { validateClientLogin, resolveDefaultOrganizationId } from "@/lib/client-portals";
3
+ import {
4
+ isClientAuthConfigured,
5
+ signSessionToken,
6
+ setClientAuthCookie,
7
+ } from "@/lib/client-auth";
8
+
9
+ const attempts = new Map<string, { count: number; resetAt: number }>();
10
+ const MAX_ATTEMPTS = 5;
11
+ const WINDOW_MS = 5 * 60_000;
12
+
13
+ function isRateLimited(ip: string): boolean {
14
+ const now = Date.now();
15
+ const entry = attempts.get(ip);
16
+
17
+ if (!entry || now > entry.resetAt) {
18
+ if (attempts.size > 1000) {
19
+ for (const [key, e] of attempts) {
20
+ if (now > e.resetAt) attempts.delete(key);
21
+ }
22
+ }
23
+ attempts.set(ip, { count: 1, resetAt: now + WINDOW_MS });
24
+ return false;
25
+ }
26
+
27
+ entry.count++;
28
+ return entry.count > MAX_ATTEMPTS;
29
+ }
30
+
31
+ export async function POST(req: NextRequest) {
32
+ if (!isClientAuthConfigured()) {
33
+ return NextResponse.json(
34
+ { error: "Client portal auth is not configured." },
35
+ { status: 503 }
36
+ );
37
+ }
38
+
39
+ const ip =
40
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
41
+ req.headers.get("x-real-ip") ||
42
+ "unknown";
43
+
44
+ if (isRateLimited(ip)) {
45
+ return NextResponse.json(
46
+ { error: "Too many attempts. Please try again later." },
47
+ { status: 429 }
48
+ );
49
+ }
50
+
51
+ let body: { username?: string; password?: string };
52
+ try {
53
+ body = await req.json();
54
+ } catch {
55
+ return NextResponse.json({ error: "Invalid request" }, { status: 400 });
56
+ }
57
+
58
+ const { username, password } = body;
59
+ if (!username || !password) {
60
+ return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
61
+ }
62
+
63
+ // Resolve organization — self-hosted uses first org, cloud reads ORG_ID env var
64
+ const orgId = await resolveDefaultOrganizationId();
65
+ if (!orgId) {
66
+ return NextResponse.json({ error: "No organization configured" }, { status: 503 });
67
+ }
68
+
69
+ const slug = await validateClientLogin(orgId, username, password);
70
+ if (!slug) {
71
+ return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
72
+ }
73
+
74
+ const token = await signSessionToken(orgId, slug);
75
+ if (!token) {
76
+ return NextResponse.json({ error: "Unable to create session" }, { status: 500 });
77
+ }
78
+
79
+ const res = NextResponse.json({ ok: true, slug });
80
+ setClientAuthCookie(res, token);
81
+ return res;
82
+ }
@@ -0,0 +1,30 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ getAuthenticatedPortal,
4
+ isClientAuthConfigured,
5
+ signShareToken,
6
+ } from "@/lib/client-auth";
7
+
8
+ export async function GET(req: NextRequest) {
9
+ if (!isClientAuthConfigured()) {
10
+ return NextResponse.json(
11
+ { error: "Client portal auth is not configured." },
12
+ { status: 503 }
13
+ );
14
+ }
15
+
16
+ const portal = await getAuthenticatedPortal(req, "session");
17
+ if (!portal) {
18
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
19
+ }
20
+
21
+ const shareToken = await signShareToken(portal.orgId, portal.slug);
22
+ if (!shareToken) {
23
+ return NextResponse.json({ error: "Unable to create share link" }, { status: 500 });
24
+ }
25
+
26
+ const baseUrl = process.env.NEXT_PUBLIC_APP_URL || `${req.nextUrl.protocol}//${req.nextUrl.host}`;
27
+ const shareUrl = `${baseUrl}/client/${portal.slug}/s/${shareToken}`;
28
+
29
+ return NextResponse.json({ shareUrl });
30
+ }
@@ -0,0 +1,87 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getAuthenticatedPortal } from "@/lib/client-auth";
3
+ import { getClientPortalId } from "@/lib/client-portals";
4
+ import { sendControlPlaneEvent } from "@/lib/control-plane";
5
+ import {
6
+ EVENT_METADATA_MAX_BYTES,
7
+ isEventRateLimited,
8
+ } from "@/lib/abuse-controls";
9
+ import { prisma } from "@/lib/db";
10
+ import {
11
+ isPortalEventType,
12
+ PORTAL_EVENT_TYPES,
13
+ type PortalEventMetadata,
14
+ } from "@/lib/portal-contracts";
15
+ import { isRuntimeSnapshotMode } from "@/lib/runtime-state";
16
+
17
+ const VALID_EVENTS = new Set(PORTAL_EVENT_TYPES);
18
+
19
+ export async function POST(req: NextRequest) {
20
+ const portal = await getAuthenticatedPortal(req);
21
+ if (!portal) {
22
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
23
+ }
24
+
25
+ let body: unknown;
26
+ try {
27
+ body = await req.json();
28
+ } catch {
29
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
30
+ }
31
+
32
+ const { event, detail, visitorId, metadata } = body as {
33
+ event?: string;
34
+ detail?: string;
35
+ visitorId?: string;
36
+ metadata?: PortalEventMetadata;
37
+ };
38
+
39
+ if (!event || !isPortalEventType(event) || !VALID_EVENTS.has(event)) {
40
+ return NextResponse.json({ error: "Invalid event type" }, { status: 400 });
41
+ }
42
+
43
+ if (isEventRateLimited(`${portal.orgId}:${portal.slug}:${visitorId || "anon"}`)) {
44
+ return NextResponse.json({ error: "Too many events. Try again later." }, { status: 429 });
45
+ }
46
+
47
+ const metadataText = metadata ? JSON.stringify(metadata) : null;
48
+ if (metadataText && Buffer.byteLength(metadataText, "utf8") > EVENT_METADATA_MAX_BYTES) {
49
+ return NextResponse.json({ error: "Event metadata too large" }, { status: 413 });
50
+ }
51
+
52
+ const ip =
53
+ req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
54
+ req.headers.get("x-real-ip") ||
55
+ "unknown";
56
+
57
+ if (isRuntimeSnapshotMode()) {
58
+ try {
59
+ await sendControlPlaneEvent(portal.slug, {
60
+ event,
61
+ detail,
62
+ visitorId,
63
+ metadata,
64
+ });
65
+ } catch {
66
+ // Do not fail portal interactions if analytics forwarding is unavailable.
67
+ }
68
+ } else {
69
+ // Look up portal by org-scoped slug to get its ID
70
+ const portalId = await getClientPortalId(portal.orgId, portal.slug);
71
+
72
+ if (portalId) {
73
+ await prisma.portalEvent.create({
74
+ data: {
75
+ portalId,
76
+ event,
77
+ detail: detail || null,
78
+ visitorId: visitorId || null,
79
+ metadata: metadataText,
80
+ ipAddress: ip,
81
+ },
82
+ });
83
+ }
84
+ }
85
+
86
+ return NextResponse.json({ ok: true });
87
+ }
@@ -0,0 +1,80 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getAuthenticatedPortal } from "@/lib/client-auth";
3
+ import { downloadControlPlaneFile } from "@/lib/control-plane";
4
+ import { readFile_, StorageError } from "@/lib/storage";
5
+ import { prisma } from "@/lib/db";
6
+ import { getServedFileMetadata, hasSafePathSegments } from "@/lib/files";
7
+ import { getStoragePath } from "@/lib/storage";
8
+ import { isRuntimeSnapshotMode } from "@/lib/runtime-state";
9
+
10
+ export async function GET(
11
+ req: NextRequest,
12
+ { params }: { params: Promise<{ path: string[] }> }
13
+ ) {
14
+ const portal = await getAuthenticatedPortal(req);
15
+ if (!portal) {
16
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
17
+ }
18
+
19
+ const { path: pathSegments } = await params;
20
+ if (!hasSafePathSegments(pathSegments) || pathSegments.length !== 1) {
21
+ return NextResponse.json({ error: "Invalid file path" }, { status: 400 });
22
+ }
23
+
24
+ if (isRuntimeSnapshotMode()) {
25
+ const res = await downloadControlPlaneFile(portal.slug, pathSegments);
26
+ if (!res.ok) {
27
+ return NextResponse.json({ error: "File not found" }, { status: res.status });
28
+ }
29
+ return new NextResponse(res.body, {
30
+ status: res.status,
31
+ headers: res.headers,
32
+ });
33
+ }
34
+
35
+ const filename = pathSegments[0];
36
+ const filePath = getStoragePath(portal.orgId, portal.slug, filename);
37
+
38
+ try {
39
+ const record = await prisma.portalFile.findFirst({
40
+ where: {
41
+ storagePath: filePath,
42
+ portal: {
43
+ organizationId: portal.orgId,
44
+ slug: portal.slug,
45
+ isActive: true,
46
+ },
47
+ },
48
+ select: {
49
+ filename: true,
50
+ mimeType: true,
51
+ storagePath: true,
52
+ },
53
+ });
54
+
55
+ if (!record) {
56
+ return NextResponse.json({ error: "File not found" }, { status: 404 });
57
+ }
58
+
59
+ const data = await readFile_(record.storagePath);
60
+ if (!data) {
61
+ return NextResponse.json({ error: "File not found" }, { status: 404 });
62
+ }
63
+
64
+ const served = getServedFileMetadata(record.filename, record.mimeType);
65
+
66
+ return new NextResponse(new Uint8Array(data), {
67
+ headers: {
68
+ "Content-Type": served.contentType,
69
+ "Content-Disposition": `${served.disposition}; filename="${record.filename}"`,
70
+ "Cache-Control": "private, max-age=3600",
71
+ "X-Content-Type-Options": "nosniff",
72
+ },
73
+ });
74
+ } catch (err) {
75
+ if (err instanceof StorageError) {
76
+ return NextResponse.json({ error: "Storage unavailable" }, { status: 502 });
77
+ }
78
+ throw err;
79
+ }
80
+ }
@@ -0,0 +1,118 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { createHash } from "crypto";
3
+ import { getAuthenticatedPortal } from "@/lib/client-auth";
4
+ import { getClientPortalId } from "@/lib/client-portals";
5
+ import { uploadControlPlaneFile } from "@/lib/control-plane";
6
+ import { prisma } from "@/lib/db";
7
+ import { saveFile, getStoragePath, StorageError } from "@/lib/storage";
8
+ import { isRuntimeSnapshotMode } from "@/lib/runtime-state";
9
+ import {
10
+ DEFAULT_PORTAL_STORAGE_QUOTA_BYTES,
11
+ isUploadRateLimited,
12
+ } from "@/lib/abuse-controls";
13
+ import {
14
+ isAllowedUploadFilename,
15
+ isBlockedUploadFilename,
16
+ sanitizeFilename,
17
+ sniffUploadContentType,
18
+ } from "@/lib/files";
19
+
20
+ const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
21
+
22
+ export async function POST(req: NextRequest) {
23
+ const portal = await getAuthenticatedPortal(req, "session");
24
+ if (!portal) {
25
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
26
+ }
27
+
28
+ let formData: FormData;
29
+ try {
30
+ formData = await req.formData();
31
+ } catch {
32
+ return NextResponse.json({ error: "Invalid multipart data" }, { status: 400 });
33
+ }
34
+
35
+ const file = formData.get("file") as File | null;
36
+
37
+ if (!file) {
38
+ return NextResponse.json({ error: "Missing file" }, { status: 400 });
39
+ }
40
+
41
+ if (isRuntimeSnapshotMode()) {
42
+ const upstream = await uploadControlPlaneFile(portal.slug, file);
43
+ return new NextResponse(upstream.body, {
44
+ status: upstream.status,
45
+ headers: upstream.headers,
46
+ });
47
+ }
48
+
49
+ if (file.size > MAX_FILE_SIZE) {
50
+ return NextResponse.json({ error: "File too large (max 50MB)" }, { status: 413 });
51
+ }
52
+
53
+ const portalId = await getClientPortalId(portal.orgId, portal.slug);
54
+
55
+ if (!portalId) {
56
+ return NextResponse.json({ error: "Portal not found" }, { status: 404 });
57
+ }
58
+
59
+ if (isUploadRateLimited(`client:${portal.orgId}:${portal.slug}`)) {
60
+ return NextResponse.json({ error: "Too many uploads. Try again later." }, { status: 429 });
61
+ }
62
+
63
+ const filename = sanitizeFilename(file.name);
64
+ const buffer = Buffer.from(await file.arrayBuffer());
65
+ const checksum = createHash("sha256").update(buffer).digest("hex");
66
+ const mimeType = sniffUploadContentType(filename, buffer, file.type || undefined);
67
+
68
+ if (isBlockedUploadFilename(filename) || !isAllowedUploadFilename(filename)) {
69
+ return NextResponse.json({ error: "File type not allowed" }, { status: 415 });
70
+ }
71
+
72
+ const storagePath = getStoragePath(portal.orgId, portal.slug, filename);
73
+
74
+ const [existingFile, portalUsage] = await Promise.all([
75
+ prisma.portalFile.findUnique({
76
+ where: { storagePath },
77
+ select: { size: true },
78
+ }),
79
+ prisma.portalFile.aggregate({
80
+ where: { portalId },
81
+ _sum: { size: true },
82
+ }),
83
+ ]);
84
+
85
+ const currentUsage = portalUsage._sum.size ?? 0;
86
+ const existingSize = existingFile?.size ?? 0;
87
+ const projectedUsage = currentUsage - existingSize + file.size;
88
+ if (projectedUsage > DEFAULT_PORTAL_STORAGE_QUOTA_BYTES) {
89
+ return NextResponse.json({ error: "Portal storage quota exceeded" }, { status: 413 });
90
+ }
91
+
92
+ try {
93
+ await saveFile(storagePath, buffer, mimeType);
94
+ } catch (err) {
95
+ if (err instanceof StorageError) {
96
+ return NextResponse.json({ error: "Storage unavailable" }, { status: 502 });
97
+ }
98
+ throw err;
99
+ }
100
+
101
+ try {
102
+ const record = await prisma.portalFile.upsert({
103
+ where: { storagePath },
104
+ update: { filename, mimeType, checksum, size: file.size, uploadedBy: "client", uploadedAt: new Date() },
105
+ create: { portalId: portalId, filename, mimeType, storagePath, checksum, size: file.size, uploadedBy: "client" },
106
+ });
107
+
108
+ console.log(JSON.stringify({ event: "file_upload", slug: portal.slug, filename, size: file.size, uploadedBy: "client" }));
109
+
110
+ return NextResponse.json({
111
+ ok: true,
112
+ file: { id: record.id, filename: record.filename, mimeType: record.mimeType, size: record.size, uploadedBy: record.uploadedBy },
113
+ });
114
+ } catch (err) {
115
+ console.error("DB error during file upload:", err);
116
+ return NextResponse.json({ error: "Database error" }, { status: 503 });
117
+ }
118
+ }
@@ -0,0 +1,37 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getAuthenticatedPortal } from "@/lib/client-auth";
3
+ import { listControlPlaneFiles } from "@/lib/control-plane";
4
+ import { prisma } from "@/lib/db";
5
+ import { isRuntimeSnapshotMode } from "@/lib/runtime-state";
6
+
7
+ export async function GET(req: NextRequest) {
8
+ const portal = await getAuthenticatedPortal(req);
9
+ if (!portal) {
10
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
11
+ }
12
+
13
+ try {
14
+ if (isRuntimeSnapshotMode()) {
15
+ const files = await listControlPlaneFiles(portal.slug);
16
+ return NextResponse.json({ files });
17
+ }
18
+
19
+ const files = await prisma.portalFile.findMany({
20
+ where: { portal: { organizationId: portal.orgId, slug: portal.slug, isActive: true } },
21
+ orderBy: { uploadedAt: "desc" },
22
+ select: {
23
+ id: true,
24
+ filename: true,
25
+ mimeType: true,
26
+ size: true,
27
+ uploadedBy: true,
28
+ uploadedAt: true,
29
+ },
30
+ });
31
+
32
+ return NextResponse.json({ files });
33
+ } catch (err) {
34
+ console.error("DB error listing files:", err);
35
+ return NextResponse.json({ error: "Database error" }, { status: 503 });
36
+ }
37
+ }
@@ -0,0 +1,131 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { timingSafeEqual } from "crypto";
3
+ import { createHash } from "crypto";
4
+ import { uploadControlPlaneFile } from "@/lib/control-plane";
5
+ import { prisma } from "@/lib/db";
6
+ import { saveFile, getStoragePath, StorageError } from "@/lib/storage";
7
+ import { resolveDefaultOrganizationId, getClientPortalId } from "@/lib/client-portals";
8
+ import { isRuntimeSnapshotMode } from "@/lib/runtime-state";
9
+ import {
10
+ DEFAULT_PORTAL_STORAGE_QUOTA_BYTES,
11
+ isUploadRateLimited,
12
+ } from "@/lib/abuse-controls";
13
+ import {
14
+ isAllowedUploadFilename,
15
+ isBlockedUploadFilename,
16
+ sanitizeFilename,
17
+ sniffUploadContentType,
18
+ } from "@/lib/files";
19
+
20
+ const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
21
+
22
+ function constantTimeCompare(a: string, b: string): boolean {
23
+ if (a.length !== b.length) return false;
24
+ return timingSafeEqual(Buffer.from(a), Buffer.from(b));
25
+ }
26
+
27
+ export async function POST(req: NextRequest) {
28
+ const authHeader = req.headers.get("authorization");
29
+ const secret = process.env.AUTH_SECRET;
30
+ if (!secret || !authHeader || !constantTimeCompare(authHeader, `Bearer ${secret}`)) {
31
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
32
+ }
33
+
34
+ let formData: FormData;
35
+ try {
36
+ formData = await req.formData();
37
+ } catch {
38
+ return NextResponse.json({ error: "Invalid multipart data" }, { status: 400 });
39
+ }
40
+
41
+ const file = formData.get("file") as File | null;
42
+ const portalSlug = formData.get("portalSlug") as string | null;
43
+
44
+ if (!file || !portalSlug) {
45
+ return NextResponse.json({ error: "Missing file or portalSlug" }, { status: 400 });
46
+ }
47
+
48
+ if (isRuntimeSnapshotMode()) {
49
+ const upstream = await uploadControlPlaneFile(portalSlug, file);
50
+ return new NextResponse(upstream.body, {
51
+ status: upstream.status,
52
+ headers: upstream.headers,
53
+ });
54
+ }
55
+
56
+ if (file.size > MAX_FILE_SIZE) {
57
+ return NextResponse.json({ error: "File too large (max 50MB)" }, { status: 413 });
58
+ }
59
+
60
+ // Resolve org — operator uploads use Bearer auth, not cookie
61
+ const orgId = await resolveDefaultOrganizationId();
62
+ if (!orgId) {
63
+ return NextResponse.json({ error: "No organization configured" }, { status: 503 });
64
+ }
65
+
66
+ const portalId = await getClientPortalId(orgId, portalSlug);
67
+
68
+ if (!portalId) {
69
+ return NextResponse.json({ error: "Portal not found" }, { status: 404 });
70
+ }
71
+
72
+ if (isUploadRateLimited(`operator:${orgId}:${portalSlug}`)) {
73
+ return NextResponse.json({ error: "Too many uploads. Try again later." }, { status: 429 });
74
+ }
75
+
76
+ const filename = sanitizeFilename(file.name);
77
+ const buffer = Buffer.from(await file.arrayBuffer());
78
+ const checksum = createHash("sha256").update(buffer).digest("hex");
79
+ const mimeType = sniffUploadContentType(filename, buffer, file.type || undefined);
80
+
81
+ if (isBlockedUploadFilename(filename) || !isAllowedUploadFilename(filename)) {
82
+ return NextResponse.json({ error: "File type not allowed" }, { status: 415 });
83
+ }
84
+
85
+ const storagePath = getStoragePath(orgId, portalSlug, filename);
86
+
87
+ const [existingFile, portalUsage] = await Promise.all([
88
+ prisma.portalFile.findUnique({
89
+ where: { storagePath },
90
+ select: { size: true },
91
+ }),
92
+ prisma.portalFile.aggregate({
93
+ where: { portalId },
94
+ _sum: { size: true },
95
+ }),
96
+ ]);
97
+
98
+ const currentUsage = portalUsage._sum.size ?? 0;
99
+ const existingSize = existingFile?.size ?? 0;
100
+ const projectedUsage = currentUsage - existingSize + file.size;
101
+ if (projectedUsage > DEFAULT_PORTAL_STORAGE_QUOTA_BYTES) {
102
+ return NextResponse.json({ error: "Portal storage quota exceeded" }, { status: 413 });
103
+ }
104
+
105
+ try {
106
+ await saveFile(storagePath, buffer, mimeType);
107
+ } catch (err) {
108
+ if (err instanceof StorageError) {
109
+ return NextResponse.json({ error: "Storage unavailable" }, { status: 502 });
110
+ }
111
+ throw err;
112
+ }
113
+
114
+ try {
115
+ const record = await prisma.portalFile.upsert({
116
+ where: { storagePath },
117
+ update: { filename, mimeType, checksum, size: file.size, uploadedBy: "operator", uploadedAt: new Date() },
118
+ create: { portalId, filename, mimeType, storagePath, checksum, size: file.size, uploadedBy: "operator" },
119
+ });
120
+
121
+ console.log(JSON.stringify({ event: "file_upload", slug: portalSlug, filename, size: file.size, uploadedBy: "operator" }));
122
+
123
+ return NextResponse.json({
124
+ ok: true,
125
+ file: { id: record.id, filename: record.filename, mimeType: record.mimeType, size: record.size, uploadedBy: record.uploadedBy },
126
+ });
127
+ } catch (err) {
128
+ console.error("DB error during file upload:", err);
129
+ return NextResponse.json({ error: "Database error" }, { status: 503 });
130
+ }
131
+ }
@@ -0,0 +1,19 @@
1
+ import { NextResponse } from "next/server";
2
+ import { prisma } from "@/lib/db";
3
+ import { isRuntimeSnapshotMode } from "@/lib/runtime-state";
4
+
5
+ export async function GET() {
6
+ if (isRuntimeSnapshotMode()) {
7
+ return NextResponse.json({ status: "ok", mode: "runtime-snapshot" });
8
+ }
9
+
10
+ try {
11
+ await prisma.$queryRaw`SELECT 1`;
12
+ return NextResponse.json({ status: "ok", db: "connected" });
13
+ } catch {
14
+ return NextResponse.json(
15
+ { status: "error", db: "disconnected" },
16
+ { status: 503 }
17
+ );
18
+ }
19
+ }
@@ -0,0 +1,7 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ --color-primary: #111827;
7
+ }
@@ -0,0 +1,25 @@
1
+ import type { Metadata } from "next";
2
+ import { Inter } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const inter = Inter({ subsets: ["latin"] });
6
+
7
+ export const metadata: Metadata = {
8
+ title: "Client Portal",
9
+ description: "Secure client portal powered by Showpane",
10
+ robots: { index: false, follow: false },
11
+ };
12
+
13
+ export default function RootLayout({
14
+ children,
15
+ }: {
16
+ children: React.ReactNode;
17
+ }) {
18
+ return (
19
+ <html lang="en">
20
+ <body className={inter.className}>
21
+ {children}
22
+ </body>
23
+ </html>
24
+ );
25
+ }