Add rSplat module — Gaussian splat viewer with x402 gated uploads
New rSpace module for 3D Gaussian splat viewing. Gallery + full-viewport Three.js/GaussianSplats3D viewer loaded via CDN importmap. EncryptID auth on uploads, optional x402 micro-transaction gate. Reusable x402 Hono middleware in shared/x402/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
cf3be7d7a9
commit
c99c25f174
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,313 @@
|
|||
/**
|
||||
* <folk-splat-viewer> — 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) => `
|
||||
<a class="splat-card" href="/${this._spaceSlug}/splat/view/${s.slug}">
|
||||
<div class="splat-card__preview">
|
||||
<span>🔮</span>
|
||||
</div>
|
||||
<div class="splat-card__body">
|
||||
<div class="splat-card__title">${esc(s.title)}</div>
|
||||
<div class="splat-card__meta">
|
||||
<span class="splat-badge splat-badge--${s.file_format}">${s.file_format}</span>
|
||||
<span>${formatSize(s.file_size_bytes)}</span>
|
||||
<span>${s.view_count} views</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
`).join("");
|
||||
|
||||
const empty = this._splats.length === 0 ? `
|
||||
<div class="splat-empty">
|
||||
<div class="splat-empty__icon">🔮</div>
|
||||
<h3>No splats yet</h3>
|
||||
<p>Upload a .ply, .splat, or .spz file to get started</p>
|
||||
</div>
|
||||
` : "";
|
||||
|
||||
this.innerHTML = `
|
||||
<div class="splat-gallery">
|
||||
<h1>rSplat</h1>
|
||||
<p class="splat-gallery__subtitle">3D Gaussian Splat Gallery</p>
|
||||
${empty}
|
||||
<div class="splat-grid">${cards}</div>
|
||||
<div class="splat-upload" id="splat-drop">
|
||||
<div class="splat-upload__icon">📤</div>
|
||||
<p class="splat-upload__text">
|
||||
Drag & drop a <strong>.ply</strong>, <strong>.splat</strong>, or <strong>.spz</strong> file here
|
||||
<br>or <strong id="splat-browse">browse</strong> to upload
|
||||
</p>
|
||||
<input type="file" id="splat-file" accept=".ply,.splat,.spz" hidden>
|
||||
<div class="splat-upload__form" id="splat-form">
|
||||
<input type="text" id="splat-title-input" placeholder="Title (required)" required>
|
||||
<textarea id="splat-desc-input" placeholder="Description (optional)" rows="2"></textarea>
|
||||
<input type="text" id="splat-tags-input" placeholder="Tags (comma-separated)">
|
||||
<button class="splat-upload__btn" id="splat-submit" disabled>Upload Splat</button>
|
||||
<div class="splat-upload__status" id="splat-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<div class="splat-viewer">
|
||||
<div class="splat-loading" id="splat-loading">
|
||||
<div class="splat-loading__spinner"></div>
|
||||
<div class="splat-loading__text">Loading splat...</div>
|
||||
</div>
|
||||
<div class="splat-viewer__controls">
|
||||
<a class="splat-viewer__back" href="/${this._spaceSlug}/splat">← Gallery</a>
|
||||
</div>
|
||||
${this._splatTitle ? `
|
||||
<div class="splat-viewer__info">
|
||||
<p class="splat-viewer__title">${esc(this._splatTitle)}</p>
|
||||
${this._splatDesc ? `<p class="splat-viewer__desc">${esc(this._splatDesc)}</p>` : ""}
|
||||
</div>
|
||||
` : ""}
|
||||
<div id="splat-container" class="splat-viewer__canvas"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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, ">").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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -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, "<").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 = `<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
|
||||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/",
|
||||
"@mkkellogg/gaussian-splats-3d": "https://cdn.jsdelivr.net/npm/@mkkellogg/gaussian-splats-3d@0.4.6/build/gaussian-splats-3d.module.js"
|
||||
}
|
||||
}
|
||||
</script>`;
|
||||
|
||||
// ── 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<Response | null>((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: `<folk-splat-viewer id="gallery" mode="gallery"></folk-splat-viewer>`,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
head: `
|
||||
<link rel="stylesheet" href="/modules/splat/splat.css">
|
||||
${IMPORTMAP}
|
||||
`,
|
||||
scripts: `
|
||||
<script type="module">
|
||||
import { FolkSplatViewer } from '/modules/splat/folk-splat-viewer.js';
|
||||
const gallery = document.getElementById('gallery');
|
||||
gallery.splats = ${splatsJSON};
|
||||
gallery.spaceSlug = '${spaceSlug}';
|
||||
</script>
|
||||
`,
|
||||
});
|
||||
|
||||
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: `<div style="padding:3rem;text-align:center;color:#94a3b8;"><h2>Splat not found</h2><p><a href="/${spaceSlug}/splat" style="color:#818cf8;">Back to gallery</a></p></div>`,
|
||||
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: `
|
||||
<folk-splat-viewer
|
||||
id="viewer"
|
||||
mode="viewer"
|
||||
splat-url="${escapeAttr(fileUrl)}"
|
||||
splat-title="${escapeAttr(splat.title)}"
|
||||
splat-desc="${escapeAttr(splat.description || '')}"
|
||||
space-slug="${escapeAttr(spaceSlug)}"
|
||||
></folk-splat-viewer>
|
||||
`,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
head: `
|
||||
<link rel="stylesheet" href="/modules/splat/splat.css">
|
||||
${IMPORTMAP}
|
||||
`,
|
||||
scripts: `
|
||||
<script type="module">
|
||||
import { FolkSplatViewer } from '/modules/splat/folk-splat-viewer.js';
|
||||
</script>
|
||||
`,
|
||||
});
|
||||
|
||||
return c.html(html);
|
||||
});
|
||||
|
||||
// ── Initialize DB schema ──
|
||||
async function initDB(): Promise<void> {
|
||||
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();
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<X402Config>): 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);
|
||||
}
|
||||
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
export function exact(
|
||||
payTo: string,
|
||||
amount: string | number,
|
||||
extra?: Record<string, unknown>
|
||||
): 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[];
|
||||
}
|
||||
|
|
@ -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"),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue