fix: use single-segment admin endpoints to bypass Cloudflare redirect
Cloudflare has a wildcard rule that redirects any multi-segment path on rspace.online to a subdomain (e.g. /foo/bar → foo.rspace.online/bar). This broke both /api/* and /admin/api/* paths. Replace with single-segment endpoints: - GET /admin-data — returns spaces + modules (admin-only) - POST /admin-action — handles mutations like delete-space Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
caae204c2b
commit
85ac897a1a
128
server/index.ts
128
server/index.ts
|
|
@ -25,6 +25,8 @@ import {
|
|||
updateShape,
|
||||
updateShapeFields,
|
||||
cascadePermissions,
|
||||
listCommunities,
|
||||
deleteCommunity,
|
||||
} from "./community-store";
|
||||
import type { NestPermissions, SpaceRefFilter } from "./community-store";
|
||||
import { ensureDemoCommunity } from "./seed-demo";
|
||||
|
|
@ -65,7 +67,7 @@ import { photosModule } from "../modules/rphotos/mod";
|
|||
import { socialsModule } from "../modules/rsocials/mod";
|
||||
import { docsModule } from "../modules/rdocs/mod";
|
||||
import { designModule } from "../modules/rdesign/mod";
|
||||
import { spaces, adminApi } from "./spaces";
|
||||
import { spaces } from "./spaces";
|
||||
import { renderShell, renderModuleLanding } from "./shell";
|
||||
import { renderMainLanding, renderSpaceDashboard } from "./landing";
|
||||
import { fetchLandingPage } from "./landing-proxy";
|
||||
|
|
@ -134,9 +136,6 @@ app.get("/.well-known/webauthn", (c) => {
|
|||
// ── Space registry API ──
|
||||
app.route("/api/spaces", spaces);
|
||||
|
||||
// ── Admin API (bypasses Cloudflare /api/* redirect rule) ──
|
||||
app.route("/admin/api", adminApi);
|
||||
|
||||
// ── mi — AI assistant endpoint ──
|
||||
const MI_MODEL = process.env.MI_MODEL || "llama3.2";
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
|
||||
|
|
@ -865,7 +864,18 @@ app.get("/create-space", async (c) => {
|
|||
// Legacy redirect
|
||||
app.get("/new", (c) => c.redirect("/create-space", 301));
|
||||
|
||||
// Admin dashboard
|
||||
// ── Admin dashboard & API ──
|
||||
// NOTE: Cloudflare has a wildcard rule that redirects any multi-segment path
|
||||
// (rspace.online/foo/bar → foo.rspace.online/bar), so all admin endpoints
|
||||
// must be single-segment paths. Use POST /admin-action for mutations.
|
||||
|
||||
const ADMIN_DIDS = (process.env.ADMIN_DIDS || "").split(",").filter(Boolean);
|
||||
|
||||
function isAdminDID(did: string | undefined): boolean {
|
||||
return !!did && ADMIN_DIDS.includes(did);
|
||||
}
|
||||
|
||||
// Serve admin HTML page
|
||||
app.get("/admin", async (c) => {
|
||||
const file = Bun.file(resolve(DIST_DIR, "admin.html"));
|
||||
if (await file.exists()) {
|
||||
|
|
@ -874,6 +884,114 @@ app.get("/admin", async (c) => {
|
|||
return c.text("Admin", 200);
|
||||
});
|
||||
|
||||
// Admin data endpoint (GET) — returns spaces list + modules
|
||||
app.get("/admin-data", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims: EncryptIDClaims;
|
||||
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
if (!isAdminDID(claims.sub)) return c.json({ error: "Admin access required" }, 403);
|
||||
|
||||
const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities";
|
||||
const slugs = await listCommunities();
|
||||
|
||||
const spacesList = [];
|
||||
for (const slug of slugs) {
|
||||
await loadCommunity(slug);
|
||||
const data = getDocumentData(slug);
|
||||
if (!data?.meta) continue;
|
||||
|
||||
const shapes = data.shapes || {};
|
||||
const members = data.members || {};
|
||||
const shapeCount = Object.keys(shapes).length;
|
||||
const memberCount = Object.keys(members).length;
|
||||
|
||||
let fileSizeBytes = 0;
|
||||
try {
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const s = await stat(`${STORAGE_DIR}/${slug}.automerge`);
|
||||
fileSizeBytes = s.size;
|
||||
} catch {
|
||||
try {
|
||||
const { stat } = await import("node:fs/promises");
|
||||
const s = await stat(`${STORAGE_DIR}/${slug}.json`);
|
||||
fileSizeBytes = s.size;
|
||||
} catch { /* not on disk yet */ }
|
||||
}
|
||||
|
||||
const shapeTypes: Record<string, number> = {};
|
||||
for (const shape of Object.values(shapes)) {
|
||||
const t = (shape as Record<string, unknown>).type as string || "unknown";
|
||||
shapeTypes[t] = (shapeTypes[t] || 0) + 1;
|
||||
}
|
||||
|
||||
spacesList.push({
|
||||
slug: data.meta.slug,
|
||||
name: data.meta.name,
|
||||
visibility: data.meta.visibility || "public_read",
|
||||
createdAt: data.meta.createdAt,
|
||||
ownerDID: data.meta.ownerDID,
|
||||
shapeCount,
|
||||
memberCount,
|
||||
fileSizeBytes,
|
||||
shapeTypes,
|
||||
});
|
||||
}
|
||||
|
||||
spacesList.sort((a, b) => {
|
||||
const da = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const db = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
return db - da;
|
||||
});
|
||||
|
||||
return c.json({
|
||||
spaces: spacesList,
|
||||
total: spacesList.length,
|
||||
modules: getModuleInfoList(),
|
||||
});
|
||||
});
|
||||
|
||||
// Admin action endpoint (POST) — mutations (delete space, etc.)
|
||||
app.post("/admin-action", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims: EncryptIDClaims;
|
||||
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
if (!isAdminDID(claims.sub)) return c.json({ error: "Admin access required" }, 403);
|
||||
|
||||
const body = await c.req.json();
|
||||
const { action, slug } = body;
|
||||
|
||||
if (action === "delete-space" && slug) {
|
||||
await loadCommunity(slug);
|
||||
const data = getDocumentData(slug);
|
||||
if (!data) return c.json({ error: "Space not found" }, 404);
|
||||
|
||||
for (const mod of getAllModules()) {
|
||||
if (mod.onSpaceDelete) {
|
||||
try { await mod.onSpaceDelete(slug); } catch (e) {
|
||||
console.error(`[Admin] Module ${mod.id} onSpaceDelete failed:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up EncryptID space_members
|
||||
try {
|
||||
await fetch(`https://auth.rspace.online/api/admin/spaces/${slug}/members`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[Admin] Failed to clean EncryptID space_members for ${slug}:`, e);
|
||||
}
|
||||
|
||||
await deleteCommunity(slug);
|
||||
return c.json({ ok: true, message: `Space "${slug}" deleted by admin` });
|
||||
}
|
||||
|
||||
return c.json({ error: "Unknown action" }, 400);
|
||||
});
|
||||
|
||||
// Space root: /:space → space dashboard
|
||||
app.get("/:space", (c) => {
|
||||
const space = c.req.param("space");
|
||||
|
|
|
|||
112
server/spaces.ts
112
server/spaces.ts
|
|
@ -41,7 +41,7 @@ import {
|
|||
extractToken,
|
||||
} from "@encryptid/sdk/server";
|
||||
import type { EncryptIDClaims } from "@encryptid/sdk/server";
|
||||
import { getAllModules, getModuleInfoList } from "../shared/module";
|
||||
import { getAllModules } from "../shared/module";
|
||||
|
||||
// ── In-memory pending nest requests (move to DB later) ──
|
||||
const nestRequests = new Map<string, PendingNestRequest>();
|
||||
|
|
@ -1125,112 +1125,4 @@ spaces.patch("/:slug/access-requests/:reqId", async (c) => {
|
|||
return c.json({ error: "action must be 'approve' or 'deny'" }, 400);
|
||||
});
|
||||
|
||||
// ── Admin API router (mounted separately to bypass Cloudflare /api/* redirect) ──
|
||||
|
||||
const adminApi = new Hono();
|
||||
|
||||
adminApi.get("/modules", (c) => {
|
||||
return c.json({ modules: getModuleInfoList() });
|
||||
});
|
||||
|
||||
adminApi.get("/spaces", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims: EncryptIDClaims;
|
||||
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
if (!isAdmin(claims.sub)) return c.json({ error: "Admin access required" }, 403);
|
||||
|
||||
const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities";
|
||||
const slugs = await listCommunities();
|
||||
|
||||
const spacesList = [];
|
||||
for (const slug of slugs) {
|
||||
await loadCommunity(slug);
|
||||
const data = getDocumentData(slug);
|
||||
if (!data?.meta) continue;
|
||||
|
||||
const shapes = data.shapes || {};
|
||||
const members = data.members || {};
|
||||
const shapeCount = Object.keys(shapes).length;
|
||||
const memberCount = Object.keys(members).length;
|
||||
|
||||
let fileSizeBytes = 0;
|
||||
try {
|
||||
const s = await stat(`${STORAGE_DIR}/${slug}.automerge`);
|
||||
fileSizeBytes = s.size;
|
||||
} catch {
|
||||
try {
|
||||
const s = await stat(`${STORAGE_DIR}/${slug}.json`);
|
||||
fileSizeBytes = s.size;
|
||||
} catch { /* not on disk yet */ }
|
||||
}
|
||||
|
||||
const shapeTypes: Record<string, number> = {};
|
||||
for (const shape of Object.values(shapes)) {
|
||||
const t = (shape as Record<string, unknown>).type as string || "unknown";
|
||||
shapeTypes[t] = (shapeTypes[t] || 0) + 1;
|
||||
}
|
||||
|
||||
spacesList.push({
|
||||
slug: data.meta.slug,
|
||||
name: data.meta.name,
|
||||
visibility: data.meta.visibility || "public_read",
|
||||
createdAt: data.meta.createdAt,
|
||||
ownerDID: data.meta.ownerDID,
|
||||
shapeCount,
|
||||
memberCount,
|
||||
fileSizeBytes,
|
||||
shapeTypes,
|
||||
});
|
||||
}
|
||||
|
||||
spacesList.sort((a, b) => {
|
||||
const da = a.createdAt ? new Date(a.createdAt).getTime() : 0;
|
||||
const db = b.createdAt ? new Date(b.createdAt).getTime() : 0;
|
||||
return db - da;
|
||||
});
|
||||
|
||||
return c.json({ spaces: spacesList, total: spacesList.length });
|
||||
});
|
||||
|
||||
adminApi.delete("/spaces/:slug", async (c) => {
|
||||
const slug = c.req.param("slug");
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims: EncryptIDClaims;
|
||||
try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
if (!isAdmin(claims.sub)) return c.json({ error: "Admin access required" }, 403);
|
||||
|
||||
await loadCommunity(slug);
|
||||
const data = getDocumentData(slug);
|
||||
if (!data) return c.json({ error: "Space not found" }, 404);
|
||||
|
||||
for (const mod of getAllModules()) {
|
||||
if (mod.onSpaceDelete) {
|
||||
try { await mod.onSpaceDelete(slug); } catch (e) {
|
||||
console.error(`[Spaces] Module ${mod.id} onSpaceDelete failed:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, req] of accessRequests) {
|
||||
if (req.spaceSlug === slug) accessRequests.delete(id);
|
||||
}
|
||||
for (const [id, req] of nestRequests) {
|
||||
if (req.sourceSlug === slug || req.targetSlug === slug) nestRequests.delete(id);
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(`https://auth.rspace.online/api/admin/spaces/${slug}/members`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[Admin] Failed to clean EncryptID space_members for ${slug}:`, e);
|
||||
}
|
||||
|
||||
await deleteCommunity(slug);
|
||||
return c.json({ ok: true, message: `Space "${slug}" deleted by admin` });
|
||||
});
|
||||
|
||||
export { spaces, adminApi };
|
||||
export { spaces };
|
||||
|
|
|
|||
|
|
@ -502,9 +502,8 @@
|
|||
RStackSpaceSwitcher.define();
|
||||
RStackMi.define();
|
||||
|
||||
fetch("/admin/api/modules").then(r => r.json()).then(data => {
|
||||
document.querySelector("rstack-app-switcher")?.setModules(data.modules || []);
|
||||
}).catch(() => {});
|
||||
// NOTE: modules are loaded from /admin-data along with spaces (single-segment
|
||||
// path to bypass Cloudflare wildcard subdomain redirect on multi-segment paths)
|
||||
|
||||
const ENCRYPTID_URL = "https://auth.rspace.online";
|
||||
|
||||
|
|
@ -528,7 +527,7 @@
|
|||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/admin/api/spaces", { headers: authHeaders() });
|
||||
const res = await fetch("/admin-data", { headers: authHeaders() });
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
document.getElementById("auth-gate").innerHTML =
|
||||
'<div class="auth-gate"><h2>Access Denied</h2><p>Your account does not have admin privileges.</p></div>';
|
||||
|
|
@ -541,6 +540,9 @@
|
|||
document.getElementById("auth-gate").style.display = "none";
|
||||
document.getElementById("admin-content").style.display = "block";
|
||||
|
||||
// Load modules into app switcher
|
||||
document.querySelector("rstack-app-switcher")?.setModules(data.modules || []);
|
||||
|
||||
// Load spaces data
|
||||
allSpaces = data.spaces || [];
|
||||
updateSpaceStats();
|
||||
|
|
@ -738,9 +740,10 @@
|
|||
if (!confirm(`Delete space "${name}" (${slug})?\n\nThis will permanently delete all shapes, members, and data. This cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/admin/api/spaces/${encodeURIComponent(slug)}`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders(),
|
||||
const res = await fetch("/admin-action", {
|
||||
method: "POST",
|
||||
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "delete-space", slug }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
|
|
|||
Loading…
Reference in New Issue