Merge branch 'dev'
This commit is contained in:
commit
a38cd7bb5d
|
|
@ -78,7 +78,7 @@ import { vnbModule } from "../modules/rvnb/mod";
|
|||
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
||||
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
||||
import type { SpaceRoleString } from "./spaces";
|
||||
import { renderShell, renderSubPageInfo, renderOnboarding } from "./shell";
|
||||
import { renderShell, renderSubPageInfo, renderOnboarding, renderAccessDenied } from "./shell";
|
||||
import { renderOutputListPage } from "./output-list";
|
||||
import { renderMainLanding, renderSpaceDashboard } from "./landing";
|
||||
import { syncServer } from "./sync-instance";
|
||||
|
|
@ -2070,9 +2070,43 @@ for (const mod of getAllModules()) {
|
|||
const effectiveSpace = resolveDataSpace(mod.id, space, overrides);
|
||||
c.set("effectiveSpace", effectiveSpace);
|
||||
|
||||
// Resolve caller's role for write-method blocking
|
||||
// ── Access control for private spaces ──
|
||||
const vis = doc?.meta?.visibility || "private";
|
||||
const accept = c.req.header("Accept") || "";
|
||||
const isHtmlRequest = accept.includes("text/html");
|
||||
let authResolved = false;
|
||||
|
||||
if (vis === "private") {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
let claims: EncryptIDClaims | null = null;
|
||||
if (token) {
|
||||
try { claims = await verifyEncryptIDToken(token); } catch {}
|
||||
}
|
||||
const resolved = await resolveCallerRole(space, claims);
|
||||
if (resolved) {
|
||||
c.set("spaceRole", resolved.role);
|
||||
c.set("isOwner", resolved.isOwner);
|
||||
authResolved = true;
|
||||
}
|
||||
// Non-members (viewer role, not owner) cannot access private spaces
|
||||
if (!claims || (resolved && !resolved.isOwner && resolved.role === "viewer")) {
|
||||
if (isHtmlRequest) {
|
||||
const userSlug = claims?.username || undefined;
|
||||
return c.html(renderAccessDenied({ spaceSlug: space, modules: getModuleInfoList(), userSlug }), 403);
|
||||
}
|
||||
return c.json({ error: "You don't have access to this space" }, 403);
|
||||
}
|
||||
} else if (vis === "permissioned" && !isHtmlRequest) {
|
||||
// API requests to permissioned spaces require auth
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) {
|
||||
return c.json({ error: "Authentication required" }, 401);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve caller's role for write-method blocking (skip if already resolved)
|
||||
const method = c.req.method;
|
||||
if (!mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
|
||||
if (!authResolved && !mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
let claims: EncryptIDClaims | null = null;
|
||||
if (token) {
|
||||
|
|
@ -2086,6 +2120,12 @@ for (const mod of getAllModules()) {
|
|||
return c.json({ error: "Write access required" }, 403);
|
||||
}
|
||||
}
|
||||
} else if (authResolved && !mod.publicWrite && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
|
||||
// Already resolved for private space — just check the write gate
|
||||
const role = c.get("spaceRole") as SpaceRoleString | undefined;
|
||||
if (role === "viewer") {
|
||||
return c.json({ error: "Write access required" }, 403);
|
||||
}
|
||||
}
|
||||
|
||||
return next();
|
||||
|
|
@ -2287,10 +2327,28 @@ app.post("/admin-action", async (c) => {
|
|||
});
|
||||
|
||||
// Space root: /:space → space dashboard
|
||||
app.get("/:space", (c) => {
|
||||
app.get("/:space", async (c) => {
|
||||
const space = c.req.param("space");
|
||||
// Don't serve dashboard for static file paths
|
||||
if (space.includes(".")) return c.notFound();
|
||||
|
||||
// Gate private spaces: non-members see access denied page
|
||||
await loadCommunity(space);
|
||||
const doc = getDocumentData(space);
|
||||
const vis = doc?.meta?.visibility || "private";
|
||||
if (vis === "private") {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
let claims: EncryptIDClaims | null = null;
|
||||
if (token) {
|
||||
try { claims = await verifyEncryptIDToken(token); } catch {}
|
||||
}
|
||||
const resolved = await resolveCallerRole(space, claims);
|
||||
if (!claims || (resolved && !resolved.isOwner && resolved.role === "viewer")) {
|
||||
const userSlug = claims?.username || undefined;
|
||||
return c.html(renderAccessDenied({ spaceSlug: space, modules: getModuleInfoList(), userSlug }), 403);
|
||||
}
|
||||
}
|
||||
|
||||
return c.html(renderSpaceDashboard(space, getModuleInfoList()));
|
||||
});
|
||||
|
||||
|
|
@ -2549,18 +2607,30 @@ const server = Bun.serve<WSData>({
|
|||
let readOnly = false;
|
||||
let spaceRole: WSData['spaceRole'] = null;
|
||||
|
||||
// Load doc early so we can check membership for private spaces
|
||||
await loadCommunity(communitySlug);
|
||||
const spaceData = getDocumentData(communitySlug);
|
||||
|
||||
if (spaceConfig) {
|
||||
const vis = spaceConfig.visibility;
|
||||
if (vis === "permissioned" || vis === "private") {
|
||||
if (!claims) return new Response("Authentication required", { status: 401 });
|
||||
} else if (vis === "public") {
|
||||
}
|
||||
// Reject non-members of private spaces
|
||||
if (vis === "private" && claims && spaceData) {
|
||||
const callerDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`;
|
||||
const isOwner = spaceData.meta?.ownerDID === claims.sub || spaceData.meta?.ownerDID === callerDid;
|
||||
const isMember = spaceData.members?.[claims.sub] || spaceData.members?.[callerDid];
|
||||
if (!isOwner && !isMember) {
|
||||
return new Response("You don't have access to this space", { status: 403 });
|
||||
}
|
||||
}
|
||||
if (vis === "public") {
|
||||
readOnly = !claims;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the caller's space role
|
||||
await loadCommunity(communitySlug);
|
||||
const spaceData = getDocumentData(communitySlug);
|
||||
if (spaceData) {
|
||||
if (claims && spaceData.meta.ownerDID === claims.sub) {
|
||||
spaceRole = 'admin';
|
||||
|
|
|
|||
|
|
@ -2544,6 +2544,65 @@ const ONBOARDING_CSS = `
|
|||
}
|
||||
`;
|
||||
|
||||
// ── Access denied page ──
|
||||
|
||||
export function renderAccessDenied(opts: { spaceSlug: string; modules: ModuleInfo[]; userSlug?: string }): string {
|
||||
const { spaceSlug, modules, userSlug } = opts;
|
||||
const homeHref = userSlug
|
||||
? `https://${userSlug}.rspace.online/`
|
||||
: `https://rspace.online/`;
|
||||
const homeLabel = userSlug ? `Return to ${userSlug}` : "Return to rSpace";
|
||||
|
||||
const body = `
|
||||
<div class="access-denied">
|
||||
<div class="access-denied__card">
|
||||
<div class="access-denied__icon">🔒</div>
|
||||
<h1 class="access-denied__title">Private Space</h1>
|
||||
<p class="access-denied__desc">You don't have access to <strong>${escapeHtml(spaceSlug)}</strong>.</p>
|
||||
<p class="access-denied__hint">This space is private. Ask the owner to invite you as a member.</p>
|
||||
<a href="${escapeAttr(homeHref)}" class="access-denied__btn">${escapeHtml(homeLabel)}</a>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
return renderShell({
|
||||
title: `Access Denied — ${spaceSlug} | rSpace`,
|
||||
moduleId: "rspace",
|
||||
spaceSlug,
|
||||
modules,
|
||||
body,
|
||||
styles: `<style>${ACCESS_DENIED_CSS}</style>`,
|
||||
});
|
||||
}
|
||||
|
||||
const ACCESS_DENIED_CSS = `
|
||||
.access-denied {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
min-height: calc(70vh - 56px); padding: 3rem 1.5rem 2rem;
|
||||
}
|
||||
.access-denied__card {
|
||||
text-align: center; max-width: 420px;
|
||||
background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 16px; padding: 2.5rem 2rem;
|
||||
}
|
||||
.access-denied__icon { font-size: 3rem; margin-bottom: 1rem; }
|
||||
.access-denied__title {
|
||||
font-size: 1.5rem; font-weight: 600; margin: 0 0 0.75rem;
|
||||
color: var(--color-text, #e0e0e0);
|
||||
}
|
||||
.access-denied__desc {
|
||||
color: var(--color-text-muted, #aaa); margin: 0 0 0.5rem; font-size: 1rem;
|
||||
}
|
||||
.access-denied__hint {
|
||||
color: var(--color-text-muted, #888); margin: 0 0 1.5rem; font-size: 0.875rem;
|
||||
}
|
||||
.access-denied__btn {
|
||||
display: inline-block; padding: 0.6rem 1.5rem; border-radius: 8px;
|
||||
background: var(--color-primary, #6366f1); color: #fff; text-decoration: none;
|
||||
font-weight: 500; font-size: 0.9rem; transition: opacity 0.15s;
|
||||
}
|
||||
.access-denied__btn:hover { opacity: 0.85; }
|
||||
`;
|
||||
|
||||
// ── Demo page CSS utilities (rd-* prefix, parallel to rl-* landing pages) ──
|
||||
|
||||
export function escapeHtml(s: string): string {
|
||||
|
|
|
|||
Loading…
Reference in New Issue