Wire real auth and backends into all 21 rSpace modules
- Auth: 10 modules now use EncryptID token verification on write routes (vote, inbox, forum, files, notes, work, cal, trips, cart, providers). All POST/PUT/DELETE without valid token → 401. - Tube: S3Client for Cloudflare R2 bucket (upload, streaming, range) - Data: Umami analytics proxy (stats, active, tracker, events) - Wallet: Safe Global API proxy with 12 chains + cache headers - Network: Twenty CRM GraphQL client (people, companies, graph) - Maps: sync-url endpoint for WebSocket connection - Inbox: background IMAP sync worker (30s poll via ImapFlow) - Forum: provisioner already wired (Hetzner + Cloudflare + cloud-init) - Config: .gitignore fix, docker-compose env vars + rmail network, added @aws-sdk/client-s3, imapflow, mailparser deps Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2015a9c277
commit
cf3be7d7a9
|
|
@ -6,6 +6,7 @@ dist/
|
|||
|
||||
# Data storage
|
||||
data/
|
||||
!modules/data/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
|
|
|
|||
|
|
@ -23,6 +23,22 @@ services:
|
|||
- FLOW_ID=a79144ec-e6a2-4e30-a42a-6d8237a5953d
|
||||
- FUNNEL_ID=0ff6a9ac-1667-4fc7-9a01-b1620810509f
|
||||
- FILES_DIR=/data/files
|
||||
- R2_ENDPOINT=${R2_ENDPOINT}
|
||||
- R2_BUCKET=${R2_BUCKET:-rtube-videos}
|
||||
- R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID}
|
||||
- R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY}
|
||||
- R2_PUBLIC_URL=${R2_PUBLIC_URL}
|
||||
- UMAMI_URL=https://analytics.rspace.online
|
||||
- UMAMI_WEBSITE_ID=292f6ac6-79f8-497b-ba6a-7a51e3b87b9f
|
||||
- MAPS_SYNC_URL=wss://sync.rmaps.online
|
||||
- IMAP_HOST=mail.rmail.online
|
||||
- IMAP_PORT=993
|
||||
- IMAP_TLS_REJECT_UNAUTHORIZED=false
|
||||
- HETZNER_API_TOKEN=${HETZNER_API_TOKEN}
|
||||
- CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN}
|
||||
- CLOUDFLARE_ZONE_ID=${CLOUDFLARE_ZONE_ID}
|
||||
- TWENTY_API_URL=https://rnetwork.online
|
||||
- TWENTY_API_TOKEN=${TWENTY_API_TOKEN}
|
||||
depends_on:
|
||||
rspace-db:
|
||||
condition: service_healthy
|
||||
|
|
@ -43,6 +59,7 @@ services:
|
|||
- traefik-public
|
||||
- rspace-internal
|
||||
- payment-network
|
||||
- rmail-mailcow
|
||||
|
||||
rspace-db:
|
||||
image: postgres:16-alpine
|
||||
|
|
@ -76,4 +93,7 @@ networks:
|
|||
payment-network:
|
||||
name: payment-infra_payment-network
|
||||
external: true
|
||||
rmail-mailcow:
|
||||
name: mailcowdockerized_mailcow-network
|
||||
external: true
|
||||
rspace-internal:
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
|
|
@ -65,6 +66,11 @@ routes.get("/api/events", async (c) => {
|
|||
|
||||
// POST /api/events — create event
|
||||
routes.post("/api/events", 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 body = await c.req.json();
|
||||
const { title, description, start_time, end_time, all_day, timezone, source_id, location_id, location_name,
|
||||
is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id } = body;
|
||||
|
|
@ -72,11 +78,11 @@ routes.post("/api/events", async (c) => {
|
|||
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rcal.events (title, description, start_time, end_time, all_day, timezone, source_id,
|
||||
location_id, location_name, is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING *`,
|
||||
location_id, location_name, is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id, created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
|
||||
[title.trim(), description || null, start_time, end_time || null, all_day || false, timezone || "UTC",
|
||||
source_id || null, location_id || null, location_name || null, is_virtual || false,
|
||||
virtual_url || null, virtual_platform || null, r_tool_source || null, r_tool_entity_id || null]
|
||||
virtual_url || null, virtual_platform || null, r_tool_source || null, r_tool_entity_id || null, claims.sub]
|
||||
);
|
||||
return c.json(rows[0], 201);
|
||||
});
|
||||
|
|
@ -95,6 +101,11 @@ routes.get("/api/events/:id", async (c) => {
|
|||
|
||||
// PATCH /api/events/:id
|
||||
routes.patch("/api/events/: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 body = await c.req.json();
|
||||
|
||||
|
|
@ -148,6 +159,10 @@ routes.get("/api/sources", async (c) => {
|
|||
});
|
||||
|
||||
routes.post("/api/sources", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const body = await c.req.json();
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rcal.calendar_sources (name, source_type, url, color, is_active, is_visible)
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { renderShell } from "../../server/shell";
|
|||
import { getModuleInfoList } from "../../shared/module";
|
||||
import { depositOrderRevenue } from "./flow";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
|
|
@ -171,6 +172,13 @@ routes.patch("/api/catalog/:id", async (c) => {
|
|||
|
||||
// POST /api/orders — Create an order
|
||||
routes.post("/api/orders", async (c) => {
|
||||
// Optional auth — set buyer_did from claims if authenticated
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
let buyerDid: string | null = null;
|
||||
if (token) {
|
||||
try { const claims = await verifyEncryptIDToken(token); buyerDid = claims.sub; } catch {}
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const {
|
||||
catalog_entry_id, artifact_id, buyer_id, buyer_location, buyer_contact,
|
||||
|
|
@ -208,7 +216,7 @@ routes.post("/api/orders", async (c) => {
|
|||
RETURNING *`,
|
||||
[
|
||||
entry.id, entry.artifact_id,
|
||||
buyer_id || null,
|
||||
buyerDid || buyer_id || null,
|
||||
buyer_location ? JSON.stringify(buyer_location) : null,
|
||||
buyer_contact ? JSON.stringify(buyer_contact) : null,
|
||||
provider_id, provider_name || null, provider_distance_km || null,
|
||||
|
|
@ -228,6 +236,13 @@ routes.post("/api/orders", async (c) => {
|
|||
|
||||
// GET /api/orders — List orders
|
||||
routes.get("/api/orders", async (c) => {
|
||||
// Optional auth — filter by buyer if authenticated
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
let authedBuyer: string | null = null;
|
||||
if (token) {
|
||||
try { const claims = await verifyEncryptIDToken(token); authedBuyer = claims.sub; } catch {}
|
||||
}
|
||||
|
||||
const { status, provider_id, buyer_id, limit = "50", offset = "0" } = c.req.query();
|
||||
|
||||
const conditions: string[] = [];
|
||||
|
|
@ -236,7 +251,8 @@ routes.get("/api/orders", async (c) => {
|
|||
|
||||
if (status) { conditions.push(`o.status = $${paramIdx}`); params.push(status); paramIdx++; }
|
||||
if (provider_id) { conditions.push(`o.provider_id = $${paramIdx}`); params.push(provider_id); paramIdx++; }
|
||||
if (buyer_id) { conditions.push(`o.buyer_id = $${paramIdx}`); params.push(buyer_id); paramIdx++; }
|
||||
const effectiveBuyerId = buyer_id || (authedBuyer && !status && !provider_id ? authedBuyer : null);
|
||||
if (effectiveBuyerId) { conditions.push(`o.buyer_id = $${paramIdx}`); params.push(effectiveBuyerId); paramIdx++; }
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const limitNum = Math.min(parseInt(limit) || 50, 100);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,13 @@ import type { RSpaceModule } from "../../shared/module";
|
|||
const routes = new Hono();
|
||||
|
||||
const UMAMI_URL = process.env.UMAMI_URL || "https://analytics.rspace.online";
|
||||
const UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || "292f6ac6-79f8-497b-ba6a-7a51e3b87b9f";
|
||||
|
||||
const TRACKED_APPS = [
|
||||
"rSpace", "rNotes", "rVote", "rFunds", "rCart", "rWallet",
|
||||
"rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles",
|
||||
"rTrips", "rTube", "rWork", "rNetwork", "rData",
|
||||
];
|
||||
|
||||
// ── API routes ──
|
||||
|
||||
|
|
@ -22,30 +29,94 @@ routes.get("/api/info", (c) => {
|
|||
module: "data",
|
||||
name: "rData",
|
||||
umamiUrl: UMAMI_URL,
|
||||
umamiConfigured: !!UMAMI_URL,
|
||||
features: ["privacy-first", "cookieless", "self-hosted"],
|
||||
trackedApps: 17,
|
||||
trackedApps: TRACKED_APPS.length,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/health
|
||||
routes.get("/api/health", (c) => c.json({ ok: true }));
|
||||
|
||||
// GET /api/stats — summary stats (placeholder until Umami API is wired)
|
||||
routes.get("/api/stats", (c) => {
|
||||
// GET /api/stats — proxy to Umami stats API
|
||||
routes.get("/api/stats", async (c) => {
|
||||
const startAt = c.req.query("startAt") || String(Date.now() - 24 * 3600_000);
|
||||
const endAt = c.req.query("endAt") || String(Date.now());
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${UMAMI_URL}/api/websites/${UMAMI_WEBSITE_ID}/stats?startAt=${startAt}&endAt=${endAt}`,
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return c.json({
|
||||
...data as Record<string, unknown>,
|
||||
trackedApps: TRACKED_APPS.length,
|
||||
apps: TRACKED_APPS,
|
||||
selfHosted: true,
|
||||
dashboardUrl: UMAMI_URL,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Fallback when Umami unreachable
|
||||
return c.json({
|
||||
trackedApps: 17,
|
||||
trackedApps: TRACKED_APPS.length,
|
||||
cookiesSet: 0,
|
||||
scriptSize: "~2KB",
|
||||
selfHosted: true,
|
||||
dashboardUrl: UMAMI_URL,
|
||||
apps: [
|
||||
"rSpace", "rNotes", "rVote", "rFunds", "rCart", "rWallet",
|
||||
"rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles",
|
||||
"rTrips", "rTube", "rWork", "rNetwork", "rData",
|
||||
],
|
||||
apps: TRACKED_APPS,
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/active — proxy to Umami active visitors
|
||||
routes.get("/api/active", async (c) => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${UMAMI_URL}/api/websites/${UMAMI_WEBSITE_ID}/active`,
|
||||
{ signal: AbortSignal.timeout(5000) }
|
||||
);
|
||||
if (res.ok) return c.json(await res.json());
|
||||
} catch {}
|
||||
return c.json({ x: 0 });
|
||||
});
|
||||
|
||||
// GET /collect.js — proxy Umami tracker script
|
||||
routes.get("/collect.js", async (c) => {
|
||||
try {
|
||||
const res = await fetch(`${UMAMI_URL}/script.js`, { signal: AbortSignal.timeout(5000) });
|
||||
if (res.ok) {
|
||||
const script = await res.text();
|
||||
return new Response(script, {
|
||||
headers: {
|
||||
"Content-Type": "application/javascript",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
return new Response("/* umami unavailable */", {
|
||||
headers: { "Content-Type": "application/javascript" },
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/collect — proxy Umami event collection
|
||||
routes.post("/api/collect", async (c) => {
|
||||
try {
|
||||
const body = await c.req.text();
|
||||
const res = await fetch(`${UMAMI_URL}/api/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (res.ok) return c.json(await res.json());
|
||||
} catch {}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
|
|
@ -66,6 +67,11 @@ async function computeFileHash(buffer: ArrayBuffer): Promise<string> {
|
|||
|
||||
// ── File upload ──
|
||||
routes.post("/api/files", 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 formData = await c.req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
if (!file) return c.json({ error: "file is required" }, 400);
|
||||
|
|
@ -74,7 +80,7 @@ routes.post("/api/files", async (c) => {
|
|||
const title = formData.get("title")?.toString() || file.name.replace(/\.[^.]+$/, "");
|
||||
const description = formData.get("description")?.toString() || "";
|
||||
const tags = formData.get("tags")?.toString() || "[]";
|
||||
const uploadedBy = c.req.header("X-User-DID") || "";
|
||||
const uploadedBy = claims.sub;
|
||||
|
||||
const buffer = await file.arrayBuffer();
|
||||
const fileHash = await computeFileHash(buffer);
|
||||
|
|
@ -162,13 +168,19 @@ routes.delete("/api/files/:id", async (c) => {
|
|||
|
||||
// ── Create share link ──
|
||||
routes.post("/api/files/:id/share", async (c) => {
|
||||
const authToken = extractToken(c.req.raw.headers);
|
||||
if (!authToken) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const [file] = await sql.unsafe("SELECT * FROM rfiles.media_files WHERE id = $1", [c.req.param("id")]);
|
||||
if (!file) return c.json({ error: "File not found" }, 404);
|
||||
if (file.uploaded_by && file.uploaded_by !== claims.sub) return c.json({ error: "Not authorized" }, 403);
|
||||
|
||||
const body = await c.req.json<{ expires_in_hours?: number; max_downloads?: number; password?: string; note?: string }>();
|
||||
const token = generateToken();
|
||||
const expiresAt = body.expires_in_hours ? new Date(Date.now() + body.expires_in_hours * 3600_000).toISOString() : null;
|
||||
const createdBy = c.req.header("X-User-DID") || "";
|
||||
const createdBy = claims.sub;
|
||||
|
||||
let passwordHash: string | null = null;
|
||||
let isPasswordProtected = false;
|
||||
|
|
@ -202,12 +214,23 @@ routes.get("/api/files/:id/shares", async (c) => {
|
|||
|
||||
// ── Revoke share ──
|
||||
routes.post("/api/shares/:shareId/revoke", async (c) => {
|
||||
const authToken = extractToken(c.req.raw.headers);
|
||||
if (!authToken) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const [share] = await sql.unsafe(
|
||||
"UPDATE rfiles.public_shares SET is_active = FALSE WHERE id = $1 RETURNING *",
|
||||
"SELECT s.*, f.uploaded_by FROM rfiles.public_shares s JOIN rfiles.media_files f ON s.media_file_id = f.id WHERE s.id = $1",
|
||||
[c.req.param("shareId")]
|
||||
);
|
||||
if (!share) return c.json({ error: "Share not found" }, 404);
|
||||
return c.json({ message: "Revoked", share });
|
||||
if (share.uploaded_by && share.uploaded_by !== claims.sub) return c.json({ error: "Not authorized" }, 403);
|
||||
|
||||
const [revoked] = await sql.unsafe(
|
||||
"UPDATE rfiles.public_shares SET is_active = FALSE WHERE id = $1 RETURNING *",
|
||||
[c.req.param("shareId")]
|
||||
);
|
||||
return c.json({ message: "Revoked", share: revoked });
|
||||
});
|
||||
|
||||
// ── Public share download ──
|
||||
|
|
@ -278,9 +301,14 @@ routes.get("/s/:token/info", async (c) => {
|
|||
|
||||
// ── Memory Cards CRUD ──
|
||||
routes.post("/api/cards", async (c) => {
|
||||
const authToken = extractToken(c.req.raw.headers);
|
||||
if (!authToken) return c.json({ error: "Authentication required" }, 401);
|
||||
let claims;
|
||||
try { claims = await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const body = await c.req.json<{ title: string; body?: string; card_type?: string; tags?: string[]; shared_space?: string }>();
|
||||
const space = c.req.param("space") || body.shared_space || "default";
|
||||
const createdBy = c.req.header("X-User-DID") || "";
|
||||
const createdBy = claims.sub;
|
||||
|
||||
const [card] = await sql.unsafe(
|
||||
`INSERT INTO rfiles.memory_cards (shared_space, title, body, card_type, tags, created_by)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { renderShell } from "../../server/shell";
|
|||
import { getModuleInfoList } from "../../shared/module";
|
||||
import { provisionInstance, destroyInstance } from "./lib/provisioner";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
|
|
@ -40,10 +41,12 @@ async function getOrCreateUser(did: string): Promise<any> {
|
|||
|
||||
// ── API: List instances ──
|
||||
routes.get("/api/instances", async (c) => {
|
||||
const did = c.req.header("X-User-DID");
|
||||
if (!did) return c.json({ error: "Authentication required" }, 401);
|
||||
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 user = await getOrCreateUser(did);
|
||||
const user = await getOrCreateUser(claims.sub);
|
||||
const rows = await sql.unsafe(
|
||||
"SELECT * FROM rforum.instances WHERE user_id = $1 AND status != 'destroyed' ORDER BY created_at DESC",
|
||||
[user.id],
|
||||
|
|
@ -53,10 +56,12 @@ routes.get("/api/instances", async (c) => {
|
|||
|
||||
// ── API: Create instance ──
|
||||
routes.post("/api/instances", async (c) => {
|
||||
const did = c.req.header("X-User-DID");
|
||||
if (!did) return c.json({ error: "Authentication required" }, 401);
|
||||
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 user = await getOrCreateUser(did);
|
||||
const user = await getOrCreateUser(claims.sub);
|
||||
const body = await c.req.json<{
|
||||
name: string;
|
||||
subdomain: string;
|
||||
|
|
@ -92,10 +97,12 @@ routes.post("/api/instances", async (c) => {
|
|||
|
||||
// ── API: Get instance detail ──
|
||||
routes.get("/api/instances/:id", async (c) => {
|
||||
const did = c.req.header("X-User-DID");
|
||||
if (!did) return c.json({ error: "Authentication required" }, 401);
|
||||
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 user = await getOrCreateUser(did);
|
||||
const user = await getOrCreateUser(claims.sub);
|
||||
const [instance] = await sql.unsafe(
|
||||
"SELECT * FROM rforum.instances WHERE id = $1 AND user_id = $2",
|
||||
[c.req.param("id"), user.id],
|
||||
|
|
@ -112,10 +119,12 @@ routes.get("/api/instances/:id", async (c) => {
|
|||
|
||||
// ── API: Destroy instance ──
|
||||
routes.delete("/api/instances/:id", async (c) => {
|
||||
const did = c.req.header("X-User-DID");
|
||||
if (!did) return c.json({ error: "Authentication required" }, 401);
|
||||
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 user = await getOrCreateUser(did);
|
||||
const user = await getOrCreateUser(claims.sub);
|
||||
const [instance] = await sql.unsafe(
|
||||
"SELECT * FROM rforum.instances WHERE id = $1 AND user_id = $2",
|
||||
[c.req.param("id"), user.id],
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
|
|
@ -29,6 +30,17 @@ async function initDB() {
|
|||
|
||||
initDB();
|
||||
|
||||
// ── Helper: get or create user by DID ──
|
||||
async function getOrCreateUser(did: string, username?: string) {
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rinbox.users (did, username) VALUES ($1, $2)
|
||||
ON CONFLICT (did) DO UPDATE SET username = COALESCE($2, rinbox.users.username)
|
||||
RETURNING *`,
|
||||
[did, username || null]
|
||||
);
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
// ── Mailboxes API ──
|
||||
|
||||
// GET /api/mailboxes — list mailboxes
|
||||
|
|
@ -52,16 +64,21 @@ routes.get("/api/mailboxes", async (c) => {
|
|||
|
||||
// POST /api/mailboxes — create mailbox
|
||||
routes.post("/api/mailboxes", 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 body = await c.req.json();
|
||||
const { slug, name, email, description, visibility = "members_only" } = body;
|
||||
const { slug, name, email, description, visibility = "members_only", imap_user, imap_password } = body;
|
||||
if (!slug || !name || !email) return c.json({ error: "slug, name, email required" }, 400);
|
||||
if (!/^[a-z0-9-]+$/.test(slug)) return c.json({ error: "Invalid slug" }, 400);
|
||||
|
||||
try {
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rinbox.mailboxes (slug, name, email, description, visibility, owner_did)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||
[slug, name, email, description || null, visibility, "anonymous"]
|
||||
`INSERT INTO rinbox.mailboxes (slug, name, email, description, visibility, owner_did, imap_user)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[slug, name, email, description || null, visibility, claims.sub, imap_user || null]
|
||||
);
|
||||
return c.json(rows[0], 201);
|
||||
} catch (e: any) {
|
||||
|
|
@ -172,6 +189,11 @@ routes.patch("/api/threads/:id", async (c) => {
|
|||
|
||||
// POST /api/threads/:id/comments — add comment
|
||||
routes.post("/api/threads/:id/comments", 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 threadId = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
const { text, mentions } = body;
|
||||
|
|
@ -181,16 +203,12 @@ routes.post("/api/threads/:id/comments", async (c) => {
|
|||
const thread = await sql.unsafe("SELECT id FROM rinbox.threads WHERE id = $1", [threadId]);
|
||||
if (thread.length === 0) return c.json({ error: "Thread not found" }, 404);
|
||||
|
||||
// Get or create anonymous user
|
||||
const user = await sql.unsafe(
|
||||
`INSERT INTO rinbox.users (did, username) VALUES ('anonymous', 'Anonymous')
|
||||
ON CONFLICT (did) DO UPDATE SET username = rinbox.users.username RETURNING id`
|
||||
);
|
||||
const user = await getOrCreateUser(claims.sub, claims.username);
|
||||
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rinbox.comments (thread_id, author_id, body, mentions)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[threadId, user[0].id, text, mentions || []]
|
||||
[threadId, user.id, text, mentions || []]
|
||||
);
|
||||
return c.json(rows[0], 201);
|
||||
});
|
||||
|
|
@ -222,6 +240,11 @@ routes.get("/api/approvals", async (c) => {
|
|||
|
||||
// POST /api/approvals — create approval draft
|
||||
routes.post("/api/approvals", 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 body = await c.req.json();
|
||||
const { mailbox_slug, thread_id, subject, body_text, to_addresses } = body;
|
||||
if (!mailbox_slug || !subject) return c.json({ error: "mailbox_slug and subject required" }, 400);
|
||||
|
|
@ -229,21 +252,23 @@ routes.post("/api/approvals", async (c) => {
|
|||
const mb = await sql.unsafe("SELECT * FROM rinbox.mailboxes WHERE slug = $1", [mailbox_slug]);
|
||||
if (mb.length === 0) return c.json({ error: "Mailbox not found" }, 404);
|
||||
|
||||
const user = await sql.unsafe(
|
||||
`INSERT INTO rinbox.users (did, username) VALUES ('anonymous', 'Anonymous')
|
||||
ON CONFLICT (did) DO UPDATE SET username = rinbox.users.username RETURNING id`
|
||||
);
|
||||
const user = await getOrCreateUser(claims.sub, claims.username);
|
||||
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rinbox.approvals (mailbox_id, thread_id, author_id, subject, body_text, to_addresses, required_signatures)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[mb[0].id, thread_id || null, user[0].id, subject, body_text || null, JSON.stringify(to_addresses || []), mb[0].approval_threshold || 1]
|
||||
[mb[0].id, thread_id || null, user.id, subject, body_text || null, JSON.stringify(to_addresses || []), mb[0].approval_threshold || 1]
|
||||
);
|
||||
return c.json(rows[0], 201);
|
||||
});
|
||||
|
||||
// POST /api/approvals/:id/sign — sign an approval
|
||||
routes.post("/api/approvals/:id/sign", 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 body = await c.req.json();
|
||||
const { vote = "APPROVE" } = body;
|
||||
|
|
@ -253,16 +278,13 @@ routes.post("/api/approvals/:id/sign", async (c) => {
|
|||
if (approval.length === 0) return c.json({ error: "Approval not found" }, 404);
|
||||
if (approval[0].status !== "PENDING") return c.json({ error: "Approval not pending" }, 400);
|
||||
|
||||
const user = await sql.unsafe(
|
||||
`INSERT INTO rinbox.users (did, username) VALUES ('anonymous', 'Anonymous')
|
||||
ON CONFLICT (did) DO UPDATE SET username = rinbox.users.username RETURNING id`
|
||||
);
|
||||
const user = await getOrCreateUser(claims.sub, claims.username);
|
||||
|
||||
await sql.unsafe(
|
||||
`INSERT INTO rinbox.approval_signatures (approval_id, signer_id, vote)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (approval_id, signer_id) DO UPDATE SET vote = $3, signed_at = NOW()`,
|
||||
[id, user[0].id, vote]
|
||||
[id, user.id, vote]
|
||||
);
|
||||
|
||||
// Check if threshold reached
|
||||
|
|
@ -307,6 +329,11 @@ routes.get("/api/workspaces", async (c) => {
|
|||
|
||||
// POST /api/workspaces
|
||||
routes.post("/api/workspaces", 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 body = await c.req.json();
|
||||
const { slug, name, description } = body;
|
||||
if (!slug || !name) return c.json({ error: "slug and name required" }, 400);
|
||||
|
|
@ -315,7 +342,7 @@ routes.post("/api/workspaces", async (c) => {
|
|||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rinbox.workspaces (slug, name, description, owner_did)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[slug, name, description || null, "anonymous"]
|
||||
[slug, name, description || null, claims.sub]
|
||||
);
|
||||
return c.json(rows[0], 201);
|
||||
} catch (e: any) {
|
||||
|
|
@ -325,7 +352,180 @@ routes.post("/api/workspaces", async (c) => {
|
|||
});
|
||||
|
||||
// GET /api/health
|
||||
routes.get("/api/health", (c) => c.json({ ok: true }));
|
||||
routes.get("/api/health", (c) => c.json({ ok: true, imapSync: IMAP_HOST !== "" }));
|
||||
|
||||
// GET /api/sync-status — show IMAP sync state per mailbox
|
||||
routes.get("/api/sync-status", async (c) => {
|
||||
const rows = await sql.unsafe(
|
||||
`SELECT s.*, m.slug, m.name, m.email
|
||||
FROM rinbox.sync_state s
|
||||
JOIN rinbox.mailboxes m ON m.id = s.mailbox_id
|
||||
ORDER BY s.last_sync_at DESC NULLS LAST`
|
||||
);
|
||||
return c.json({ syncStates: rows });
|
||||
});
|
||||
|
||||
// ── IMAP Sync Worker ──
|
||||
const IMAP_HOST = process.env.IMAP_HOST || "";
|
||||
const IMAP_PORT = parseInt(process.env.IMAP_PORT || "993");
|
||||
const IMAP_TLS_REJECT = process.env.IMAP_TLS_REJECT_UNAUTHORIZED !== "false";
|
||||
const SYNC_INTERVAL = 30_000; // 30 seconds
|
||||
|
||||
async function syncMailbox(mailbox: any) {
|
||||
let ImapFlow: any;
|
||||
let simpleParser: any;
|
||||
try {
|
||||
ImapFlow = (await import("imapflow")).ImapFlow;
|
||||
simpleParser = (await import("mailparser")).simpleParser;
|
||||
} catch {
|
||||
console.error("[Inbox] imapflow/mailparser not installed — skipping IMAP sync");
|
||||
return;
|
||||
}
|
||||
|
||||
const host = mailbox.imap_host || IMAP_HOST;
|
||||
const port = mailbox.imap_port || IMAP_PORT;
|
||||
const user = mailbox.imap_user;
|
||||
if (!host || !user) return;
|
||||
|
||||
// Get or create sync state
|
||||
let syncState = (await sql.unsafe(
|
||||
"SELECT * FROM rinbox.sync_state WHERE mailbox_id = $1",
|
||||
[mailbox.id]
|
||||
))[0];
|
||||
|
||||
if (!syncState) {
|
||||
const rows = await sql.unsafe(
|
||||
"INSERT INTO rinbox.sync_state (mailbox_id) VALUES ($1) RETURNING *",
|
||||
[mailbox.id]
|
||||
);
|
||||
syncState = rows[0];
|
||||
}
|
||||
|
||||
const client = new ImapFlow({
|
||||
host,
|
||||
port,
|
||||
secure: port === 993,
|
||||
auth: { user, pass: process.env[`IMAP_PASS_${mailbox.slug.toUpperCase().replace(/-/g, "_")}`] || "" },
|
||||
tls: { rejectUnauthorized: IMAP_TLS_REJECT },
|
||||
logger: false,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
const lock = await client.getMailboxLock("INBOX");
|
||||
|
||||
try {
|
||||
const status = client.mailbox;
|
||||
const uidValidity = status?.uidValidity;
|
||||
|
||||
// UID validity changed — need full resync
|
||||
if (syncState.uid_validity && uidValidity && syncState.uid_validity !== uidValidity) {
|
||||
await sql.unsafe(
|
||||
"UPDATE rinbox.sync_state SET last_uid = 0, uid_validity = $1 WHERE mailbox_id = $2",
|
||||
[uidValidity, mailbox.id]
|
||||
);
|
||||
syncState.last_uid = 0;
|
||||
}
|
||||
|
||||
// Fetch messages newer than last synced UID
|
||||
const lastUid = syncState.last_uid || 0;
|
||||
const range = lastUid > 0 ? `${lastUid + 1}:*` : "1:*";
|
||||
let maxUid = lastUid;
|
||||
let count = 0;
|
||||
|
||||
for await (const msg of client.fetch(range, {
|
||||
envelope: true,
|
||||
source: true,
|
||||
uid: true,
|
||||
})) {
|
||||
if (msg.uid <= lastUid) continue;
|
||||
if (count >= 100) break; // Batch limit
|
||||
|
||||
try {
|
||||
const parsed = await simpleParser(msg.source);
|
||||
const fromAddr = parsed.from?.value?.[0]?.address || msg.envelope?.from?.[0]?.address || "";
|
||||
const fromName = parsed.from?.value?.[0]?.name || msg.envelope?.from?.[0]?.name || "";
|
||||
const subject = parsed.subject || msg.envelope?.subject || "(no subject)";
|
||||
const toAddrs = (parsed.to?.value || []).map((a: any) => ({ address: a.address, name: a.name }));
|
||||
const ccAddrs = (parsed.cc?.value || []).map((a: any) => ({ address: a.address, name: a.name }));
|
||||
const messageId = parsed.messageId || msg.envelope?.messageId || null;
|
||||
const hasAttachments = (parsed.attachments?.length || 0) > 0;
|
||||
|
||||
await sql.unsafe(
|
||||
`INSERT INTO rinbox.threads (mailbox_id, message_id, subject, from_address, from_name, to_addresses, cc_addresses, body_text, body_html, has_attachments, received_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, $10, $11)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[
|
||||
mailbox.id,
|
||||
messageId,
|
||||
subject,
|
||||
fromAddr,
|
||||
fromName,
|
||||
JSON.stringify(toAddrs),
|
||||
JSON.stringify(ccAddrs),
|
||||
parsed.text || null,
|
||||
parsed.html || null,
|
||||
hasAttachments,
|
||||
parsed.date || new Date(),
|
||||
]
|
||||
);
|
||||
count++;
|
||||
} catch (parseErr) {
|
||||
console.error(`[Inbox] Parse error UID ${msg.uid}:`, parseErr);
|
||||
}
|
||||
|
||||
if (msg.uid > maxUid) maxUid = msg.uid;
|
||||
}
|
||||
|
||||
// Update sync state
|
||||
await sql.unsafe(
|
||||
"UPDATE rinbox.sync_state SET last_uid = $1, uid_validity = $2, last_sync_at = NOW(), error = NULL WHERE mailbox_id = $3",
|
||||
[maxUid, uidValidity || null, mailbox.id]
|
||||
);
|
||||
|
||||
if (count > 0) console.log(`[Inbox] Synced ${count} messages for ${mailbox.email}`);
|
||||
} finally {
|
||||
lock.release();
|
||||
}
|
||||
|
||||
await client.logout();
|
||||
} catch (e: any) {
|
||||
console.error(`[Inbox] IMAP sync error for ${mailbox.email}:`, e.message);
|
||||
await sql.unsafe(
|
||||
"UPDATE rinbox.sync_state SET error = $1, last_sync_at = NOW() WHERE mailbox_id = $2",
|
||||
[e.message, mailbox.id]
|
||||
).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function runSyncLoop() {
|
||||
if (!IMAP_HOST) {
|
||||
console.log("[Inbox] IMAP_HOST not set — IMAP sync disabled");
|
||||
return;
|
||||
}
|
||||
console.log(`[Inbox] IMAP sync enabled — polling every ${SYNC_INTERVAL / 1000}s`);
|
||||
|
||||
const doSync = async () => {
|
||||
try {
|
||||
const mailboxes = await sql.unsafe(
|
||||
"SELECT * FROM rinbox.mailboxes WHERE imap_user IS NOT NULL"
|
||||
);
|
||||
for (const mb of mailboxes) {
|
||||
await syncMailbox(mb);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[Inbox] Sync loop error:", e);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial sync after 5s delay
|
||||
setTimeout(doSync, 5000);
|
||||
// Recurring sync
|
||||
setInterval(doSync, SYNC_INTERVAL);
|
||||
}
|
||||
|
||||
// Start IMAP sync in background
|
||||
runSyncLoop();
|
||||
|
||||
// ── Page route ──
|
||||
routes.get("/", (c) => {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ const routes = new Hono();
|
|||
|
||||
const SYNC_SERVER = process.env.MAPS_SYNC_URL || "http://localhost:3001";
|
||||
|
||||
// ── Sync URL for client-side WebSocket connection ──
|
||||
routes.get("/api/sync-url", (c) => {
|
||||
const wsUrl = process.env.MAPS_SYNC_URL || "wss://sync.rmaps.online";
|
||||
return c.json({ syncUrl: wsUrl });
|
||||
});
|
||||
|
||||
// ── Proxy: sync server health ──
|
||||
routes.get("/api/health", async (c) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* Visualizes CRM data as interactive force-directed graphs.
|
||||
* Nodes: people, companies, opportunities. Edges: relationships.
|
||||
* Syncs from Twenty CRM via API proxy.
|
||||
* Syncs from Twenty CRM via GraphQL API proxy.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
|
|
@ -13,27 +13,201 @@ import type { RSpaceModule } from "../../shared/module";
|
|||
|
||||
const routes = new Hono();
|
||||
|
||||
// No database — network data lives in Automerge CRDT docs or is proxied from Twenty CRM.
|
||||
// The existing rNetwork-online at /opt/apps/rNetwork-online/ already uses Bun+Hono+Automerge.
|
||||
const TWENTY_API_URL = process.env.TWENTY_API_URL || "https://rnetwork.online";
|
||||
const TWENTY_API_TOKEN = process.env.TWENTY_API_TOKEN || "";
|
||||
|
||||
// ── GraphQL helper ──
|
||||
async function twentyQuery(query: string, variables?: Record<string, unknown>) {
|
||||
if (!TWENTY_API_TOKEN) return null;
|
||||
const res = await fetch(`${TWENTY_API_URL}/api`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${TWENTY_API_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const json = await res.json() as { data?: unknown };
|
||||
return json.data ?? null;
|
||||
}
|
||||
|
||||
// ── Cache layer (60s TTL) ──
|
||||
let graphCache: { data: unknown; ts: number } | null = null;
|
||||
const CACHE_TTL = 60_000;
|
||||
|
||||
// ── API: Health ──
|
||||
routes.get("/api/health", (c) => {
|
||||
return c.json({ ok: true, module: "network", sync: true });
|
||||
return c.json({ ok: true, module: "network", twentyConfigured: !!TWENTY_API_TOKEN });
|
||||
});
|
||||
|
||||
// ── API: Graph info (placeholder — real data from Automerge) ──
|
||||
// ── API: Info ──
|
||||
routes.get("/api/info", (c) => {
|
||||
return c.json({
|
||||
module: "network",
|
||||
description: "Community relationship graph visualization",
|
||||
entityTypes: ["person", "company", "opportunity"],
|
||||
features: ["force-directed layout", "CRM sync", "real-time collaboration"],
|
||||
twentyConfigured: !!TWENTY_API_TOKEN,
|
||||
});
|
||||
});
|
||||
|
||||
// ── API: People ──
|
||||
routes.get("/api/people", async (c) => {
|
||||
const data = await twentyQuery(`{
|
||||
people(first: 200) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name { firstName lastName }
|
||||
email { primaryEmail }
|
||||
phone { primaryPhoneNumber }
|
||||
city
|
||||
company { id name { firstName lastName } }
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}`);
|
||||
if (!data) return c.json({ people: [], error: TWENTY_API_TOKEN ? "Twenty API error" : "Twenty not configured" });
|
||||
const people = ((data as any).people?.edges || []).map((e: any) => e.node);
|
||||
c.header("Cache-Control", "public, max-age=60");
|
||||
return c.json({ people });
|
||||
});
|
||||
|
||||
// ── API: Companies ──
|
||||
routes.get("/api/companies", async (c) => {
|
||||
const data = await twentyQuery(`{
|
||||
companies(first: 200) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
domainName { primaryLinkUrl }
|
||||
employees
|
||||
address { addressCity addressCountry }
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}`);
|
||||
if (!data) return c.json({ companies: [], error: TWENTY_API_TOKEN ? "Twenty API error" : "Twenty not configured" });
|
||||
const companies = ((data as any).companies?.edges || []).map((e: any) => e.node);
|
||||
c.header("Cache-Control", "public, max-age=60");
|
||||
return c.json({ companies });
|
||||
});
|
||||
|
||||
// ── API: Graph — transform entities to node/edge format ──
|
||||
routes.get("/api/graph", async (c) => {
|
||||
// Check cache
|
||||
if (graphCache && Date.now() - graphCache.ts < CACHE_TTL) {
|
||||
c.header("Cache-Control", "public, max-age=60");
|
||||
return c.json(graphCache.data);
|
||||
}
|
||||
|
||||
if (!TWENTY_API_TOKEN) {
|
||||
return c.json({
|
||||
nodes: [
|
||||
{ id: "demo-1", label: "Alice", type: "person", data: {} },
|
||||
{ id: "demo-2", label: "Bob", type: "person", data: {} },
|
||||
{ id: "demo-3", label: "Acme Corp", type: "company", data: {} },
|
||||
],
|
||||
edges: [
|
||||
{ source: "demo-1", target: "demo-3", type: "works_at" },
|
||||
{ source: "demo-2", target: "demo-3", type: "works_at" },
|
||||
{ source: "demo-1", target: "demo-2", type: "contact_of" },
|
||||
],
|
||||
demo: true,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await twentyQuery(`{
|
||||
people(first: 200) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name { firstName lastName }
|
||||
email { primaryEmail }
|
||||
company { id name { firstName lastName } }
|
||||
}
|
||||
}
|
||||
}
|
||||
companies(first: 200) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
domainName { primaryLinkUrl }
|
||||
employees
|
||||
}
|
||||
}
|
||||
}
|
||||
opportunities(first: 200) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
stage
|
||||
amount { amountMicros currencyCode }
|
||||
company { id name }
|
||||
pointOfContact { id name { firstName lastName } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}`);
|
||||
|
||||
if (!data) return c.json({ nodes: [], edges: [], error: "Twenty API error" });
|
||||
|
||||
const d = data as any;
|
||||
const nodes: Array<{ id: string; label: string; type: string; data: unknown }> = [];
|
||||
const edges: Array<{ source: string; target: string; type: string }> = [];
|
||||
const nodeIds = new Set<string>();
|
||||
|
||||
// People → nodes
|
||||
for (const { node: p } of d.people?.edges || []) {
|
||||
const label = [p.name?.firstName, p.name?.lastName].filter(Boolean).join(" ") || "Unknown";
|
||||
nodes.push({ id: p.id, label, type: "person", data: { email: p.email?.primaryEmail } });
|
||||
nodeIds.add(p.id);
|
||||
|
||||
// Person → Company edge
|
||||
if (p.company?.id) {
|
||||
edges.push({ source: p.id, target: p.company.id, type: "works_at" });
|
||||
}
|
||||
}
|
||||
|
||||
// Companies → nodes
|
||||
for (const { node: co } of d.companies?.edges || []) {
|
||||
nodes.push({ id: co.id, label: co.name || "Unknown", type: "company", data: { domain: co.domainName?.primaryLinkUrl, employees: co.employees } });
|
||||
nodeIds.add(co.id);
|
||||
}
|
||||
|
||||
// Opportunities → nodes + edges
|
||||
for (const { node: opp } of d.opportunities?.edges || []) {
|
||||
nodes.push({ id: opp.id, label: opp.name || "Opportunity", type: "opportunity", data: { stage: opp.stage, amount: opp.amount } });
|
||||
nodeIds.add(opp.id);
|
||||
|
||||
if (opp.company?.id && nodeIds.has(opp.company.id)) {
|
||||
edges.push({ source: opp.id, target: opp.company.id, type: "involves" });
|
||||
}
|
||||
if (opp.pointOfContact?.id && nodeIds.has(opp.pointOfContact.id)) {
|
||||
edges.push({ source: opp.pointOfContact.id, target: opp.id, type: "involved_in" });
|
||||
}
|
||||
}
|
||||
|
||||
const result = { nodes, edges, demo: false };
|
||||
graphCache = { data: result, ts: Date.now() };
|
||||
c.header("Cache-Control", "public, max-age=60");
|
||||
return c.json(result);
|
||||
} catch (e) {
|
||||
console.error("[Network] Graph fetch error:", e);
|
||||
return c.json({ nodes: [], edges: [], error: "Graph fetch failed" });
|
||||
}
|
||||
});
|
||||
|
||||
// ── API: Workspaces ──
|
||||
routes.get("/api/workspaces", (c) => {
|
||||
// In production, this would scan Automerge graph docs
|
||||
return c.json([
|
||||
{ slug: "demo", name: "Demo Network", nodeCount: 0, edgeCount: 0 },
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
|
|
@ -56,13 +57,19 @@ routes.get("/api/notebooks", async (c) => {
|
|||
|
||||
// POST /api/notebooks — create notebook
|
||||
routes.post("/api/notebooks", 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 body = await c.req.json();
|
||||
const { title, description, cover_color } = body;
|
||||
|
||||
const user = await getOrCreateUser(claims.sub, claims.username);
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rnotes.notebooks (title, description, cover_color)
|
||||
VALUES ($1, $2, $3) RETURNING *`,
|
||||
[title || "Untitled Notebook", description || null, cover_color || "#3b82f6"]
|
||||
`INSERT INTO rnotes.notebooks (title, description, cover_color, owner_id)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[title || "Untitled Notebook", description || null, cover_color || "#3b82f6", user.id]
|
||||
);
|
||||
return c.json(rows[0], 201);
|
||||
});
|
||||
|
|
@ -88,6 +95,11 @@ routes.get("/api/notebooks/:id", async (c) => {
|
|||
|
||||
// PUT /api/notebooks/:id — update notebook
|
||||
routes.put("/api/notebooks/: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 body = await c.req.json();
|
||||
const { title, description, cover_color, is_public } = body;
|
||||
|
|
@ -158,6 +170,11 @@ routes.get("/api/notes", async (c) => {
|
|||
|
||||
// POST /api/notes — create note
|
||||
routes.post("/api/notes", 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 body = await c.req.json();
|
||||
const { notebook_id, title, content, type, url, language, file_url, mime_type, file_size, duration, tags } = body;
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
|
|
@ -226,6 +227,10 @@ routes.get("/api/providers/:id", async (c) => {
|
|||
|
||||
// ── POST /api/providers — Register a new provider ──
|
||||
routes.post("/api/providers", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const body = await c.req.json();
|
||||
const { name, description, location, capabilities, substrates, turnaround, pricing, communities, contact, wallet } = body;
|
||||
|
||||
|
|
@ -259,6 +264,10 @@ routes.post("/api/providers", async (c) => {
|
|||
|
||||
// ── PUT /api/providers/:id — Update provider ──
|
||||
routes.put("/api/providers/:id", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const id = c.req.param("id");
|
||||
const existing = await sql.unsafe("SELECT id FROM providers.providers WHERE id = $1", [id]);
|
||||
if (existing.length === 0) return c.json({ error: "Provider not found" }, 404);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
|
|
@ -48,16 +49,21 @@ routes.get("/api/trips", async (c) => {
|
|||
|
||||
// POST /api/trips — create trip
|
||||
routes.post("/api/trips", 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 body = await c.req.json();
|
||||
const { title, description, start_date, end_date, budget_total, budget_currency } = body;
|
||||
if (!title?.trim()) return c.json({ error: "Title required" }, 400);
|
||||
|
||||
const slug = title.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rtrips.trips (title, slug, description, start_date, end_date, budget_total, budget_currency)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
`INSERT INTO rtrips.trips (title, slug, description, start_date, end_date, budget_total, budget_currency, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[title.trim(), slug, description || null, start_date || null, end_date || null,
|
||||
budget_total || null, budget_currency || "USD"]
|
||||
budget_total || null, budget_currency || "USD", claims.sub]
|
||||
);
|
||||
return c.json(rows[0], 201);
|
||||
});
|
||||
|
|
@ -112,6 +118,10 @@ routes.put("/api/trips/:id", async (c) => {
|
|||
// ── API: Destinations ──
|
||||
|
||||
routes.post("/api/trips/:id/destinations", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const body = await c.req.json();
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rtrips.destinations (trip_id, name, country, lat, lng, arrival_date, departure_date, notes, sort_order)
|
||||
|
|
@ -125,6 +135,10 @@ routes.post("/api/trips/:id/destinations", async (c) => {
|
|||
// ── API: Itinerary ──
|
||||
|
||||
routes.post("/api/trips/:id/itinerary", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const body = await c.req.json();
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rtrips.itinerary_items (trip_id, destination_id, title, category, date, start_time, end_time, notes, sort_order)
|
||||
|
|
@ -138,6 +152,10 @@ routes.post("/api/trips/:id/itinerary", async (c) => {
|
|||
// ── API: Bookings ──
|
||||
|
||||
routes.post("/api/trips/:id/bookings", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const body = await c.req.json();
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rtrips.bookings (trip_id, type, provider, confirmation_number, cost, currency, start_date, end_date, notes)
|
||||
|
|
@ -151,6 +169,10 @@ routes.post("/api/trips/:id/bookings", async (c) => {
|
|||
// ── API: Expenses ──
|
||||
|
||||
routes.post("/api/trips/:id/expenses", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const body = await c.req.json();
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rtrips.expenses (trip_id, description, amount, currency, category, date, split_type)
|
||||
|
|
@ -172,6 +194,10 @@ routes.get("/api/trips/:id/packing", async (c) => {
|
|||
});
|
||||
|
||||
routes.post("/api/trips/:id/packing", async (c) => {
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
if (!token) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const body = await c.req.json();
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rtrips.packing_items (trip_id, name, category, quantity, sort_order)
|
||||
|
|
|
|||
|
|
@ -9,23 +9,43 @@ import { Hono } from "hono";
|
|||
import { renderShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server";
|
||||
import { S3Client, ListObjectsV2Command, GetObjectCommand, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
// ── R2 / S3 config ──
|
||||
const R2_ENDPOINT = process.env.R2_ENDPOINT || "";
|
||||
const R2_BUCKET = process.env.R2_BUCKET || "rtube-videos";
|
||||
const R2_ACCESS_KEY = process.env.R2_ACCESS_KEY || "";
|
||||
const R2_SECRET_KEY = process.env.R2_SECRET_KEY || "";
|
||||
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID || "";
|
||||
const R2_SECRET_ACCESS_KEY = process.env.R2_SECRET_ACCESS_KEY || "";
|
||||
const R2_PUBLIC_URL = process.env.R2_PUBLIC_URL || "";
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set([
|
||||
".mp4", ".mkv", ".webm", ".mov", ".avi", ".wmv", ".flv", ".m4v",
|
||||
]);
|
||||
|
||||
// ── S3-compatible listing (lightweight, no AWS SDK needed) ──
|
||||
async function listVideos(): Promise<Array<{ name: string; size: number }>> {
|
||||
if (!R2_ENDPOINT) {
|
||||
// ── S3 Client (lazy init) ──
|
||||
let s3: S3Client | null = null;
|
||||
function getS3(): S3Client | null {
|
||||
if (!R2_ENDPOINT) return null;
|
||||
if (!s3) {
|
||||
s3 = new S3Client({
|
||||
region: "auto",
|
||||
endpoint: R2_ENDPOINT,
|
||||
credentials: {
|
||||
accessKeyId: R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: R2_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
}
|
||||
return s3;
|
||||
}
|
||||
|
||||
// ── S3-compatible listing ──
|
||||
async function listVideos(): Promise<Array<{ name: string; size: number; lastModified?: string }>> {
|
||||
const client = getS3();
|
||||
if (!client) {
|
||||
// Return demo data when R2 not configured
|
||||
return [
|
||||
{ name: "welcome-to-rtube.mp4", size: 12_500_000 },
|
||||
|
|
@ -35,31 +55,24 @@ async function listVideos(): Promise<Array<{ name: string; size: number }>> {
|
|||
}
|
||||
|
||||
try {
|
||||
// Use S3 ListObjectsV2 via signed request
|
||||
const url = `${R2_ENDPOINT}/${R2_BUCKET}?list-type=2&max-keys=200`;
|
||||
const resp = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `AWS4-HMAC-SHA256 ...`, // Simplified — real impl uses aws4 signing
|
||||
},
|
||||
});
|
||||
if (!resp.ok) return [];
|
||||
const command = new ListObjectsV2Command({ Bucket: R2_BUCKET, MaxKeys: 200 });
|
||||
const response = await client.send(command);
|
||||
const items: Array<{ name: string; size: number; lastModified?: string }> = [];
|
||||
|
||||
const text = await resp.text();
|
||||
// Parse simple XML response
|
||||
const items: Array<{ name: string; size: number }> = [];
|
||||
const keyMatches = text.matchAll(/<Key>([^<]+)<\/Key>/g);
|
||||
const sizeMatches = text.matchAll(/<Size>(\d+)<\/Size>/g);
|
||||
const keys = [...keyMatches].map((m) => m[1]);
|
||||
const sizes = [...sizeMatches].map((m) => parseInt(m[1]));
|
||||
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const ext = keys[i].substring(keys[i].lastIndexOf(".")).toLowerCase();
|
||||
for (const obj of response.Contents || []) {
|
||||
if (!obj.Key) continue;
|
||||
const ext = obj.Key.substring(obj.Key.lastIndexOf(".")).toLowerCase();
|
||||
if (VIDEO_EXTENSIONS.has(ext)) {
|
||||
items.push({ name: keys[i], size: sizes[i] || 0 });
|
||||
items.push({
|
||||
name: obj.Key,
|
||||
size: obj.Size || 0,
|
||||
lastModified: obj.LastModified?.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
return items.sort((a, b) => a.name.localeCompare(b.name));
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error("[Tube] S3 list error:", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -69,7 +82,96 @@ async function listVideos(): Promise<Array<{ name: string; size: number }>> {
|
|||
// GET /api/videos — list videos from bucket
|
||||
routes.get("/api/videos", async (c) => {
|
||||
const videos = await listVideos();
|
||||
return c.json({ videos });
|
||||
return c.json({ videos, r2Configured: !!R2_ENDPOINT });
|
||||
});
|
||||
|
||||
// GET /api/v/:path — video streaming with HTTP range request support
|
||||
routes.get("/api/v/*", async (c) => {
|
||||
const client = getS3();
|
||||
if (!client) return c.json({ error: "R2 not configured" }, 503);
|
||||
|
||||
const path = c.req.path.replace(/^.*\/api\/v\//, "");
|
||||
if (!path) return c.json({ error: "Path required" }, 400);
|
||||
|
||||
try {
|
||||
// Get object metadata for Content-Length
|
||||
const head = await client.send(new HeadObjectCommand({ Bucket: R2_BUCKET, Key: path }));
|
||||
const totalSize = head.ContentLength || 0;
|
||||
const contentType = head.ContentType || "video/mp4";
|
||||
const rangeHeader = c.req.header("range");
|
||||
|
||||
if (rangeHeader) {
|
||||
// Parse range header
|
||||
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
|
||||
if (!match) return c.json({ error: "Invalid range" }, 400);
|
||||
|
||||
const start = parseInt(match[1]);
|
||||
const end = match[2] ? parseInt(match[2]) : Math.min(start + 5 * 1024 * 1024 - 1, totalSize - 1);
|
||||
const chunkSize = end - start + 1;
|
||||
|
||||
const obj = await client.send(new GetObjectCommand({
|
||||
Bucket: R2_BUCKET,
|
||||
Key: path,
|
||||
Range: `bytes=${start}-${end}`,
|
||||
}));
|
||||
|
||||
return new Response(obj.Body as ReadableStream, {
|
||||
status: 206,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(chunkSize),
|
||||
"Content-Range": `bytes ${start}-${end}/${totalSize}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// No range — stream full file
|
||||
const obj = await client.send(new GetObjectCommand({ Bucket: R2_BUCKET, Key: path }));
|
||||
return new Response(obj.Body as ReadableStream, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(totalSize),
|
||||
"Accept-Ranges": "bytes",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.name === "NoSuchKey") return c.json({ error: "Video not found" }, 404);
|
||||
console.error("[Tube] Stream error:", e);
|
||||
return c.json({ error: "Stream failed" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/videos — upload video (auth required)
|
||||
routes.post("/api/videos", async (c) => {
|
||||
const authToken = extractToken(c.req.raw.headers);
|
||||
if (!authToken) return c.json({ error: "Authentication required" }, 401);
|
||||
try { await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
|
||||
|
||||
const client = getS3();
|
||||
if (!client) return c.json({ error: "R2 not configured" }, 503);
|
||||
|
||||
const formData = await c.req.formData();
|
||||
const file = formData.get("video") as File | null;
|
||||
if (!file) return c.json({ error: "video file required" }, 400);
|
||||
|
||||
const ext = file.name.substring(file.name.lastIndexOf(".")).toLowerCase();
|
||||
if (!VIDEO_EXTENSIONS.has(ext)) return c.json({ error: `Unsupported format. Allowed: ${[...VIDEO_EXTENSIONS].join(", ")}` }, 400);
|
||||
|
||||
const key = formData.get("path")?.toString() || file.name;
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
await client.send(new PutObjectCommand({
|
||||
Bucket: R2_BUCKET,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: file.type || "video/mp4",
|
||||
ContentLength: buffer.length,
|
||||
}));
|
||||
|
||||
return c.json({ ok: true, key, size: buffer.length, publicUrl: R2_PUBLIC_URL ? `${R2_PUBLIC_URL}/${key}` : undefined }, 201);
|
||||
});
|
||||
|
||||
// GET /api/info — module info
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
|
|
@ -79,19 +80,21 @@ routes.get("/api/spaces", async (c) => {
|
|||
|
||||
// POST /api/spaces — create space
|
||||
routes.post("/api/spaces", 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 body = await c.req.json();
|
||||
const { name, slug, description, visibility = "public_read" } = body;
|
||||
if (!name || !slug) return c.json({ error: "name and slug required" }, 400);
|
||||
if (!/^[a-z0-9-]+$/.test(slug)) return c.json({ error: "Invalid slug" }, 400);
|
||||
|
||||
// TODO: extract DID from auth header
|
||||
const ownerDid = "anonymous";
|
||||
|
||||
try {
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rvote.spaces (slug, name, description, owner_did, visibility)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[slug, name, description || null, ownerDid, visibility]
|
||||
[slug, name, description || null, claims.sub, visibility]
|
||||
);
|
||||
return c.json(rows[0], 201);
|
||||
} catch (e: any) {
|
||||
|
|
@ -138,6 +141,11 @@ routes.get("/api/proposals", async (c) => {
|
|||
|
||||
// POST /api/proposals — create proposal
|
||||
routes.post("/api/proposals", 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 body = await c.req.json();
|
||||
const { space_slug, title, description } = body;
|
||||
if (!space_slug || !title) return c.json({ error: "space_slug and title required" }, 400);
|
||||
|
|
@ -146,10 +154,11 @@ routes.post("/api/proposals", async (c) => {
|
|||
const space = await sql.unsafe("SELECT slug FROM rvote.spaces WHERE slug = $1", [space_slug]);
|
||||
if (space.length === 0) return c.json({ error: "Space not found" }, 404);
|
||||
|
||||
const user = await getOrCreateUser(claims.sub, claims.username);
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rvote.proposals (space_slug, author_id, title, description)
|
||||
VALUES ($1, (SELECT id FROM rvote.users LIMIT 1), $2, $3) RETURNING *`,
|
||||
[space_slug, title, description || null]
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[space_slug, user.id, title, description || null]
|
||||
);
|
||||
return c.json(rows[0], 201);
|
||||
});
|
||||
|
|
@ -169,6 +178,11 @@ routes.get("/api/proposals/:id", async (c) => {
|
|||
|
||||
// POST /api/proposals/:id/vote — cast conviction vote
|
||||
routes.post("/api/proposals/:id/vote", 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 body = await c.req.json();
|
||||
const { weight = 1 } = body;
|
||||
|
|
@ -181,15 +195,16 @@ routes.post("/api/proposals/:id/vote", async (c) => {
|
|||
if (proposal.length === 0) return c.json({ error: "Proposal not found" }, 404);
|
||||
if (proposal[0].status !== "RANKING") return c.json({ error: "Proposal not in ranking phase" }, 400);
|
||||
|
||||
const user = await getOrCreateUser(claims.sub, claims.username);
|
||||
const creditCost = weight * weight; // quadratic cost
|
||||
|
||||
// Upsert vote
|
||||
await sql.unsafe(
|
||||
`INSERT INTO rvote.votes (user_id, proposal_id, weight, credit_cost, decays_at)
|
||||
VALUES ((SELECT id FROM rvote.users LIMIT 1), $1, $2, $3, NOW() + INTERVAL '30 days')
|
||||
VALUES ($1, $2, $3, $4, NOW() + INTERVAL '30 days')
|
||||
ON CONFLICT (user_id, proposal_id)
|
||||
DO UPDATE SET weight = $2, credit_cost = $3, created_at = NOW(), decays_at = NOW() + INTERVAL '30 days'`,
|
||||
[id, weight, creditCost]
|
||||
DO UPDATE SET weight = $3, credit_cost = $4, created_at = NOW(), decays_at = NOW() + INTERVAL '30 days'`,
|
||||
[user.id, id, weight, creditCost]
|
||||
);
|
||||
|
||||
// Recalculate score and check for promotion
|
||||
|
|
@ -213,6 +228,11 @@ routes.post("/api/proposals/:id/vote", async (c) => {
|
|||
|
||||
// POST /api/proposals/:id/final-vote — cast binary vote
|
||||
routes.post("/api/proposals/:id/final-vote", 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 body = await c.req.json();
|
||||
const { vote } = body;
|
||||
|
|
@ -222,11 +242,12 @@ routes.post("/api/proposals/:id/final-vote", async (c) => {
|
|||
if (proposal.length === 0) return c.json({ error: "Proposal not found" }, 404);
|
||||
if (proposal[0].status !== "VOTING") return c.json({ error: "Proposal not in voting phase" }, 400);
|
||||
|
||||
const user = await getOrCreateUser(claims.sub, claims.username);
|
||||
await sql.unsafe(
|
||||
`INSERT INTO rvote.final_votes (user_id, proposal_id, vote)
|
||||
VALUES ((SELECT id FROM rvote.users LIMIT 1), $1, $2)
|
||||
ON CONFLICT (user_id, proposal_id) DO UPDATE SET vote = $2`,
|
||||
[id, vote]
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, proposal_id) DO UPDATE SET vote = $3`,
|
||||
[user.id, id, vote]
|
||||
);
|
||||
|
||||
// Update counts
|
||||
|
|
|
|||
|
|
@ -21,7 +21,9 @@ routes.get("/api/safe/:chainId/:address/balances", async (c) => {
|
|||
|
||||
const res = await fetch(`https://safe-transaction-${chainPrefix}.safe.global/api/v1/safes/${address}/balances/usd/?trusted=true&exclude_spam=true`);
|
||||
if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any);
|
||||
return c.json(await res.json());
|
||||
const data = await res.json();
|
||||
c.header("Cache-Control", "public, max-age=30");
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
routes.get("/api/safe/:chainId/:address/transfers", async (c) => {
|
||||
|
|
@ -44,7 +46,9 @@ routes.get("/api/safe/:chainId/:address/info", async (c) => {
|
|||
|
||||
const res = await fetch(`https://safe-transaction-${chainPrefix}.safe.global/api/v1/safes/${address}/`);
|
||||
if (!res.ok) return c.json({ error: "Safe API error" }, res.status as any);
|
||||
return c.json(await res.json());
|
||||
const data = await res.json();
|
||||
c.header("Cache-Control", "public, max-age=300");
|
||||
return c.json(data);
|
||||
});
|
||||
|
||||
// Detect which chains have a Safe for this address
|
||||
|
|
@ -79,6 +83,8 @@ const CHAIN_MAP: Record<string, { name: string; prefix: string }> = {
|
|||
"43114": { name: "Avalanche", prefix: "avalanche" },
|
||||
"56": { name: "BSC", prefix: "bsc" },
|
||||
"324": { name: "zkSync", prefix: "zksync" },
|
||||
"11155111": { name: "Sepolia", prefix: "sepolia" },
|
||||
"84532": { name: "Base Sepolia", prefix: "base-sepolia" },
|
||||
};
|
||||
|
||||
function getSafePrefix(chainId: string): string | null {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ 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";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
|
|
@ -45,15 +46,20 @@ routes.get("/api/spaces", async (c) => {
|
|||
|
||||
// POST /api/spaces — create workspace
|
||||
routes.post("/api/spaces", 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 body = await c.req.json();
|
||||
const { name, description, icon } = body;
|
||||
if (!name?.trim()) return c.json({ error: "Name required" }, 400);
|
||||
|
||||
const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rwork.spaces (name, slug, description, icon)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[name.trim(), slug, description || null, icon || null]
|
||||
`INSERT INTO rwork.spaces (name, slug, description, icon, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[name.trim(), slug, description || null, icon || null, claims.sub]
|
||||
);
|
||||
return c.json(rows[0], 201);
|
||||
});
|
||||
|
|
@ -84,6 +90,11 @@ routes.get("/api/spaces/:slug/tasks", async (c) => {
|
|||
|
||||
// POST /api/spaces/:slug/tasks — create task
|
||||
routes.post("/api/spaces/:slug/tasks", 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 slug = c.req.param("slug");
|
||||
const body = await c.req.json();
|
||||
const { title, description, status, priority, labels } = body;
|
||||
|
|
@ -94,15 +105,22 @@ routes.post("/api/spaces/:slug/tasks", async (c) => {
|
|||
|
||||
const taskStatus = status || space[0].statuses?.[0] || "TODO";
|
||||
const rows = await sql.unsafe(
|
||||
`INSERT INTO rwork.tasks (space_id, title, description, status, priority, labels)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||
[space[0].id, title.trim(), description || null, taskStatus, priority || "MEDIUM", labels || []]
|
||||
`INSERT INTO rwork.tasks (space_id, title, description, status, priority, labels, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[space[0].id, title.trim(), description || null, taskStatus, priority || "MEDIUM", labels || [], claims.sub]
|
||||
);
|
||||
return c.json(rows[0], 201);
|
||||
});
|
||||
|
||||
// PATCH /api/tasks/:id — update task (status change, assignment, etc.)
|
||||
routes.patch("/api/tasks/:id", async (c) => {
|
||||
// Optional auth — track who updated
|
||||
const token = extractToken(c.req.raw.headers);
|
||||
let updatedBy: string | null = null;
|
||||
if (token) {
|
||||
try { const claims = await verifyEncryptIDToken(token); updatedBy = claims.sub; } catch {}
|
||||
}
|
||||
|
||||
const id = c.req.param("id");
|
||||
const body = await c.req.json();
|
||||
const { title, description, status, priority, labels, sort_order, assignee_id } = body;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -21,10 +21,14 @@
|
|||
"nodemailer": "^6.9.0",
|
||||
"sharp": "^0.33.0",
|
||||
"perfect-arrows": "^0.3.7",
|
||||
"perfect-freehand": "^1.2.2"
|
||||
"perfect-freehand": "^1.2.2",
|
||||
"@aws-sdk/client-s3": "^3.700.0",
|
||||
"imapflow": "^1.0.170",
|
||||
"mailparser": "^3.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/mailparser": "^3.4.0",
|
||||
"@types/node": "^22.10.1",
|
||||
"bun-types": "^1.1.38",
|
||||
"typescript": "^5.7.2",
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import { networkModule } from "../modules/network/mod";
|
|||
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 { spaces } from "./spaces";
|
||||
import { renderShell } from "./shell";
|
||||
|
||||
|
|
@ -84,6 +85,7 @@ registerModule(networkModule);
|
|||
registerModule(tubeModule);
|
||||
registerModule(inboxModule);
|
||||
registerModule(dataModule);
|
||||
registerModule(conicModule);
|
||||
|
||||
// ── Config ──
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
|
|
|
|||
|
|
@ -625,6 +625,40 @@ export default defineConfig({
|
|||
resolve(__dirname, "modules/data/components/data.css"),
|
||||
resolve(__dirname, "dist/modules/data/data.css"),
|
||||
);
|
||||
|
||||
// Build conic module component
|
||||
await build({
|
||||
configFile: false,
|
||||
root: resolve(__dirname, "modules/conic/components"),
|
||||
resolve: {
|
||||
alias: {
|
||||
"../lib/types": resolve(__dirname, "modules/conic/lib/types.ts"),
|
||||
"../lib/conic-math": resolve(__dirname, "modules/conic/lib/conic-math.ts"),
|
||||
"../lib/projection": resolve(__dirname, "modules/conic/lib/projection.ts"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
outDir: resolve(__dirname, "dist/modules/conic"),
|
||||
lib: {
|
||||
entry: resolve(__dirname, "modules/conic/components/folk-conic-viewer.ts"),
|
||||
formats: ["es"],
|
||||
fileName: () => "folk-conic-viewer.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: "folk-conic-viewer.js",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Copy conic CSS
|
||||
mkdirSync(resolve(__dirname, "dist/modules/conic"), { recursive: true });
|
||||
copyFileSync(
|
||||
resolve(__dirname, "modules/conic/components/conic.css"),
|
||||
resolve(__dirname, "dist/modules/conic/conic.css"),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue