fix: bypass Cloudflare /api/* redirect for admin dashboard
Cloudflare has a redirect rule that rewrites rspace.online/api/* to http://api.rspace.online/*, causing Mixed Content errors in the browser. Add a separate /admin/api router that serves the same admin data at paths that don't trigger the redirect rule. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8d77c6eee8
commit
caae204c2b
|
|
@ -65,7 +65,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 } from "./spaces";
|
||||
import { spaces, adminApi } from "./spaces";
|
||||
import { renderShell, renderModuleLanding } from "./shell";
|
||||
import { renderMainLanding, renderSpaceDashboard } from "./landing";
|
||||
import { fetchLandingPage } from "./landing-proxy";
|
||||
|
|
@ -134,6 +134,9 @@ 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";
|
||||
|
|
|
|||
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 } from "../shared/module";
|
||||
import { getAllModules, getModuleInfoList } from "../shared/module";
|
||||
|
||||
// ── In-memory pending nest requests (move to DB later) ──
|
||||
const nestRequests = new Map<string, PendingNestRequest>();
|
||||
|
|
@ -1125,4 +1125,112 @@ spaces.patch("/:slug/access-requests/:reqId", async (c) => {
|
|||
return c.json({ error: "action must be 'approve' or 'deny'" }, 400);
|
||||
});
|
||||
|
||||
export { spaces };
|
||||
// ── 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 };
|
||||
|
|
|
|||
|
|
@ -502,7 +502,7 @@
|
|||
RStackSpaceSwitcher.define();
|
||||
RStackMi.define();
|
||||
|
||||
fetch("/api/modules").then(r => r.json()).then(data => {
|
||||
fetch("/admin/api/modules").then(r => r.json()).then(data => {
|
||||
document.querySelector("rstack-app-switcher")?.setModules(data.modules || []);
|
||||
}).catch(() => {});
|
||||
|
||||
|
|
@ -528,7 +528,7 @@
|
|||
}
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/spaces/admin", { headers: authHeaders() });
|
||||
const res = await fetch("/admin/api/spaces", { 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>';
|
||||
|
|
@ -738,7 +738,7 @@
|
|||
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(`/api/spaces/admin/${encodeURIComponent(slug)}`, {
|
||||
const res = await fetch(`/admin/api/spaces/${encodeURIComponent(slug)}`, {
|
||||
method: "DELETE",
|
||||
headers: authHeaders(),
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue