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.
- package/README.md +14 -1
- package/bundle/meta/scaffold-manifest.json +73 -0
- package/bundle/scaffold/VERSION +1 -0
- package/bundle/scaffold/__dot__env.example +24 -0
- package/bundle/scaffold/__dot__gitignore +41 -0
- package/bundle/scaffold/docker/Caddyfile +3 -0
- package/bundle/scaffold/docker/Dockerfile +30 -0
- package/bundle/scaffold/docker-compose.yml +53 -0
- package/bundle/scaffold/next.config.ts +20 -0
- package/bundle/scaffold/package-lock.json +5843 -0
- package/bundle/scaffold/package.json +42 -0
- package/bundle/scaffold/postcss.config.js +6 -0
- package/bundle/scaffold/prisma/migrations/20260408000000_init/migration.sql +143 -0
- package/bundle/scaffold/prisma/migrations/20260408010000_add_visitor_tracking/migration.sql +6 -0
- package/bundle/scaffold/prisma/migrations/20260409040000_add_portal_file_checksum/migration.sql +2 -0
- package/bundle/scaffold/prisma/migrations/migration_lock.toml +3 -0
- package/bundle/scaffold/prisma/schema.local.prisma +131 -0
- package/bundle/scaffold/prisma/schema.prisma +128 -0
- package/bundle/scaffold/prisma/seed.ts +49 -0
- package/bundle/scaffold/public/example-avatar.svg +4 -0
- package/bundle/scaffold/public/example-logo.svg +4 -0
- package/bundle/scaffold/public/robots.txt +2 -0
- package/bundle/scaffold/scripts/backup.sh +19 -0
- package/bundle/scaffold/scripts/e2e-verify.sh +487 -0
- package/bundle/scaffold/scripts/prisma-db-push.mjs +7 -0
- package/bundle/scaffold/scripts/prisma-generate.mjs +3 -0
- package/bundle/scaffold/scripts/prisma-schema.mjs +74 -0
- package/bundle/scaffold/scripts/restore.sh +31 -0
- package/bundle/scaffold/src/__tests__/client-portals.test.ts +80 -0
- package/bundle/scaffold/src/__tests__/portal-contracts.test.ts +32 -0
- package/bundle/scaffold/src/app/(portal)/client/[slug]/page.tsx +79 -0
- package/bundle/scaffold/src/app/(portal)/client/[slug]/s/[token]/route.ts +22 -0
- package/bundle/scaffold/src/app/(portal)/client/example/example-client.tsx +372 -0
- package/bundle/scaffold/src/app/(portal)/client/example/page.tsx +5 -0
- package/bundle/scaffold/src/app/(portal)/client/layout.tsx +7 -0
- package/bundle/scaffold/src/app/(portal)/client/page.tsx +18 -0
- package/bundle/scaffold/src/app/api/client-auth/route.ts +82 -0
- package/bundle/scaffold/src/app/api/client-auth/share/route.ts +30 -0
- package/bundle/scaffold/src/app/api/client-events/route.ts +87 -0
- package/bundle/scaffold/src/app/api/client-files/[...path]/route.ts +80 -0
- package/bundle/scaffold/src/app/api/client-files/client-upload/route.ts +118 -0
- package/bundle/scaffold/src/app/api/client-files/route.ts +37 -0
- package/bundle/scaffold/src/app/api/client-files/upload/route.ts +131 -0
- package/bundle/scaffold/src/app/api/health/route.ts +19 -0
- package/bundle/scaffold/src/app/globals.css +7 -0
- package/bundle/scaffold/src/app/layout.tsx +25 -0
- package/bundle/scaffold/src/app/page.tsx +171 -0
- package/bundle/scaffold/src/components/portal-login.tsx +169 -0
- package/bundle/scaffold/src/components/portal-shell.tsx +373 -0
- package/bundle/scaffold/src/lib/abuse-controls.ts +43 -0
- package/bundle/scaffold/src/lib/branding.ts +50 -0
- package/bundle/scaffold/src/lib/client-auth.ts +98 -0
- package/bundle/scaffold/src/lib/client-portals.ts +134 -0
- package/bundle/scaffold/src/lib/control-plane.ts +100 -0
- package/bundle/scaffold/src/lib/db.ts +7 -0
- package/bundle/scaffold/src/lib/files.ts +124 -0
- package/bundle/scaffold/src/lib/load-app-env.ts +42 -0
- package/bundle/scaffold/src/lib/portal-contracts.ts +69 -0
- package/bundle/scaffold/src/lib/prisma-client.ts +5 -0
- package/bundle/scaffold/src/lib/runtime-state.ts +69 -0
- package/bundle/scaffold/src/lib/storage.ts +204 -0
- package/bundle/scaffold/src/lib/token.ts +186 -0
- package/bundle/scaffold/src/lib/utils.ts +6 -0
- package/bundle/scaffold/src/middleware.ts +61 -0
- package/bundle/scaffold/tailwind.config.ts +15 -0
- package/bundle/scaffold/tests/__dot__gitkeep +0 -0
- package/bundle/scaffold/tsconfig.json +23 -0
- package/bundle/scaffold/vitest.config.ts +13 -0
- package/bundle/toolchain/VERSION +1 -0
- package/bundle/toolchain/bin/check-slug.ts +59 -0
- package/bundle/toolchain/bin/create-deploy-bundle.ts +93 -0
- package/bundle/toolchain/bin/create-portal.ts +71 -0
- package/bundle/toolchain/bin/delete-portal.ts +48 -0
- package/bundle/toolchain/bin/export-file-manifest.ts +84 -0
- package/bundle/toolchain/bin/export-runtime-state.ts +90 -0
- package/bundle/toolchain/bin/generate-share-link.ts +68 -0
- package/bundle/toolchain/bin/list-portals.ts +53 -0
- package/bundle/toolchain/bin/materialize-file.ts +35 -0
- package/bundle/toolchain/bin/query-analytics.ts +88 -0
- package/bundle/toolchain/bin/rotate-credentials.ts +57 -0
- package/bundle/toolchain/bin/showpane-config +63 -0
- package/bundle/toolchain/bin/tsconfig.json +13 -0
- package/bundle/toolchain/skills/VERSION +1 -0
- package/bundle/toolchain/skills/portal-analytics/SKILL.md +263 -0
- package/bundle/toolchain/skills/portal-create/SKILL.md +341 -0
- package/bundle/toolchain/skills/portal-credentials/SKILL.md +274 -0
- package/bundle/toolchain/skills/portal-delete/SKILL.md +265 -0
- package/bundle/toolchain/skills/portal-deploy/SKILL.md +721 -0
- package/bundle/toolchain/skills/portal-dev/SKILL.md +301 -0
- package/bundle/toolchain/skills/portal-list/SKILL.md +253 -0
- package/bundle/toolchain/skills/portal-onboard/SKILL.md +277 -0
- package/bundle/toolchain/skills/portal-preview/SKILL.md +257 -0
- package/bundle/toolchain/skills/portal-setup/SKILL.md +309 -0
- package/bundle/toolchain/skills/portal-share/SKILL.md +234 -0
- package/bundle/toolchain/skills/portal-status/SKILL.md +268 -0
- package/bundle/toolchain/skills/portal-update/SKILL.md +348 -0
- package/bundle/toolchain/skills/portal-upgrade/SKILL.md +235 -0
- package/bundle/toolchain/skills/portal-verify/SKILL.md +265 -0
- package/bundle/toolchain/skills/shared/bin/check-portal-guard.sh +49 -0
- package/bundle/toolchain/skills/shared/platform-constraints.md +33 -0
- package/bundle/toolchain/skills/shared/preamble.md +137 -0
- package/bundle/toolchain/templates/consulting/consulting-client.tsx +205 -0
- package/bundle/toolchain/templates/onboarding/onboarding-client.tsx +237 -0
- package/bundle/toolchain/templates/sales-followup/sales-followup-client.tsx +283 -0
- package/dist/index.js +873 -166
- package/package.json +3 -2
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { hasSafePathSegments } from "@/lib/files";
|
|
2
|
+
import { type LocalPortalEventPayload, toCloudPortalEventPayload } from "@/lib/portal-contracts";
|
|
3
|
+
|
|
4
|
+
export type ControlPlaneFileRecord = {
|
|
5
|
+
id: string;
|
|
6
|
+
filename: string;
|
|
7
|
+
mimeType: string;
|
|
8
|
+
size: number;
|
|
9
|
+
uploadedBy: string;
|
|
10
|
+
uploadedAt: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function getControlPlaneUrl(): string | null {
|
|
14
|
+
return process.env.SHOWPANE_CONTROL_PLANE_URL ?? null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function getPortalServiceToken(): string | null {
|
|
18
|
+
return process.env.PORTAL_SERVICE_TOKEN ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isControlPlaneMode(): boolean {
|
|
22
|
+
return Boolean(getControlPlaneUrl() && getPortalServiceToken());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getControlPlaneHeaders(): HeadersInit {
|
|
26
|
+
return {
|
|
27
|
+
Authorization: `Bearer ${getPortalServiceToken()}`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function listControlPlaneFiles(portalSlug: string): Promise<ControlPlaneFileRecord[]> {
|
|
32
|
+
const baseUrl = getControlPlaneUrl();
|
|
33
|
+
if (!baseUrl) return [];
|
|
34
|
+
|
|
35
|
+
const res = await fetch(`${baseUrl}/api/runtime/files?portalSlug=${encodeURIComponent(portalSlug)}`, {
|
|
36
|
+
headers: getControlPlaneHeaders(),
|
|
37
|
+
cache: "no-store",
|
|
38
|
+
});
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
throw new Error(`Control-plane file list failed (${res.status})`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const body = (await res.json()) as { files?: ControlPlaneFileRecord[] };
|
|
44
|
+
return body.files ?? [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function downloadControlPlaneFile(
|
|
48
|
+
portalSlug: string,
|
|
49
|
+
pathSegments: string[]
|
|
50
|
+
): Promise<Response> {
|
|
51
|
+
const baseUrl = getControlPlaneUrl();
|
|
52
|
+
if (!baseUrl || !hasSafePathSegments(pathSegments) || pathSegments.length !== 1) {
|
|
53
|
+
return new Response(null, { status: 400 });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return fetch(
|
|
57
|
+
`${baseUrl}/api/runtime/files/${encodeURIComponent(pathSegments[0])}?portalSlug=${encodeURIComponent(portalSlug)}`,
|
|
58
|
+
{
|
|
59
|
+
headers: getControlPlaneHeaders(),
|
|
60
|
+
cache: "no-store",
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function sendControlPlaneEvent(
|
|
66
|
+
portalSlug: string,
|
|
67
|
+
payload: LocalPortalEventPayload
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
const baseUrl = getControlPlaneUrl();
|
|
70
|
+
if (!baseUrl) return;
|
|
71
|
+
|
|
72
|
+
await fetch(`${baseUrl}/api/events`, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: {
|
|
75
|
+
...getControlPlaneHeaders(),
|
|
76
|
+
"Content-Type": "application/json",
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify(toCloudPortalEventPayload(portalSlug, payload)),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function uploadControlPlaneFile(
|
|
83
|
+
portalSlug: string,
|
|
84
|
+
file: File
|
|
85
|
+
): Promise<Response> {
|
|
86
|
+
const baseUrl = getControlPlaneUrl();
|
|
87
|
+
if (!baseUrl) {
|
|
88
|
+
return new Response(JSON.stringify({ error: "Control plane unavailable" }), { status: 503 });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const formData = new FormData();
|
|
92
|
+
formData.append("portalSlug", portalSlug);
|
|
93
|
+
formData.append("file", file);
|
|
94
|
+
|
|
95
|
+
return fetch(`${baseUrl}/api/runtime/files/upload`, {
|
|
96
|
+
method: "POST",
|
|
97
|
+
headers: getControlPlaneHeaders(),
|
|
98
|
+
body: formData,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { PrismaClient } from "@/lib/prisma-client";
|
|
2
|
+
|
|
3
|
+
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
|
4
|
+
|
|
5
|
+
export const prisma = globalForPrisma.prisma || new PrismaClient();
|
|
6
|
+
|
|
7
|
+
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const EXTENSION_CONTENT_TYPES: Record<string, string> = {
|
|
2
|
+
pdf: "application/pdf",
|
|
3
|
+
png: "image/png",
|
|
4
|
+
jpg: "image/jpeg",
|
|
5
|
+
jpeg: "image/jpeg",
|
|
6
|
+
txt: "text/plain",
|
|
7
|
+
csv: "text/csv",
|
|
8
|
+
md: "text/markdown",
|
|
9
|
+
doc: "application/msword",
|
|
10
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
11
|
+
xls: "application/vnd.ms-excel",
|
|
12
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
13
|
+
ppt: "application/vnd.ms-powerpoint",
|
|
14
|
+
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
15
|
+
zip: "application/zip",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const BLOCKED_UPLOAD_EXTENSIONS = new Set([
|
|
19
|
+
"svg",
|
|
20
|
+
"svgz",
|
|
21
|
+
"html",
|
|
22
|
+
"htm",
|
|
23
|
+
"xhtml",
|
|
24
|
+
"xml",
|
|
25
|
+
"js",
|
|
26
|
+
"mjs",
|
|
27
|
+
"cjs",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const ALLOWED_UPLOAD_EXTENSIONS = new Set(Object.keys(EXTENSION_CONTENT_TYPES));
|
|
31
|
+
|
|
32
|
+
const INLINE_CONTENT_TYPES = new Set([
|
|
33
|
+
"application/pdf",
|
|
34
|
+
"image/png",
|
|
35
|
+
"image/jpeg",
|
|
36
|
+
"text/plain",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
function getFileExtension(filename: string): string {
|
|
40
|
+
return filename.split(".").pop()?.toLowerCase() ?? "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function sanitizeFilename(name: string): string {
|
|
44
|
+
const sanitized = name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 255);
|
|
45
|
+
return sanitized || "file";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function hasSafePathSegments(segments: string[]): boolean {
|
|
49
|
+
return (
|
|
50
|
+
segments.length > 0 &&
|
|
51
|
+
segments.every(
|
|
52
|
+
(segment) =>
|
|
53
|
+
Boolean(segment) &&
|
|
54
|
+
segment !== "." &&
|
|
55
|
+
segment !== ".." &&
|
|
56
|
+
!segment.includes("/") &&
|
|
57
|
+
!segment.includes("\\") &&
|
|
58
|
+
!segment.includes("\0")
|
|
59
|
+
)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isBlockedUploadFilename(filename: string): boolean {
|
|
64
|
+
return BLOCKED_UPLOAD_EXTENSIONS.has(getFileExtension(filename));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isAllowedUploadFilename(filename: string): boolean {
|
|
68
|
+
const extension = getFileExtension(filename);
|
|
69
|
+
return ALLOWED_UPLOAD_EXTENSIONS.has(extension) && !BLOCKED_UPLOAD_EXTENSIONS.has(extension);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function sniffUploadContentType(
|
|
73
|
+
filename: string,
|
|
74
|
+
bytes: Buffer,
|
|
75
|
+
clientContentType?: string
|
|
76
|
+
): string {
|
|
77
|
+
if (bytes.length >= 4) {
|
|
78
|
+
if (bytes.subarray(0, 4).equals(Buffer.from([0x25, 0x50, 0x44, 0x46]))) {
|
|
79
|
+
return "application/pdf";
|
|
80
|
+
}
|
|
81
|
+
if (
|
|
82
|
+
bytes.length >= 8 &&
|
|
83
|
+
bytes.subarray(0, 8).equals(
|
|
84
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
|
85
|
+
)
|
|
86
|
+
) {
|
|
87
|
+
return "image/png";
|
|
88
|
+
}
|
|
89
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
90
|
+
return "image/jpeg";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const extension = getFileExtension(filename);
|
|
95
|
+
if (extension in EXTENSION_CONTENT_TYPES) {
|
|
96
|
+
return EXTENSION_CONTENT_TYPES[extension];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return clientContentType || "application/octet-stream";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getServedFileMetadata(
|
|
103
|
+
filename: string,
|
|
104
|
+
storedContentType?: string | null
|
|
105
|
+
): { contentType: string; disposition: "inline" | "attachment" } {
|
|
106
|
+
const extension = getFileExtension(filename);
|
|
107
|
+
|
|
108
|
+
if (BLOCKED_UPLOAD_EXTENSIONS.has(extension) || storedContentType === "image/svg+xml") {
|
|
109
|
+
return {
|
|
110
|
+
contentType: "application/octet-stream",
|
|
111
|
+
disposition: "attachment",
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const contentType =
|
|
116
|
+
(extension && EXTENSION_CONTENT_TYPES[extension]) ||
|
|
117
|
+
storedContentType ||
|
|
118
|
+
"application/octet-stream";
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
contentType,
|
|
122
|
+
disposition: INLINE_CONTENT_TYPES.has(contentType) ? "inline" : "attachment",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
function parseEnvValue(rawValue: string) {
|
|
5
|
+
const value = rawValue.trim();
|
|
6
|
+
if (
|
|
7
|
+
(value.startsWith("\"") && value.endsWith("\"")) ||
|
|
8
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
9
|
+
) {
|
|
10
|
+
return value.slice(1, -1);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function loadEnvFile(filePath: string) {
|
|
16
|
+
if (!fs.existsSync(filePath)) return;
|
|
17
|
+
|
|
18
|
+
for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
|
|
19
|
+
const trimmed = line.trim();
|
|
20
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
21
|
+
|
|
22
|
+
const separator = trimmed.indexOf("=");
|
|
23
|
+
if (separator === -1) continue;
|
|
24
|
+
|
|
25
|
+
const key = trimmed.slice(0, separator).trim();
|
|
26
|
+
if (process.env[key] !== undefined) continue;
|
|
27
|
+
|
|
28
|
+
process.env[key] = parseEnvValue(trimmed.slice(separator + 1));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let loaded = false;
|
|
33
|
+
|
|
34
|
+
export function ensureAppEnvLoaded() {
|
|
35
|
+
if (loaded) return;
|
|
36
|
+
loaded = true;
|
|
37
|
+
|
|
38
|
+
const envFiles = [".env.local", ".env"];
|
|
39
|
+
for (const envFile of envFiles) {
|
|
40
|
+
loadEnvFile(path.join(process.cwd(), envFile));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Synced snapshot of ../../packages/portal-contracts/src/index.ts.
|
|
2
|
+
// Keep the app-local copy until the app consumes the package directly.
|
|
3
|
+
|
|
4
|
+
export const PORTAL_EVENT_TYPES = [
|
|
5
|
+
"portal_view",
|
|
6
|
+
"tab_switch",
|
|
7
|
+
"section_view",
|
|
8
|
+
"section_time",
|
|
9
|
+
"file_download",
|
|
10
|
+
"share_link_access",
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
export type PortalEventType = (typeof PORTAL_EVENT_TYPES)[number];
|
|
14
|
+
export const ORGANIZATION_REQUIRED_ERROR = "organization_required" as const;
|
|
15
|
+
|
|
16
|
+
export const ANALYTICS_METADATA_KEYS = {
|
|
17
|
+
durationSeconds: "durationSeconds",
|
|
18
|
+
} as const;
|
|
19
|
+
|
|
20
|
+
export type PortalEventMetadata = Record<string, unknown> | null;
|
|
21
|
+
|
|
22
|
+
export interface LocalPortalEventPayload {
|
|
23
|
+
event: PortalEventType;
|
|
24
|
+
detail?: string | null;
|
|
25
|
+
visitorId?: string;
|
|
26
|
+
metadata?: PortalEventMetadata;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CloudPortalEventPayload {
|
|
30
|
+
portalSlug: string;
|
|
31
|
+
visitorId?: string;
|
|
32
|
+
eventType: PortalEventType;
|
|
33
|
+
sectionName?: string | null;
|
|
34
|
+
metadata?: PortalEventMetadata;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PortalFileSyncManifestEntry {
|
|
38
|
+
portalSlug: string;
|
|
39
|
+
storagePath: string;
|
|
40
|
+
filename: string;
|
|
41
|
+
mimeType: string;
|
|
42
|
+
size: number;
|
|
43
|
+
uploadedBy: string;
|
|
44
|
+
uploadedAt: string;
|
|
45
|
+
checksum: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PortalFileSyncManifestPayload {
|
|
49
|
+
files: PortalFileSyncManifestEntry[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const PORTAL_EVENT_TYPE_SET = new Set<string>(PORTAL_EVENT_TYPES);
|
|
53
|
+
|
|
54
|
+
export function isPortalEventType(value: unknown): value is PortalEventType {
|
|
55
|
+
return typeof value === "string" && PORTAL_EVENT_TYPE_SET.has(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function toCloudPortalEventPayload(
|
|
59
|
+
portalSlug: string,
|
|
60
|
+
payload: LocalPortalEventPayload
|
|
61
|
+
): CloudPortalEventPayload {
|
|
62
|
+
return {
|
|
63
|
+
portalSlug,
|
|
64
|
+
visitorId: payload.visitorId,
|
|
65
|
+
eventType: payload.event,
|
|
66
|
+
sectionName: payload.detail ?? null,
|
|
67
|
+
metadata: payload.metadata ?? null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFile } from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
export type RuntimePortalSnapshot = {
|
|
5
|
+
slug: string;
|
|
6
|
+
companyName: string;
|
|
7
|
+
logoUrl?: string | null;
|
|
8
|
+
username: string;
|
|
9
|
+
passwordHash: string;
|
|
10
|
+
credentialVersion: string;
|
|
11
|
+
isActive: boolean;
|
|
12
|
+
lastUpdated?: string | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type RuntimeOrganizationSnapshot = {
|
|
16
|
+
id: string;
|
|
17
|
+
slug: string;
|
|
18
|
+
name: string;
|
|
19
|
+
logoUrl?: string | null;
|
|
20
|
+
primaryColor?: string;
|
|
21
|
+
portalLabel?: string;
|
|
22
|
+
websiteUrl?: string | null;
|
|
23
|
+
contactName?: string | null;
|
|
24
|
+
contactTitle?: string | null;
|
|
25
|
+
contactEmail?: string | null;
|
|
26
|
+
contactPhone?: string | null;
|
|
27
|
+
contactAvatar?: string | null;
|
|
28
|
+
supportEmail?: string | null;
|
|
29
|
+
customDomain?: string | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type RuntimeState = {
|
|
33
|
+
organization: RuntimeOrganizationSnapshot;
|
|
34
|
+
portals: RuntimePortalSnapshot[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let cachedState: RuntimeState | null | undefined;
|
|
38
|
+
|
|
39
|
+
function getRuntimeStatePath(): string {
|
|
40
|
+
return process.env.SHOWPANE_RUNTIME_STATE_PATH || path.join(process.cwd(), "runtime", "runtime-state.json");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isRuntimeSnapshotMode(): boolean {
|
|
44
|
+
return Boolean(process.env.SHOWPANE_RUNTIME_STATE_PATH);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function getRuntimeState(): Promise<RuntimeState | null> {
|
|
48
|
+
if (!isRuntimeSnapshotMode()) return null;
|
|
49
|
+
if (cachedState !== undefined) return cachedState;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const raw = await readFile(getRuntimeStatePath(), "utf8");
|
|
53
|
+
cachedState = JSON.parse(raw) as RuntimeState;
|
|
54
|
+
return cachedState;
|
|
55
|
+
} catch {
|
|
56
|
+
cachedState = null;
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function getRuntimePortalBySlug(slug: string) {
|
|
62
|
+
const state = await getRuntimeState();
|
|
63
|
+
return state?.portals.find((portal) => portal.slug === slug && portal.isActive) ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function getRuntimePortalByUsername(username: string) {
|
|
67
|
+
const state = await getRuntimeState();
|
|
68
|
+
return state?.portals.find((portal) => portal.username === username && portal.isActive) ?? null;
|
|
69
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { mkdir, readFile, stat, writeFile } from "fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
type StorageProvider = "local" | "s3" | "r2";
|
|
5
|
+
|
|
6
|
+
function getProvider(): StorageProvider {
|
|
7
|
+
const provider = process.env.STORAGE_PROVIDER || "local";
|
|
8
|
+
if (provider === "s3" || provider === "r2") return provider;
|
|
9
|
+
return "local";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// ─── Local Storage ──────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
const UPLOADS_DIR = path.resolve(process.cwd(), "uploads");
|
|
15
|
+
|
|
16
|
+
function safePath(filePath: string): string {
|
|
17
|
+
const resolved = path.resolve(UPLOADS_DIR, filePath);
|
|
18
|
+
if (!resolved.startsWith(UPLOADS_DIR + path.sep) && resolved !== UPLOADS_DIR) {
|
|
19
|
+
throw new Error("Path traversal detected");
|
|
20
|
+
}
|
|
21
|
+
return resolved;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function localSave(filePath: string, data: Buffer): Promise<void> {
|
|
25
|
+
const fullPath = safePath(filePath);
|
|
26
|
+
await mkdir(path.dirname(fullPath), { recursive: true });
|
|
27
|
+
await writeFile(fullPath, data);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function localRead(filePath: string): Promise<Buffer | null> {
|
|
31
|
+
const fullPath = safePath(filePath);
|
|
32
|
+
try {
|
|
33
|
+
return await readFile(fullPath);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function localExists(filePath: string): Promise<boolean> {
|
|
40
|
+
const fullPath = safePath(filePath);
|
|
41
|
+
try {
|
|
42
|
+
const s = await stat(fullPath);
|
|
43
|
+
return s.isFile();
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── S3-Compatible Storage (S3 + R2) ────────────────────────────────────────
|
|
50
|
+
// @aws-sdk/client-s3 is an optional dependency — install it only if using S3/R2.
|
|
51
|
+
// We use require() so webpack doesn't fail when the package is absent.
|
|
52
|
+
|
|
53
|
+
function getS3Config() {
|
|
54
|
+
return {
|
|
55
|
+
bucket: process.env.S3_BUCKET || "",
|
|
56
|
+
region: process.env.S3_REGION || "auto",
|
|
57
|
+
accessKey: process.env.S3_ACCESS_KEY || "",
|
|
58
|
+
secretKey: process.env.S3_SECRET_KEY || "",
|
|
59
|
+
endpoint: process.env.S3_ENDPOINT, // Required for R2
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
64
|
+
function loadS3(): any {
|
|
65
|
+
try {
|
|
66
|
+
// Dynamic require — webpack won't statically analyze this
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
68
|
+
return require("@aws-sdk/client-s3");
|
|
69
|
+
} catch {
|
|
70
|
+
throw new StorageError(
|
|
71
|
+
"S3 storage requires @aws-sdk/client-s3. Install it with: npm install @aws-sdk/client-s3"
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function s3Save(filePath: string, data: Buffer, contentType?: string): Promise<void> {
|
|
77
|
+
const config = getS3Config();
|
|
78
|
+
const { S3Client, PutObjectCommand } = loadS3();
|
|
79
|
+
|
|
80
|
+
const client = new S3Client({
|
|
81
|
+
region: config.region,
|
|
82
|
+
credentials: {
|
|
83
|
+
accessKeyId: config.accessKey,
|
|
84
|
+
secretAccessKey: config.secretKey,
|
|
85
|
+
},
|
|
86
|
+
...(config.endpoint ? { endpoint: config.endpoint } : {}),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
await client.send(
|
|
91
|
+
new PutObjectCommand({
|
|
92
|
+
Bucket: config.bucket,
|
|
93
|
+
Key: filePath,
|
|
94
|
+
Body: data,
|
|
95
|
+
ContentType: contentType || "application/octet-stream",
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
if (err instanceof StorageError) throw err;
|
|
100
|
+
console.error("S3 storage error (save):", err);
|
|
101
|
+
throw new StorageError("Failed to save file to storage");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function s3Read(filePath: string): Promise<Buffer | null> {
|
|
106
|
+
const config = getS3Config();
|
|
107
|
+
const { S3Client, GetObjectCommand } = loadS3();
|
|
108
|
+
|
|
109
|
+
const client = new S3Client({
|
|
110
|
+
region: config.region,
|
|
111
|
+
credentials: {
|
|
112
|
+
accessKeyId: config.accessKey,
|
|
113
|
+
secretAccessKey: config.secretKey,
|
|
114
|
+
},
|
|
115
|
+
...(config.endpoint ? { endpoint: config.endpoint } : {}),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const response = await client.send(
|
|
120
|
+
new GetObjectCommand({
|
|
121
|
+
Bucket: config.bucket,
|
|
122
|
+
Key: filePath,
|
|
123
|
+
})
|
|
124
|
+
);
|
|
125
|
+
const stream = response.Body;
|
|
126
|
+
if (!stream) return null;
|
|
127
|
+
const chunks: Uint8Array[] = [];
|
|
128
|
+
for await (const chunk of stream as AsyncIterable<Uint8Array>) {
|
|
129
|
+
chunks.push(chunk);
|
|
130
|
+
}
|
|
131
|
+
return Buffer.concat(chunks);
|
|
132
|
+
} catch (err: unknown) {
|
|
133
|
+
if (err instanceof StorageError) throw err;
|
|
134
|
+
if (err && typeof err === "object" && "name" in err && (err as { name: string }).name === "NoSuchKey") {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
console.error("S3 storage error (read):", err);
|
|
138
|
+
throw new StorageError("Failed to read file from storage");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function s3Exists(filePath: string): Promise<boolean> {
|
|
143
|
+
const config = getS3Config();
|
|
144
|
+
const { S3Client, HeadObjectCommand } = loadS3();
|
|
145
|
+
|
|
146
|
+
const client = new S3Client({
|
|
147
|
+
region: config.region,
|
|
148
|
+
credentials: {
|
|
149
|
+
accessKeyId: config.accessKey,
|
|
150
|
+
secretAccessKey: config.secretKey,
|
|
151
|
+
},
|
|
152
|
+
...(config.endpoint ? { endpoint: config.endpoint } : {}),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
await client.send(
|
|
157
|
+
new HeadObjectCommand({
|
|
158
|
+
Bucket: config.bucket,
|
|
159
|
+
Key: filePath,
|
|
160
|
+
})
|
|
161
|
+
);
|
|
162
|
+
return true;
|
|
163
|
+
} catch (err) {
|
|
164
|
+
if (err instanceof StorageError) throw err;
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── Storage Error ──────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
export class StorageError extends Error {
|
|
172
|
+
constructor(message: string) {
|
|
173
|
+
super(message);
|
|
174
|
+
this.name = "StorageError";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
export async function saveFile(filePath: string, data: Buffer, contentType?: string): Promise<void> {
|
|
181
|
+
const provider = getProvider();
|
|
182
|
+
if (provider === "local") return localSave(filePath, data);
|
|
183
|
+
return s3Save(filePath, data, contentType);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function readFile_(filePath: string): Promise<Buffer | null> {
|
|
187
|
+
const provider = getProvider();
|
|
188
|
+
if (provider === "local") return localRead(filePath);
|
|
189
|
+
return s3Read(filePath);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function fileExists(filePath: string): Promise<boolean> {
|
|
193
|
+
const provider = getProvider();
|
|
194
|
+
if (provider === "local") return localExists(filePath);
|
|
195
|
+
return s3Exists(filePath);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function getStoragePath(
|
|
199
|
+
organizationId: string,
|
|
200
|
+
portalSlug: string,
|
|
201
|
+
filename: string
|
|
202
|
+
): string {
|
|
203
|
+
return `orgs/${organizationId}/portals/${portalSlug}/${filename}`;
|
|
204
|
+
}
|