diff --git a/Dockerfile b/Dockerfile index 2ab2615..4782f06 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,7 +46,7 @@ COPY --from=build /encryptid-sdk /encryptid-sdk RUN bun install --production # Create data directories -RUN mkdir -p /data/communities /data/books /data/swag-artifacts /data/files +RUN mkdir -p /data/communities /data/books /data/swag-artifacts /data/files /data/splats # Set environment ENV NODE_ENV=production @@ -54,6 +54,7 @@ ENV STORAGE_DIR=/data/communities ENV BOOKS_DIR=/data/books ENV SWAG_ARTIFACTS_DIR=/data/swag-artifacts ENV FILES_DIR=/data/files +ENV SPLATS_DIR=/data/splats ENV PORT=3000 # Data volumes for persistence @@ -61,6 +62,7 @@ VOLUME /data/communities VOLUME /data/books VOLUME /data/swag-artifacts VOLUME /data/files +VOLUME /data/splats EXPOSE 3000 diff --git a/db/init.sql b/db/init.sql index 32c22de..5f350ee 100644 --- a/db/init.sql +++ b/db/init.sql @@ -12,6 +12,7 @@ CREATE SCHEMA IF NOT EXISTS rcart; CREATE SCHEMA IF NOT EXISTS providers; CREATE SCHEMA IF NOT EXISTS rfiles; CREATE SCHEMA IF NOT EXISTS rforum; +CREATE SCHEMA IF NOT EXISTS rsplat; -- Grant usage to the rspace user GRANT ALL ON SCHEMA rbooks TO rspace; @@ -19,3 +20,4 @@ GRANT ALL ON SCHEMA rcart TO rspace; GRANT ALL ON SCHEMA providers TO rspace; GRANT ALL ON SCHEMA rfiles TO rspace; GRANT ALL ON SCHEMA rforum TO rspace; +GRANT ALL ON SCHEMA rsplat TO rspace; diff --git a/docker-compose.yml b/docker-compose.yml index ab67bbd..6edf913 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,7 @@ services: - rspace-books:/data/books - rspace-swag:/data/swag-artifacts - rspace-files:/data/files + - rspace-splats:/data/splats environment: - NODE_ENV=production - STORAGE_DIR=/data/communities @@ -23,6 +24,11 @@ services: - FLOW_ID=a79144ec-e6a2-4e30-a42a-6d8237a5953d - FUNNEL_ID=0ff6a9ac-1667-4fc7-9a01-b1620810509f - FILES_DIR=/data/files + - SPLATS_DIR=/data/splats + - X402_PAY_TO=${X402_PAY_TO:-} + - X402_NETWORK=${X402_NETWORK:-eip155:84532} + - X402_UPLOAD_PRICE=${X402_UPLOAD_PRICE:-0.01} + - X402_FACILITATOR_URL=${X402_FACILITATOR_URL:-https://x402.org/facilitator} - R2_ENDPOINT=${R2_ENDPOINT} - R2_BUCKET=${R2_BUCKET:-rtube-videos} - R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID} @@ -85,6 +91,7 @@ volumes: rspace-books: rspace-swag: rspace-files: + rspace-splats: rspace-pgdata: networks: diff --git a/modules/splat/components/folk-splat-viewer.ts b/modules/splat/components/folk-splat-viewer.ts new file mode 100644 index 0000000..b118d57 --- /dev/null +++ b/modules/splat/components/folk-splat-viewer.ts @@ -0,0 +1,313 @@ +/** + * — Gaussian splat gallery + 3D viewer web component. + * + * Gallery mode: card grid of splats with upload form. + * Viewer mode: full-viewport Three.js + GaussianSplats3D renderer. + * + * Three.js and GaussianSplats3D are loaded via CDN importmap (not bundled). + */ + +interface SplatItem { + id: string; + slug: string; + title: string; + description?: string; + file_format: string; + file_size_bytes: number; + view_count: number; + contributor_name?: string; + created_at: string; +} + +export class FolkSplatViewer extends HTMLElement { + private _mode: "gallery" | "viewer" = "gallery"; + private _splats: SplatItem[] = []; + private _spaceSlug = "demo"; + private _splatUrl = ""; + private _splatTitle = ""; + private _splatDesc = ""; + private _viewer: any = null; + + static get observedAttributes() { + return ["mode", "splat-url", "splat-title", "splat-desc", "space-slug"]; + } + + set splats(val: SplatItem[]) { + this._splats = val; + if (this._mode === "gallery") this.renderGallery(); + } + + set spaceSlug(val: string) { + this._spaceSlug = val; + } + + connectedCallback() { + this._mode = (this.getAttribute("mode") as "gallery" | "viewer") || "gallery"; + this._splatUrl = this.getAttribute("splat-url") || ""; + this._splatTitle = this.getAttribute("splat-title") || ""; + this._splatDesc = this.getAttribute("splat-desc") || ""; + this._spaceSlug = this.getAttribute("space-slug") || "demo"; + + if (this._mode === "viewer") { + this.renderViewer(); + } else { + this.renderGallery(); + } + } + + disconnectedCallback() { + if (this._viewer) { + try { this._viewer.dispose(); } catch {} + this._viewer = null; + } + } + + attributeChangedCallback(name: string, _old: string, val: string) { + if (name === "mode") this._mode = val as "gallery" | "viewer"; + if (name === "splat-url") this._splatUrl = val; + if (name === "splat-title") this._splatTitle = val; + if (name === "splat-desc") this._splatDesc = val; + if (name === "space-slug") this._spaceSlug = val; + } + + // ── Gallery ── + + private renderGallery() { + const cards = this._splats.map((s) => ` + +
+ 🔮 +
+
+
${esc(s.title)}
+
+ ${s.file_format} + ${formatSize(s.file_size_bytes)} + ${s.view_count} views +
+
+
+ `).join(""); + + const empty = this._splats.length === 0 ? ` +
+
🔮
+

No splats yet

+

Upload a .ply, .splat, or .spz file to get started

+
+ ` : ""; + + this.innerHTML = ` + + `; + + this.setupUploadHandlers(); + } + + private setupUploadHandlers() { + const drop = this.querySelector("#splat-drop") as HTMLElement; + const fileInput = this.querySelector("#splat-file") as HTMLInputElement; + const browse = this.querySelector("#splat-browse") as HTMLElement; + const form = this.querySelector("#splat-form") as HTMLElement; + const titleInput = this.querySelector("#splat-title-input") as HTMLInputElement; + const descInput = this.querySelector("#splat-desc-input") as HTMLTextAreaElement; + const tagsInput = this.querySelector("#splat-tags-input") as HTMLInputElement; + const submitBtn = this.querySelector("#splat-submit") as HTMLButtonElement; + const status = this.querySelector("#splat-status") as HTMLElement; + + if (!drop || !fileInput) return; + + let selectedFile: File | null = null; + + browse?.addEventListener("click", () => fileInput.click()); + + fileInput.addEventListener("change", () => { + if (fileInput.files?.[0]) { + selectedFile = fileInput.files[0]; + form.classList.add("active"); + // Auto-populate title from filename + const name = selectedFile.name.replace(/\.(ply|splat|spz)$/i, ""); + titleInput.value = name.replace(/[-_]/g, " "); + titleInput.dispatchEvent(new Event("input")); + } + }); + + drop.addEventListener("dragover", (e) => { + e.preventDefault(); + drop.classList.add("splat-upload--dragover"); + }); + + drop.addEventListener("dragleave", () => { + drop.classList.remove("splat-upload--dragover"); + }); + + drop.addEventListener("drop", (e) => { + e.preventDefault(); + drop.classList.remove("splat-upload--dragover"); + const file = e.dataTransfer?.files[0]; + if (file && /\.(ply|splat|spz)$/i.test(file.name)) { + selectedFile = file; + form.classList.add("active"); + const name = file.name.replace(/\.(ply|splat|spz)$/i, ""); + titleInput.value = name.replace(/[-_]/g, " "); + titleInput.dispatchEvent(new Event("input")); + } + }); + + titleInput?.addEventListener("input", () => { + submitBtn.disabled = !titleInput.value.trim() || !selectedFile; + }); + + submitBtn?.addEventListener("click", async () => { + if (!selectedFile || !titleInput.value.trim()) return; + + submitBtn.disabled = true; + status.textContent = "Uploading..."; + + const formData = new FormData(); + formData.append("file", selectedFile); + formData.append("title", titleInput.value.trim()); + formData.append("description", descInput.value.trim()); + formData.append("tags", tagsInput.value.trim()); + + try { + const token = localStorage.getItem("encryptid_token") || ""; + const res = await fetch(`/${this._spaceSlug}/splat/api/splats`, { + method: "POST", + headers: token ? { Authorization: `Bearer ${token}` } : {}, + body: formData, + }); + + if (res.status === 402) { + status.textContent = "Payment required for upload (x402)"; + submitBtn.disabled = false; + return; + } + + if (res.status === 401) { + status.textContent = "Sign in with rStack Identity to upload"; + submitBtn.disabled = false; + return; + } + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Upload failed" })); + status.textContent = (err as any).error || "Upload failed"; + submitBtn.disabled = false; + return; + } + + const splat = await res.json() as SplatItem; + status.textContent = "Uploaded!"; + // Navigate to viewer + setTimeout(() => { + window.location.href = `/${this._spaceSlug}/splat/view/${splat.slug}`; + }, 500); + } catch (e) { + status.textContent = "Network error"; + submitBtn.disabled = false; + } + }); + } + + // ── Viewer ── + + private renderViewer() { + this.innerHTML = ` +
+
+
+
Loading splat...
+
+ + ${this._splatTitle ? ` +
+

${esc(this._splatTitle)}

+ ${this._splatDesc ? `

${esc(this._splatDesc)}

` : ""} +
+ ` : ""} +
+
+ `; + + this.initThreeViewer(); + } + + private async initThreeViewer() { + const container = this.querySelector("#splat-container") as HTMLElement; + const loading = this.querySelector("#splat-loading") as HTMLElement; + + if (!container || !this._splatUrl) return; + + try { + // Dynamic import from CDN (via importmap) + const THREE = await import("three"); + const GaussianSplats3D = await import("@mkkellogg/gaussian-splats-3d"); + + const viewer = new GaussianSplats3D.Viewer({ + cameraUp: [0, -1, 0], + initialCameraPosition: [1, 0.5, 1], + initialCameraLookAt: [0, 0, 0], + rootElement: container, + sharedMemoryForWorkers: false, + }); + + this._viewer = viewer; + + await viewer.addSplatScene(this._splatUrl, { + showLoadingUI: false, + progressiveLoad: true, + }); + + viewer.start(); + + if (loading) { + loading.classList.add("hidden"); + } + } catch (e) { + console.error("[rSplat] Viewer init error:", e); + if (loading) { + const text = loading.querySelector(".splat-loading__text"); + if (text) text.textContent = `Error loading splat: ${(e as Error).message}`; + const spinner = loading.querySelector(".splat-loading__spinner") as HTMLElement; + if (spinner) spinner.style.display = "none"; + } + } + } +} + +// ── Helpers ── + +function esc(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +customElements.define("folk-splat-viewer", FolkSplatViewer); diff --git a/modules/splat/components/splat.css b/modules/splat/components/splat.css new file mode 100644 index 0000000..6639de5 --- /dev/null +++ b/modules/splat/components/splat.css @@ -0,0 +1,352 @@ +/* rSplat — Gaussian Splat Viewer */ + +:root { + --splat-bg: #0f172a; + --splat-surface: #1e293b; + --splat-border: #334155; + --splat-text: #e2e8f0; + --splat-text-muted: #94a3b8; + --splat-accent: #818cf8; + --splat-accent-hover: #6366f1; + --splat-card-bg: rgba(30, 41, 59, 0.8); +} + +/* ── Gallery ── */ + +.splat-gallery { + padding: 2rem; + max-width: 1200px; + margin: 0 auto; + color: var(--splat-text); + min-height: calc(100vh - 56px); + background: var(--splat-bg); +} + +.splat-gallery h1 { + font-size: 1.5rem; + font-weight: 600; + margin: 0 0 0.5rem; +} + +.splat-gallery__subtitle { + color: var(--splat-text-muted); + font-size: 0.875rem; + margin: 0 0 2rem; +} + +.splat-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.5rem; +} + +/* ── Card ── */ + +.splat-card { + background: var(--splat-card-bg); + border: 1px solid var(--splat-border); + border-radius: 12px; + overflow: hidden; + text-decoration: none; + color: inherit; + transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s; + display: flex; + flex-direction: column; + backdrop-filter: blur(8px); +} + +.splat-card:hover { + transform: translateY(-2px); + border-color: var(--splat-accent); + box-shadow: 0 8px 24px rgba(129, 140, 248, 0.15); +} + +.splat-card__preview { + height: 160px; + background: linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #1e293b 100%); + display: flex; + align-items: center; + justify-content: center; + font-size: 3rem; + position: relative; +} + +.splat-card__body { + padding: 1rem; + flex: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.splat-card__title { + font-weight: 600; + font-size: 1rem; + line-height: 1.3; +} + +.splat-card__meta { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.75rem; + color: var(--splat-text-muted); +} + +.splat-badge { + display: inline-block; + padding: 0.125rem 0.5rem; + border-radius: 9999px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.splat-badge--ply { background: #4338ca; color: #e0e7ff; } +.splat-badge--splat { background: #7c3aed; color: #ede9fe; } +.splat-badge--spz { background: #2563eb; color: #dbeafe; } + +/* ── Upload ── */ + +.splat-upload { + margin-top: 2rem; + border: 2px dashed var(--splat-border); + border-radius: 12px; + padding: 2rem; + text-align: center; + transition: border-color 0.2s, background 0.2s; +} + +.splat-upload:hover, +.splat-upload--dragover { + border-color: var(--splat-accent); + background: rgba(129, 140, 248, 0.05); +} + +.splat-upload__icon { + font-size: 2.5rem; + margin-bottom: 0.75rem; +} + +.splat-upload__text { + color: var(--splat-text-muted); + font-size: 0.875rem; + margin: 0 0 1rem; +} + +.splat-upload__text strong { + color: var(--splat-accent); + cursor: pointer; +} + +.splat-upload__form { + display: none; + flex-direction: column; + gap: 0.75rem; + max-width: 400px; + margin: 1rem auto 0; +} + +.splat-upload__form.active { + display: flex; +} + +.splat-upload__form input, +.splat-upload__form textarea { + padding: 0.5rem 0.75rem; + border-radius: 6px; + border: 1px solid var(--splat-border); + background: var(--splat-surface); + color: var(--splat-text); + font-size: 0.875rem; +} + +.splat-upload__form textarea { + resize: vertical; + min-height: 60px; +} + +.splat-upload__btn { + padding: 0.625rem 1.25rem; + border-radius: 8px; + border: none; + background: var(--splat-accent); + color: white; + font-weight: 600; + font-size: 0.875rem; + cursor: pointer; + transition: background 0.2s; +} + +.splat-upload__btn:hover { + background: var(--splat-accent-hover); +} + +.splat-upload__btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.splat-upload__status { + font-size: 0.8rem; + color: var(--splat-text-muted); +} + +.splat-upload__login { + color: var(--splat-text-muted); + font-size: 0.875rem; +} + +.splat-upload__login a { + color: var(--splat-accent); +} + +/* ── Viewer ── */ + +.splat-viewer { + position: relative; + width: 100%; + height: calc(100vh - 56px); + background: #111827; + overflow: hidden; +} + +.splat-viewer__canvas { + width: 100%; + height: 100%; + display: block; +} + +.splat-viewer__controls { + position: absolute; + top: 1rem; + left: 1rem; + z-index: 10; + display: flex; + gap: 0.5rem; +} + +.splat-viewer__back { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 0.75rem; + border-radius: 8px; + background: rgba(30, 41, 59, 0.85); + backdrop-filter: blur(8px); + color: var(--splat-text); + text-decoration: none; + font-size: 0.8rem; + border: 1px solid var(--splat-border); + transition: background 0.2s; +} + +.splat-viewer__back:hover { + background: rgba(51, 65, 85, 0.9); +} + +.splat-viewer__info { + position: absolute; + bottom: 1rem; + left: 1rem; + z-index: 10; + padding: 0.75rem 1rem; + border-radius: 8px; + background: rgba(30, 41, 59, 0.85); + backdrop-filter: blur(8px); + border: 1px solid var(--splat-border); + color: var(--splat-text); + font-size: 0.8rem; + max-width: 320px; +} + +.splat-viewer__title { + font-weight: 600; + margin: 0 0 0.25rem; +} + +.splat-viewer__desc { + color: var(--splat-text-muted); + margin: 0; +} + +/* ── Loading ── */ + +.splat-loading { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: #111827; + z-index: 20; + transition: opacity 0.4s; +} + +.splat-loading.hidden { + opacity: 0; + pointer-events: none; +} + +.splat-loading__spinner { + width: 48px; + height: 48px; + border: 3px solid var(--splat-border); + border-top-color: var(--splat-accent); + border-radius: 50%; + animation: splat-spin 0.8s linear infinite; +} + +.splat-loading__text { + margin-top: 1rem; + color: var(--splat-text-muted); + font-size: 0.875rem; +} + +@keyframes splat-spin { + to { transform: rotate(360deg); } +} + +/* ── Empty state ── */ + +.splat-empty { + text-align: center; + padding: 3rem 1rem; + color: var(--splat-text-muted); +} + +.splat-empty__icon { + font-size: 3rem; + margin-bottom: 1rem; +} + +.splat-empty h3 { + font-size: 1.125rem; + color: var(--splat-text); + margin: 0 0 0.5rem; +} + +.splat-empty p { + font-size: 0.875rem; + margin: 0; +} + +/* ── Responsive ── */ + +@media (max-width: 640px) { + .splat-gallery { + padding: 1rem; + } + + .splat-grid { + grid-template-columns: 1fr; + } + + .splat-viewer__info { + left: 0.5rem; + right: 0.5rem; + max-width: none; + } +} diff --git a/modules/splat/db/schema.sql b/modules/splat/db/schema.sql new file mode 100644 index 0000000..1aa35a3 --- /dev/null +++ b/modules/splat/db/schema.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS rsplat.splats ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + slug TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + description TEXT, + file_path TEXT NOT NULL, + file_format TEXT NOT NULL DEFAULT 'ply', + file_size_bytes BIGINT DEFAULT 0, + tags TEXT[] DEFAULT '{}', + space_slug TEXT NOT NULL DEFAULT 'demo', + contributor_id TEXT, + contributor_name TEXT, + source TEXT DEFAULT 'upload', + status TEXT NOT NULL DEFAULT 'published', + view_count INTEGER DEFAULT 0, + payment_tx TEXT, + payment_network TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_splats_space ON rsplat.splats (space_slug); +CREATE INDEX IF NOT EXISTS idx_splats_slug ON rsplat.splats (slug); +CREATE INDEX IF NOT EXISTS idx_splats_status ON rsplat.splats (status); +CREATE INDEX IF NOT EXISTS idx_splats_created ON rsplat.splats (created_at DESC); diff --git a/modules/splat/mod.ts b/modules/splat/mod.ts new file mode 100644 index 0000000..b4f26e1 --- /dev/null +++ b/modules/splat/mod.ts @@ -0,0 +1,415 @@ +/** + * Splat module — Gaussian splat viewer with x402 gated uploads. + * + * Routes are relative to mount point (/:space/splat in unified). + * Three.js + GaussianSplats3D loaded via CDN importmap. + */ + +import { Hono } from "hono"; +import { resolve } from "node:path"; +import { mkdir, readFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import { sql } from "../../shared/db/pool"; +import { renderShell } from "../../server/shell"; +import { getModuleInfoList } from "../../shared/module"; +import type { RSpaceModule } from "../../shared/module"; +import { + verifyEncryptIDToken, + extractToken, +} from "@encryptid/sdk/server"; +import { setupX402FromEnv } from "../../shared/x402/hono-middleware"; + +const SPLATS_DIR = process.env.SPLATS_DIR || "/data/splats"; +const VALID_FORMATS = ["ply", "splat", "spz"]; + +// ── Types ── + +export interface SplatRow { + id: string; + slug: string; + title: string; + description: string | null; + file_path: string; + file_format: string; + file_size_bytes: number; + tags: string[]; + space_slug: string; + contributor_id: string | null; + contributor_name: string | null; + source: string; + status: string; + view_count: number; + payment_tx: string | null; + payment_network: string | null; + created_at: string; +} + +// ── Helpers ── + +function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 80); +} + +function escapeAttr(s: string): string { + return s.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); +} + +function getFileFormat(filename: string): string | null { + const ext = filename.split(".").pop()?.toLowerCase(); + return ext && VALID_FORMATS.includes(ext) ? ext : null; +} + +function getMimeType(format: string): string { + switch (format) { + case "ply": return "application/x-ply"; + case "splat": return "application/octet-stream"; + case "spz": return "application/octet-stream"; + default: return "application/octet-stream"; + } +} + +// ── CDN importmap for Three.js + GaussianSplats3D ── + +const IMPORTMAP = ``; + +// ── x402 middleware ── + +const x402Middleware = setupX402FromEnv({ + description: "Upload Gaussian splat file", + resource: "/api/splats", +}); + +// ── Routes ── + +const routes = new Hono(); + +// ── API: List splats ── +routes.get("/api/splats", async (c) => { + const spaceSlug = c.req.param("space") || "demo"; + const tag = c.req.query("tag"); + const limit = Math.min(parseInt(c.req.query("limit") || "50"), 100); + const offset = parseInt(c.req.query("offset") || "0"); + + let query = `SELECT id, slug, title, description, file_format, file_size_bytes, + tags, contributor_name, view_count, created_at + FROM rsplat.splats WHERE status = 'published' AND space_slug = $1`; + const params: (string | number)[] = [spaceSlug]; + + if (tag) { + params.push(tag); + query += ` AND $${params.length} = ANY(tags)`; + } + + query += ` ORDER BY created_at DESC`; + params.push(limit); + query += ` LIMIT $${params.length}`; + params.push(offset); + query += ` OFFSET $${params.length}`; + + const rows = await sql.unsafe(query, params); + return c.json({ splats: rows }); +}); + +// ── API: Get splat details ── +routes.get("/api/splats/:id", async (c) => { + const id = c.req.param("id"); + + const rows = await sql.unsafe( + `SELECT * FROM rsplat.splats WHERE (slug = $1 OR id::text = $1) AND status = 'published'`, + [id] + ); + + if (rows.length === 0) return c.json({ error: "Splat not found" }, 404); + + // Increment view count + await sql.unsafe( + `UPDATE rsplat.splats SET view_count = view_count + 1 WHERE id = $1`, + [rows[0].id] + ); + + return c.json(rows[0]); +}); + +// ── API: Serve splat file ── +routes.get("/api/splats/:id/file", async (c) => { + const id = c.req.param("id"); + + const rows = await sql.unsafe( + `SELECT id, slug, file_path, file_format FROM rsplat.splats WHERE (slug = $1 OR id::text = $1) AND status = 'published'`, + [id] + ); + + if (rows.length === 0) return c.json({ error: "Splat not found" }, 404); + + const splat = rows[0]; + const filepath = resolve(SPLATS_DIR, splat.file_path); + const file = Bun.file(filepath); + + if (!(await file.exists())) { + return c.json({ error: "Splat file not found on disk" }, 404); + } + + return new Response(file, { + headers: { + "Content-Type": getMimeType(splat.file_format), + "Content-Disposition": `inline; filename="${splat.slug}.${splat.file_format}"`, + "Content-Length": String(file.size), + "Access-Control-Allow-Origin": "*", + "Cache-Control": "public, max-age=86400", + }, + }); +}); + +// ── API: Upload splat (EncryptID auth + optional x402) ── +routes.post("/api/splats", async (c) => { + // Auth check + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims; + try { + claims = await verifyEncryptIDToken(token); + } catch { + return c.json({ error: "Invalid token" }, 401); + } + + // x402 check (if enabled) + if (x402Middleware) { + const paymentResult = await new Promise((resolve) => { + const fakeNext = async () => { resolve(null); }; + x402Middleware(c, fakeNext).then((res) => { + if (res instanceof Response) resolve(res); + }); + }); + if (paymentResult) return paymentResult; + } + + const spaceSlug = c.req.param("space") || "demo"; + const formData = await c.req.formData(); + const file = formData.get("file") as File | null; + const title = (formData.get("title") as string || "").trim(); + const description = (formData.get("description") as string || "").trim() || null; + const tagsRaw = (formData.get("tags") as string || "").trim(); + + if (!file) { + return c.json({ error: "Splat file required" }, 400); + } + + const format = getFileFormat(file.name); + if (!format) { + return c.json({ error: "Invalid file format. Accepted: .ply, .splat, .spz" }, 400); + } + + if (!title) { + return c.json({ error: "Title required" }, 400); + } + + // 500MB limit + if (file.size > 500 * 1024 * 1024) { + return c.json({ error: "File too large. Maximum 500MB." }, 400); + } + + const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean) : []; + const shortId = randomUUID().slice(0, 8); + let slug = slugify(title); + + // Check slug collision + const existing = await sql.unsafe( + `SELECT 1 FROM rsplat.splats WHERE slug = $1`, [slug] + ); + if (existing.length > 0) { + slug = `${slug}-${shortId}`; + } + + // Save file to disk + await mkdir(SPLATS_DIR, { recursive: true }); + const filename = `${slug}.${format}`; + const filepath = resolve(SPLATS_DIR, filename); + const buffer = Buffer.from(await file.arrayBuffer()); + await Bun.write(filepath, buffer); + + // Insert into DB + const paymentTx = c.get("x402Payment") || null; + const rows = await sql.unsafe( + `INSERT INTO rsplat.splats (slug, title, description, file_path, file_format, file_size_bytes, tags, space_slug, contributor_id, contributor_name, payment_tx) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id, slug, title, description, file_format, file_size_bytes, tags, created_at`, + [slug, title, description, filename, format, buffer.length, tags, spaceSlug, claims.sub, claims.username || null, paymentTx] + ); + + return c.json(rows[0], 201); +}); + +// ── API: Delete splat (owner only) ── +routes.delete("/api/splats/:id", async (c) => { + const token = extractToken(c.req.raw.headers); + if (!token) return c.json({ error: "Authentication required" }, 401); + + let claims; + try { + claims = await verifyEncryptIDToken(token); + } catch { + return c.json({ error: "Invalid token" }, 401); + } + + const id = c.req.param("id"); + const rows = await sql.unsafe( + `SELECT id, contributor_id FROM rsplat.splats WHERE (slug = $1 OR id::text = $1) AND status = 'published'`, + [id] + ); + + if (rows.length === 0) return c.json({ error: "Splat not found" }, 404); + if (rows[0].contributor_id !== claims.sub) { + return c.json({ error: "Not authorized" }, 403); + } + + await sql.unsafe( + `UPDATE rsplat.splats SET status = 'removed' WHERE id = $1`, + [rows[0].id] + ); + + return c.json({ ok: true }); +}); + +// ── Page: Gallery ── +routes.get("/", async (c) => { + const spaceSlug = c.req.param("space") || "demo"; + + const rows = await sql.unsafe( + `SELECT id, slug, title, description, file_format, file_size_bytes, + tags, contributor_name, view_count, created_at + FROM rsplat.splats WHERE status = 'published' AND space_slug = $1 + ORDER BY created_at DESC LIMIT 50`, + [spaceSlug] + ); + + const splatsJSON = JSON.stringify(rows); + + const html = renderShell({ + title: `${spaceSlug} — rSplat | rSpace`, + moduleId: "splat", + spaceSlug, + body: ``, + modules: getModuleInfoList(), + theme: "dark", + head: ` + + ${IMPORTMAP} + `, + scripts: ` + + `, + }); + + return c.html(html); +}); + +// ── Page: Viewer ── +routes.get("/view/:id", async (c) => { + const spaceSlug = c.req.param("space") || "demo"; + const id = c.req.param("id"); + + const rows = await sql.unsafe( + `SELECT * FROM rsplat.splats WHERE (slug = $1 OR id::text = $1) AND status = 'published'`, + [id] + ); + + if (rows.length === 0) { + const html = renderShell({ + title: "Splat not found | rSpace", + moduleId: "splat", + spaceSlug, + body: `

Splat not found

Back to gallery

`, + modules: getModuleInfoList(), + theme: "dark", + }); + return c.html(html, 404); + } + + const splat = rows[0]; + + // Increment view count + await sql.unsafe( + `UPDATE rsplat.splats SET view_count = view_count + 1 WHERE id = $1`, + [splat.id] + ); + + const fileUrl = `/${spaceSlug}/splat/api/splats/${splat.slug}/file`; + + const html = renderShell({ + title: `${splat.title} | rSplat`, + moduleId: "splat", + spaceSlug, + body: ` + + `, + modules: getModuleInfoList(), + theme: "dark", + head: ` + + ${IMPORTMAP} + `, + scripts: ` + + `, + }); + + return c.html(html); +}); + +// ── Initialize DB schema ── +async function initDB(): Promise { + try { + const schemaPath = resolve(import.meta.dir, "db/schema.sql"); + const schemaSql = await readFile(schemaPath, "utf-8"); + await sql.unsafe(`SET search_path TO rsplat, public`); + await sql.unsafe(schemaSql); + await sql.unsafe(`SET search_path TO public`); + console.log("[Splat] Database schema initialized"); + } catch (e) { + console.error("[Splat] Schema init failed:", e); + } +} + +// ── Module export ── + +export const splatModule: RSpaceModule = { + id: "splat", + name: "rSplat", + icon: "🔮", + description: "3D Gaussian splat viewer", + routes, + + async onSpaceCreate(_spaceSlug: string) { + // Splats are scoped by space_slug column. No per-space setup needed. + }, +}; + +// Run schema init on import +initDB(); diff --git a/package.json b/package.json index a57e16e..f07b4b2 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ "perfect-freehand": "^1.2.2", "@aws-sdk/client-s3": "^3.700.0", "imapflow": "^1.0.170", - "mailparser": "^3.7.2" + "mailparser": "^3.7.2", + "@x402/core": "^2.3.1", + "@x402/evm": "^2.3.1" }, "devDependencies": { "@types/nodemailer": "^6.4.0", diff --git a/server/index.ts b/server/index.ts index 75ff701..48d2d21 100644 --- a/server/index.ts +++ b/server/index.ts @@ -60,6 +60,7 @@ import { tubeModule } from "../modules/tube/mod"; import { inboxModule } from "../modules/inbox/mod"; import { dataModule } from "../modules/data/mod"; import { conicModule } from "../modules/conic/mod"; +import { splatModule } from "../modules/splat/mod"; import { spaces } from "./spaces"; import { renderShell } from "./shell"; @@ -86,6 +87,7 @@ registerModule(tubeModule); registerModule(inboxModule); registerModule(dataModule); registerModule(conicModule); +registerModule(splatModule); // ── Config ── const PORT = Number(process.env.PORT) || 3000; diff --git a/shared/x402/hono-middleware.ts b/shared/x402/hono-middleware.ts new file mode 100644 index 0000000..aabbf1e --- /dev/null +++ b/shared/x402/hono-middleware.ts @@ -0,0 +1,104 @@ +/** + * x402 Hono middleware — reusable payment gate for rSpace modules. + * + * When X402_PAY_TO env is set, protects routes with x402 micro-transactions. + * When not set, acts as a no-op passthrough. + */ + +import type { Context, Next, MiddlewareHandler } from "hono"; + +export interface X402Config { + payTo: string; + network: string; + amount: string; + facilitatorUrl: string; + resource?: string; + description?: string; +} + +/** + * Create x402 payment middleware for Hono routes. + * Returns null if X402_PAY_TO is not configured (disabled). + */ +export function createX402Middleware(config: X402Config): MiddlewareHandler { + return async (c: Context, next: Next) => { + const paymentHeader = c.req.header("X-PAYMENT"); + + if (!paymentHeader) { + // Return 402 with payment requirements + const requirements = { + x402Version: 1, + scheme: "exact", + network: config.network, + maxAmountRequired: config.amount, + resource: config.resource || c.req.url, + description: config.description || "Payment required for upload", + payTo: config.payTo, + maxTimeoutSeconds: 300, + }; + + return c.json( + { error: "Payment Required", paymentRequirements: requirements }, + 402, + { "X-PAYMENT-REQUIREMENTS": JSON.stringify(requirements) } + ); + } + + // Verify payment via facilitator + try { + const verifyRes = await fetch(`${config.facilitatorUrl}/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + payment: paymentHeader, + requirements: { + scheme: "exact", + network: config.network, + maxAmountRequired: config.amount, + payTo: config.payTo, + }, + }), + }); + + if (!verifyRes.ok) { + const err = await verifyRes.text(); + return c.json({ error: "Payment verification failed", details: err }, 402); + } + + const result = await verifyRes.json() as { valid?: boolean }; + if (!result.valid) { + return c.json({ error: "Payment invalid or insufficient" }, 402); + } + + // Payment valid — store tx info for downstream handlers + c.set("x402Payment", paymentHeader); + await next(); + } catch (e) { + console.error("[x402] Verification error:", e); + return c.json({ error: "Payment verification service unavailable" }, 503); + } + }; +} + +/** + * Initialize x402 from environment variables. + * Returns middleware or null if disabled. + */ +export function setupX402FromEnv(overrides?: Partial): MiddlewareHandler | null { + const payTo = process.env.X402_PAY_TO; + if (!payTo) { + console.log("[x402] Disabled — X402_PAY_TO not set"); + return null; + } + + const config: X402Config = { + payTo, + network: process.env.X402_NETWORK || "eip155:84532", + amount: process.env.X402_UPLOAD_PRICE || "0.01", + facilitatorUrl: process.env.X402_FACILITATOR_URL || "https://x402.org/facilitator", + ...overrides, + }; + + console.log(`[x402] Enabled — payTo=${payTo}, network=${config.network}, amount=${config.amount}`); + return createX402Middleware(config); +} diff --git a/shared/x402/types.d.ts b/shared/x402/types.d.ts new file mode 100644 index 0000000..90a088a --- /dev/null +++ b/shared/x402/types.d.ts @@ -0,0 +1,41 @@ +declare module "@x402/core" { + export interface PaymentRequirements { + scheme: string; + network: string; + maxAmountRequired: string; + resource: string; + description: string; + mimeType?: string; + payTo: string; + maxTimeoutSeconds?: number; + outputSchema?: unknown; + extra?: Record; + } + + export function exact( + payTo: string, + amount: string | number, + extra?: Record + ): PaymentRequirements; +} + +declare module "@x402/core/types" { + export interface PaymentPayload { + x402Version: number; + scheme: string; + network: string; + payload: unknown; + } +} + +declare module "@x402/core/verify" { + export function verifyPayment( + payment: string, + requirements: import("@x402/core").PaymentRequirements, + opts?: { facilitatorUrl?: string } + ): Promise<{ valid: boolean; error?: string }>; +} + +declare module "@x402/evm" { + export function getEvmPaymentSchemes(): unknown[]; +} diff --git a/vite.config.ts b/vite.config.ts index a2cff11..e295fb0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -659,6 +659,34 @@ export default defineConfig({ resolve(__dirname, "modules/conic/components/conic.css"), resolve(__dirname, "dist/modules/conic/conic.css"), ); + + // Build splat module component + await build({ + configFile: false, + root: resolve(__dirname, "modules/splat/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/splat"), + lib: { + entry: resolve(__dirname, "modules/splat/components/folk-splat-viewer.ts"), + formats: ["es"], + fileName: () => "folk-splat-viewer.js", + }, + rollupOptions: { + external: ["three", "three/addons/", "@mkkellogg/gaussian-splats-3d"], + output: { + entryFileNames: "folk-splat-viewer.js", + }, + }, + }, + }); + + // Copy splat CSS + mkdirSync(resolve(__dirname, "dist/modules/splat"), { recursive: true }); + copyFileSync( + resolve(__dirname, "modules/splat/components/splat.css"), + resolve(__dirname, "dist/modules/splat/splat.css"), + ); }, }, },