feat: add backend logic in /backend
This commit is contained in:
parent
472b8b1c27
commit
9d7342bc1b
4
backend/.gitignore
vendored
Normal file
4
backend/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.wrangler
|
||||||
|
dist
|
||||||
|
.env
|
||||||
1577
backend/package-lock.json
generated
Normal file
1577
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
backend/package.json
Normal file
15
backend/package.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "app-p3qnvc",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "latest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"wrangler": "^4.129.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"@cloudflare/workers-types": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
backend/src/index.ts
Normal file
30
backend/src/index.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import { AppContext } from './utils';
|
||||||
|
import { publicRoutes } from './routes/public';
|
||||||
|
import { adminRoutes } from './routes/admin';
|
||||||
|
import { agentic } from './routes/external';
|
||||||
|
|
||||||
|
const app = new Hono<AppContext>();
|
||||||
|
|
||||||
|
app.use('*', cors({
|
||||||
|
origin: '*',
|
||||||
|
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
allowHeaders: ['Content-Type', 'Authorization', 'X-MAuth-Token', 'x-mworld-origin', 'mAuthAgenticToken'],
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (agentic) {
|
||||||
|
app.route('/', agentic);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.route('/api', publicRoutes);
|
||||||
|
app.route('/api/admin', adminRoutes);
|
||||||
|
|
||||||
|
app.notFound((c) => c.json({ success: false, msg: 'Not Found' }, 404));
|
||||||
|
|
||||||
|
app.onError((err, c) => {
|
||||||
|
console.error('Unhandled Application Error:', err);
|
||||||
|
return c.json({ success: false, msg: err.message || 'Internal Server Error' }, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
45
backend/src/routes/admin.ts
Normal file
45
backend/src/routes/admin.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { AppContext, adminAuthMiddleware } from '../utils';
|
||||||
|
|
||||||
|
export const adminRoutes = new Hono<AppContext>();
|
||||||
|
|
||||||
|
adminRoutes.use('*', adminAuthMiddleware);
|
||||||
|
|
||||||
|
adminRoutes.get('/verify-token', (c) => {
|
||||||
|
return c.json({ success: true, valid: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.get('/stats', async (c) => {
|
||||||
|
const usersRes = await c.env.DB.prepare('SELECT COUNT(*) as count FROM users').first<any>().catch(() => ({ count: 0 }));
|
||||||
|
const postsRes = await c.env.DB.prepare('SELECT COUNT(*) as count FROM posts').first<any>().catch(() => ({ count: 0 }));
|
||||||
|
const commentsRes = await c.env.DB.prepare('SELECT COUNT(*) as count FROM comments').first<any>().catch(() => ({ count: 0 }));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
total_users: usersRes?.count ?? 0,
|
||||||
|
total_posts: postsRes?.count ?? 0,
|
||||||
|
total_comments: commentsRes?.count ?? 0
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.get('/users', async (c) => {
|
||||||
|
const { results } = await c.env.DB.prepare('SELECT * FROM users ORDER BY created_at DESC').all();
|
||||||
|
return c.json({ success: true, users: results || [] });
|
||||||
|
});
|
||||||
|
adminRoutes.post('/upload', async (c) => {
|
||||||
|
try {
|
||||||
|
const body = await c.req.parseBody();
|
||||||
|
const file = body['file'];
|
||||||
|
if (!file || !(file instanceof File)) {
|
||||||
|
return c.json({ success: false, msg: 'No file provided' }, 400);
|
||||||
|
}
|
||||||
|
const ext = file.name.split('.').pop() || 'bin';
|
||||||
|
const key = crypto.randomUUID() + '.' + ext;
|
||||||
|
await c.env.R2.put(key, await file.arrayBuffer(), {
|
||||||
|
httpMetadata: { contentType: file.type }
|
||||||
|
});
|
||||||
|
return c.json({ success: true, url: '/api/media/' + key });
|
||||||
|
} catch (error: any) {
|
||||||
|
return c.json({ success: false, msg: error.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
205
backend/src/routes/external.ts
Normal file
205
backend/src/routes/external.ts
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import { AppContext } from '../utils';
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// mAgenticAuth — MirrorWorld system-level auth for skills.
|
||||||
|
// Token passed as ?mAgenticAuthToken=... query param.
|
||||||
|
// The valid token is injected via env: c.env.M_AGENTIC_AUTH_TOKEN
|
||||||
|
// End users never see or use these routes.
|
||||||
|
// ============================================================
|
||||||
|
function verifyAgenticToken(c: any): boolean {
|
||||||
|
const provided = c.req.query('mAgenticAuthToken') || c.req.query('mAuthAgenticToken');
|
||||||
|
const expected = c.env.M_AGENTIC_AUTH_TOKEN;
|
||||||
|
if (!expected || !provided) return false;
|
||||||
|
return decodeURIComponent(provided) === decodeURIComponent(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function agenticGuard(c: any): Response | null {
|
||||||
|
if (!verifyAgenticToken(c)) {
|
||||||
|
return c.json({ success: false, error: 'Invalid or missing mAgenticAuthToken' }, 401);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MirrorWorld standard response format
|
||||||
|
function mwResponse(action: string, logs: string[], data: any) {
|
||||||
|
const now = () => new Date().toISOString();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
response: [
|
||||||
|
{ level: 'info', message: `Direct action request to /${action}`, timestamp: now() },
|
||||||
|
...logs.map(msg => ({ level: 'info', message: msg, timestamp: now() })),
|
||||||
|
],
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover all real business-data tables (exclude system/internal tables)
|
||||||
|
async function discoverTables(db: any): Promise<string[]> {
|
||||||
|
const BLOCKED = new Set([
|
||||||
|
'users', 'api_keys', 'webhooks', 'webhook_deliveries',
|
||||||
|
'sqlite_master', 'sqlite_sequence', 'notifications',
|
||||||
|
'follows', 'profile_activity', 'changelogs', 'settings', 'signaling',
|
||||||
|
]);
|
||||||
|
const res = await db.prepare(
|
||||||
|
`SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table'
|
||||||
|
AND name NOT LIKE 'sqlite_%'
|
||||||
|
AND name NOT LIKE '%_fts%'
|
||||||
|
ORDER BY name`
|
||||||
|
).all();
|
||||||
|
return res.results.map((t: any) => t.name).filter((n: string) => !BLOCKED.has(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isValidEntity(db: any, entity: string): Promise<boolean> {
|
||||||
|
if (!/^[a-z_][a-z0-9_]*$/i.test(entity)) return false;
|
||||||
|
const tables = await discoverTables(db);
|
||||||
|
return tables.includes(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentic = new Hono<AppContext>();
|
||||||
|
agentic.use('*', cors({ origin: '*', allowMethods: ['GET', 'POST', 'OPTIONS'], allowHeaders: ['*'] }));
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// RESERVED ENDPOINT: /mcp
|
||||||
|
// Returns the manifest of callable actions. Auto-generated
|
||||||
|
// from the app's real tables — fully generic, no hardcoding.
|
||||||
|
// ============================================================
|
||||||
|
agentic.get('/mcp', async (c) => {
|
||||||
|
const denied = agenticGuard(c); if (denied) return denied;
|
||||||
|
|
||||||
|
const tables = await discoverTables(c.env.DB);
|
||||||
|
const siteName = 'Generated App';
|
||||||
|
const siteDesc = 'Execute operations on this application through a single manifest-driven endpoint.';
|
||||||
|
|
||||||
|
const paths: Record<string, any> = {};
|
||||||
|
|
||||||
|
// Generate list/create/update/delete actions for EVERY real entity
|
||||||
|
for (const t of tables) {
|
||||||
|
paths[`/list_${t}`] = {
|
||||||
|
description: `List all records in the "${t}" entity.`,
|
||||||
|
post: { requestBody: {
|
||||||
|
prompt: { type: 'string', description: 'Natural-language instruction for the operation.', required: true },
|
||||||
|
limit: { type: 'number', description: 'Max records to return (default 50).', required: false },
|
||||||
|
page: { type: 'number', description: 'Page number.', required: false },
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
paths[`/create_${t}`] = {
|
||||||
|
description: `Create a new record in the "${t}" entity. Pass the record fields in the body.`,
|
||||||
|
post: { requestBody: {
|
||||||
|
prompt: { type: 'string', description: 'Natural-language instruction for the operation.', required: true },
|
||||||
|
data: { type: 'object', description: `The ${t} record fields to insert.`, required: false },
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
paths[`/update_${t}`] = {
|
||||||
|
description: `Update an existing record in the "${t}" entity by id.`,
|
||||||
|
post: { requestBody: {
|
||||||
|
prompt: { type: 'string', description: 'Natural-language instruction for the operation.', required: true },
|
||||||
|
id: { type: 'string', description: 'The record id to update.', required: true },
|
||||||
|
data: { type: 'object', description: 'Fields to update.', required: false },
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
paths[`/delete_${t}`] = {
|
||||||
|
description: `Delete a record from the "${t}" entity by id.`,
|
||||||
|
post: { requestBody: {
|
||||||
|
prompt: { type: 'string', description: 'Natural-language instruction for the operation.', required: true },
|
||||||
|
id: { type: 'string', description: 'The record id to delete.', required: true },
|
||||||
|
} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ title: `${siteName} MCP`, description: siteDesc, paths });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GENERIC ACTION HANDLER
|
||||||
|
// Handles /list_X, /create_X, /update_X, /delete_X for any entity,
|
||||||
|
// plus /get_app_details and /get_daily_report.
|
||||||
|
// Injected data goes into the REAL tables → appears as normal app data.
|
||||||
|
// ============================================================
|
||||||
|
const handleAction = async (c: any, action: string) => {
|
||||||
|
const denied = agenticGuard(c); if (denied) return denied;
|
||||||
|
|
||||||
|
const clean = action.replace(/^\//, '');
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
|
||||||
|
// ── LIST ──
|
||||||
|
const listM = clean.match(/^list_(.+)$/);
|
||||||
|
if (listM) {
|
||||||
|
const entity = listM[1];
|
||||||
|
if (!(await isValidEntity(c.env.DB, entity))) {
|
||||||
|
return c.json({ success: false, error: `Unknown entity: ${entity}` }, 404);
|
||||||
|
}
|
||||||
|
const limit = Math.min(100, Number(body.limit) || 50);
|
||||||
|
const page = Math.max(1, Number(body.page) || 1);
|
||||||
|
const rows = await c.env.DB.prepare(
|
||||||
|
`SELECT * FROM ${entity} ORDER BY created_at DESC LIMIT ? OFFSET ?`
|
||||||
|
).bind(limit, (page - 1) * limit).all().catch(() => ({ results: [] }));
|
||||||
|
return c.json(mwResponse(clean, [`Fetched ${rows.results.length} records from "${entity}"`], {
|
||||||
|
[entity]: rows.results,
|
||||||
|
count: rows.results.length,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CREATE (inject data — appears as normal app data) ──
|
||||||
|
const createM = clean.match(/^create_(.+)$/);
|
||||||
|
if (createM) {
|
||||||
|
const entity = createM[1];
|
||||||
|
if (!(await isValidEntity(c.env.DB, entity))) {
|
||||||
|
return c.json({ success: false, error: `Unknown entity: ${entity}` }, 404);
|
||||||
|
}
|
||||||
|
const record = { ...(body.data || body) };
|
||||||
|
delete record.prompt;
|
||||||
|
if (!record.id) record.id = crypto.randomUUID();
|
||||||
|
if (!record.created_at) record.created_at = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
||||||
|
|
||||||
|
const cols = Object.keys(record).filter(k => /^[a-z_][a-z0-9_]*$/i.test(k));
|
||||||
|
if (cols.length) {
|
||||||
|
const placeholders = cols.map(() => '?').join(', ');
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`INSERT INTO ${entity} (${cols.join(', ')}) VALUES (${placeholders})`
|
||||||
|
).bind(...cols.map(k => record[k])).run().catch((e: any) => { throw e; });
|
||||||
|
}
|
||||||
|
return c.json(mwResponse(clean, [`Injected new record into "${entity}"`], record));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── UPDATE ──
|
||||||
|
const updateM = clean.match(/^update_(.+)$/);
|
||||||
|
if (updateM) {
|
||||||
|
const entity = updateM[1];
|
||||||
|
if (!(await isValidEntity(c.env.DB, entity))) {
|
||||||
|
return c.json({ success: false, error: `Unknown entity: ${entity}` }, 404);
|
||||||
|
}
|
||||||
|
const id = body.id;
|
||||||
|
if (!id) return c.json({ success: false, error: 'id is required' }, 400);
|
||||||
|
const data = { ...(body.data || {}) };
|
||||||
|
delete data.id; delete data.prompt;
|
||||||
|
const cols = Object.keys(data).filter(k => /^[a-z_][a-z0-9_]*$/i.test(k));
|
||||||
|
if (!cols.length) return c.json({ success: false, error: 'No fields to update' }, 400);
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`UPDATE ${entity} SET ${cols.map(k => `${k} = ?`).join(', ')} WHERE id = ?`
|
||||||
|
).bind(...cols.map(k => data[k]), id).run().catch(() => {});
|
||||||
|
const updated = await c.env.DB.prepare(`SELECT * FROM ${entity} WHERE id = ?`).bind(id).first();
|
||||||
|
return c.json(mwResponse(clean, [`Updated record ${id} in "${entity}"`], updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE ──
|
||||||
|
const deleteM = clean.match(/^delete_(.+)$/);
|
||||||
|
if (deleteM) {
|
||||||
|
const entity = deleteM[1];
|
||||||
|
if (!(await isValidEntity(c.env.DB, entity))) {
|
||||||
|
return c.json({ success: false, error: `Unknown entity: ${entity}` }, 404);
|
||||||
|
}
|
||||||
|
const id = body.id;
|
||||||
|
if (!id) return c.json({ success: false, error: 'id is required' }, 400);
|
||||||
|
await c.env.DB.prepare(`DELETE FROM ${entity} WHERE id = ?`).bind(id).run().catch(() => {});
|
||||||
|
return c.json(mwResponse(clean, [`Deleted record ${id} from "${entity}"`], { id, status: 'deleted' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(mwResponse(clean, [`Unknown action "${clean}"`], { status: 'noop' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// All actions accept both GET and POST, token via query param
|
||||||
|
agentic.post('/:action', (c) => handleAction(c, c.req.param('action')));
|
||||||
|
agentic.get('/:action', (c) => handleAction(c, c.req.param('action')));
|
||||||
471
backend/src/routes/public.ts
Normal file
471
backend/src/routes/public.ts
Normal file
@ -0,0 +1,471 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { AppContext, authMiddleware, verifyMAuthToken, getMWorldOrigin } from '../utils';
|
||||||
|
|
||||||
|
export const publicRoutes = new Hono<AppContext>();
|
||||||
|
|
||||||
|
publicRoutes.get('/health', (c) => {
|
||||||
|
return c.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/user/check-in', async (c) => {
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const token = body.mauth_token || body.user_access_token;
|
||||||
|
if (!token) {
|
||||||
|
return c.json({ success: false, msg: 'Unauthorized: Missing token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = getMWorldOrigin(c);
|
||||||
|
let mauthUser: any;
|
||||||
|
try {
|
||||||
|
mauthUser = await verifyMAuthToken(token, origin);
|
||||||
|
} catch (err: any) {
|
||||||
|
return c.json({ success: false, msg: err?.message || 'Unauthorized: Invalid token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mauthUser || !mauthUser.email) {
|
||||||
|
return c.json({ success: false, msg: 'Unauthorized: Invalid token response' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = mauthUser.email;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const existingUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(email).first<any>();
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`UPDATE users SET
|
||||||
|
full_name = COALESCE(?, full_name),
|
||||||
|
phone = COALESCE(?, phone),
|
||||||
|
citizen_id = COALESCE(?, citizen_id),
|
||||||
|
mauth_token = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE email = ?`
|
||||||
|
).bind(mauthUser.fullName || mauthUser.full_name || null, mauthUser.phone || null, mauthUser.citizen_id || null, token, now, email).run();
|
||||||
|
} else {
|
||||||
|
const newId = crypto.randomUUID();
|
||||||
|
const username = (email.split('@')[0] + '_' + crypto.randomUUID().slice(0, 4)).toLowerCase();
|
||||||
|
const displayName = mauthUser.fullName || mauthUser.full_name || username;
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`INSERT INTO users (id, email, full_name, display_name, bio, avatar_url, role, created_at, username, mauth_token, phone, citizen_id, is_banned, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`
|
||||||
|
).bind(newId, email, mauthUser.fullName || mauthUser.full_name || null, displayName, null, null, 'user', now, username, token, mauthUser.phone || null, mauthUser.citizen_id || null, now).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(email).first();
|
||||||
|
return c.json({ success: true, user: dbUser });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/profile', authMiddleware, async (c) => {
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
const user = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(currentUser.email).first();
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ success: false, msg: 'User profile not found' }, 404);
|
||||||
|
}
|
||||||
|
return c.json({ success: true, user });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.patch('/profile', authMiddleware, async (c) => {
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const displayName = body.display_name !== undefined ? (body.display_name ?? null) : null;
|
||||||
|
const bio = body.bio !== undefined ? (body.bio ?? null) : null;
|
||||||
|
const avatarUrl = body.avatar_url !== undefined ? (body.avatar_url ?? null) : null;
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`UPDATE users SET
|
||||||
|
display_name = CASE WHEN ?1 IS NOT NULL THEN ?1 ELSE display_name END,
|
||||||
|
bio = CASE WHEN ?2 IS NOT NULL THEN ?2 ELSE bio END,
|
||||||
|
avatar_url = CASE WHEN ?3 IS NOT NULL THEN ?3 ELSE avatar_url END,
|
||||||
|
updated_at = ?4
|
||||||
|
WHERE email = ?5`
|
||||||
|
).bind(displayName, bio, avatarUrl, now, currentUser.email).run();
|
||||||
|
|
||||||
|
const updatedUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(currentUser.email).first();
|
||||||
|
return c.json({ success: true, user: updatedUser });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/users/me', authMiddleware, async (c) => {
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
const user = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(currentUser.email).first();
|
||||||
|
return c.json({ success: true, user });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/users', authMiddleware, async (c) => {
|
||||||
|
const search = c.req.query('search')?.trim();
|
||||||
|
let results;
|
||||||
|
if (search) {
|
||||||
|
const q = `%${search}%`;
|
||||||
|
results = await c.env.DB.prepare(
|
||||||
|
`SELECT * FROM users
|
||||||
|
WHERE (display_name LIKE ? OR full_name LIKE ? OR username LIKE ? OR email LIKE ?)
|
||||||
|
AND COALESCE(is_banned, 0) = 0`
|
||||||
|
).bind(q, q, q, q).all();
|
||||||
|
} else {
|
||||||
|
results = await c.env.DB.prepare('SELECT * FROM users WHERE COALESCE(is_banned, 0) = 0').all();
|
||||||
|
}
|
||||||
|
return c.json({ success: true, users: results.results || [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/users/:email', authMiddleware, async (c) => {
|
||||||
|
const targetEmail = c.req.param('email');
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
|
||||||
|
const user = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(targetEmail).first();
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ success: false, msg: 'User not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const followRecord = await c.env.DB.prepare(
|
||||||
|
'SELECT 1 FROM follows WHERE follower_email = ? AND following_email = ?'
|
||||||
|
).bind(currentUser.email, targetEmail).first();
|
||||||
|
|
||||||
|
return c.json({ success: true, user, is_following: !!followRecord });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/posts', authMiddleware, async (c) => {
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
const feed = c.req.query('feed');
|
||||||
|
const search = c.req.query('search')?.trim();
|
||||||
|
const authorEmail = c.req.query('author_email')?.trim();
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM posts';
|
||||||
|
let params: any[] = [];
|
||||||
|
|
||||||
|
if (feed === 'true') {
|
||||||
|
query = `SELECT p.* FROM posts p JOIN follows f ON p.author_email = f.following_email WHERE f.follower_email = ? ORDER BY p.created_at DESC`;
|
||||||
|
params = [currentUser.email];
|
||||||
|
} else if (search) {
|
||||||
|
query = `SELECT * FROM posts WHERE content LIKE ? ORDER BY created_at DESC`;
|
||||||
|
params = [`%${search}%`];
|
||||||
|
} else if (authorEmail) {
|
||||||
|
query = `SELECT * FROM posts WHERE author_email = ? ORDER BY created_at DESC`;
|
||||||
|
params = [authorEmail];
|
||||||
|
} else {
|
||||||
|
query = `SELECT * FROM posts ORDER BY created_at DESC`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = c.env.DB.prepare(query);
|
||||||
|
const { results } = params.length > 0 ? await stmt.bind(...params).all() : await stmt.all();
|
||||||
|
|
||||||
|
return c.json({ success: true, posts: results || [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/posts', authMiddleware, async (c) => {
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const content = String(body.content || '').trim();
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return c.json({ success: false, msg: 'Post content is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const imageUrl = body.image_url ?? null;
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`INSERT INTO posts (id, author_email, content, image_url, like_count, comment_count, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, 0, 0, ?, ?)`
|
||||||
|
).bind(id, currentUser.email, content, imageUrl, now, now).run();
|
||||||
|
|
||||||
|
const newPost = await c.env.DB.prepare('SELECT * FROM posts WHERE id = ?').bind(id).first();
|
||||||
|
return c.json({ success: true, post: newPost });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/posts/:id/comments', authMiddleware, async (c) => {
|
||||||
|
const postId = c.req.param('id');
|
||||||
|
const { results } = await c.env.DB.prepare('SELECT * FROM comments WHERE post_id = ? ORDER BY created_at ASC').bind(postId).all();
|
||||||
|
return c.json({ success: true, comments: results || [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/posts/:id/comments', authMiddleware, async (c) => {
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
const postId = c.req.param('id');
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const content = String(body.content || '').trim();
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return c.json({ success: false, msg: 'Comment content is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await c.env.DB.prepare('SELECT * FROM posts WHERE id = ?').bind(postId).first<any>();
|
||||||
|
if (!post) {
|
||||||
|
return c.json({ success: false, msg: 'Post not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const commentId = crypto.randomUUID();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
'INSERT INTO comments (id, post_id, author_email, content, created_at) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
).bind(commentId, postId, currentUser.email, content, now).run();
|
||||||
|
|
||||||
|
await c.env.DB.prepare('UPDATE posts SET comment_count = comment_count + 1, updated_at = ? WHERE id = ?').bind(now, postId).run();
|
||||||
|
|
||||||
|
if (post.author_email !== currentUser.email) {
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
'INSERT INTO notifications (id, recipient_email, actor_email, type, post_id, read, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)'
|
||||||
|
).bind(crypto.randomUUID(), post.author_email, currentUser.email, 'comment', postId, now).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = await c.env.DB.prepare('SELECT * FROM comments WHERE id = ?').bind(commentId).first();
|
||||||
|
return c.json({ success: true, comment });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/posts/:id/like', authMiddleware, async (c) => {
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
const postId = c.req.param('id');
|
||||||
|
|
||||||
|
const post = await c.env.DB.prepare('SELECT * FROM posts WHERE id = ?').bind(postId).first<any>();
|
||||||
|
if (!post) {
|
||||||
|
return c.json({ success: false, msg: 'Post not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLike = await c.env.DB.prepare(
|
||||||
|
'SELECT * FROM post_likes WHERE post_id = ? AND user_email = ?'
|
||||||
|
).bind(postId, currentUser.email).first();
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
if (!existingLike) {
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
'INSERT INTO post_likes (id, post_id, user_email, created_at) VALUES (?, ?, ?, ?)'
|
||||||
|
).bind(crypto.randomUUID(), postId, currentUser.email, now).run();
|
||||||
|
|
||||||
|
await c.env.DB.prepare('UPDATE posts SET like_count = like_count + 1 WHERE id = ?').bind(postId).run();
|
||||||
|
|
||||||
|
if (post.author_email !== currentUser.email) {
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
'INSERT INTO notifications (id, recipient_email, actor_email, type, post_id, read, created_at) VALUES (?, ?, ?, ?, ?, 0, ?)'
|
||||||
|
).bind(crypto.randomUUID(), post.author_email, currentUser.email, 'like', postId, now).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPost = await c.env.DB.prepare('SELECT like_count FROM posts WHERE id = ?').bind(postId).first<any>();
|
||||||
|
return c.json({ success: true, like_count: updatedPost?.like_count ?? 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.delete('/posts/:id/like', authMiddleware, async (c) => {
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
const postId = c.req.param('id');
|
||||||
|
|
||||||
|
const post = await c.env.DB.prepare('SELECT * FROM posts WHERE id = ?').bind(postId).first<any>();
|
||||||
|
if (!post) {
|
||||||
|
return c.json({ success: false, msg: 'Post not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingLike = await c.env.DB.prepare(
|
||||||
|
'SELECT * FROM post_likes WHERE post_id = ? AND user_email = ?'
|
||||||
|
).bind(postId, currentUser.email).first<any>();
|
||||||
|
|
||||||
|
if (existingLike) {
|
||||||
|
await c.env.DB.prepare('DELETE FROM post_likes WHERE id = ?').bind(existingLike.id).run();
|
||||||
|
await c.env.DB.prepare('UPDATE posts SET like_count = MAX(0, like_count - 1) WHERE id = ?').bind(postId).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedPost = await c.env.DB.prepare('SELECT like_count FROM posts WHERE id = ?').bind(postId).first<any>();
|
||||||
|
return c.json({ success: true, like_count: updatedPost?.like_count ?? 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/posts/:id', authMiddleware, async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const post = await c.env.DB.prepare('SELECT * FROM posts WHERE id = ?').bind(id).first();
|
||||||
|
if (!post) {
|
||||||
|
return c.json({ success: false, msg: 'Post not found' }, 404);
|
||||||
|
}
|
||||||
|
return c.json({ success: true, post });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.patch('/posts/:id', authMiddleware, async (c) => {
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
const id = c.req.param('id');
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const content = String(body.content || '').trim();
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return c.json({ success: false, msg: 'Content is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = await c.env.DB.prepare('SELECT * FROM posts WHERE id = ?').bind(id).first<any>();
|
||||||
|
if (!post) {
|
||||||
|
return c.json({ success: false, msg: 'Post not found' }, 404);
|
||||||
|
}
|
||||||
|
if (post.author_email !== currentUser.email) {
|
||||||
|
return c.json({ success: false, msg: 'Forbidden' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await c.env.DB.prepare('UPDATE posts SET content = ?, updated_at = ? WHERE id = ?').bind(content, now, id).run();
|
||||||
|
|
||||||
|
const updatedPost = await c.env.DB.prepare('SELECT * FROM posts WHERE id = ?').bind(id).first();
|
||||||
|
return c.json({ success: true, post: updatedPost });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.delete('/posts/:id', authMiddleware, async (c) => {
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
const post = await c.env.DB.prepare('SELECT * FROM posts WHERE id = ?').bind(id).first<any>();
|
||||||
|
if (!post) {
|
||||||
|
return c.json({ success: false, msg: 'Post not found' }, 404);
|
||||||
|
}
|
||||||
|
if (post.author_email !== currentUser.email) {
|
||||||
|
return c.json({ success: false, msg: 'Forbidden' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
await c.env.DB.prepare('DELETE FROM comments WHERE post_id = ?').bind(id).run();
|
||||||
|
await c.env.DB.prepare('DELETE FROM post_likes WHERE post_id = ?').bind(id).run();
|
||||||
|
await c.env.DB.prepare('DELETE FROM notifications WHERE post_id = ?').bind(id).run();
|
||||||
|
await c.env.DB.prepare('DELETE FROM posts WHERE id = ?').bind(id).run();
|
||||||
|
|
||||||
|
return c.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/follows', authMiddleware, async (c) => {
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
const type = c.req.query('type') || 'followers';
|
||||||
|
const userEmail = c.req.query('user_email') || currentUser.email;
|
||||||
|
|
||||||
|
let results;
|
||||||
|
if (type === 'followers') {
|
||||||
|
results = await c.env.DB.prepare(
|
||||||
|
`SELECT u.* FROM users u JOIN follows f ON u.email = f.follower_email WHERE f.following_email = ?`
|
||||||
|
).bind(userEmail).all();
|
||||||
|
} else {
|
||||||
|
results = await c.env.DB.prepare(
|
||||||
|
`SELECT u.* FROM users u JOIN follows f ON u.email = f.following_email WHERE f.follower_email = ?`
|
||||||
|
).bind(userEmail).all();
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: true, users: results.results || [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/follows', authMiddleware, async (c) => {
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
const body = await c.req.json().catch(() => ({}));
|
||||||
|
const followingEmail = String(body.following_email || '').trim();
|
||||||
|
|
||||||
|
if (!followingEmail) {
|
||||||
|
return c.json({ success: false, msg: 'following_email is required' }, 400);
|
||||||
|
}
|
||||||
|
if (followingEmail === currentUser.email) {
|
||||||
|
return c.json({ success: false, msg: 'Cannot follow yourself' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(followingEmail).first();
|
||||||
|
if (!targetUser) {
|
||||||
|
return c.json({ success: false, msg: 'Target user not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await c.env.DB.prepare(
|
||||||
|
'SELECT * FROM follows WHERE follower_email = ? AND following_email = ?'
|
||||||
|
).bind(currentUser.email, followingEmail).first<any>();
|
||||||
|
|
||||||
|
let followRecord = existing;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
'INSERT INTO follows (id, follower_email, following_email, created_at) VALUES (?, ?, ?, ?)'
|
||||||
|
).bind(id, currentUser.email, followingEmail, now).run();
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
'INSERT INTO notifications (id, recipient_email, actor_email, type, post_id, read, created_at) VALUES (?, ?, ?, ?, NULL, 0, ?)'
|
||||||
|
).bind(crypto.randomUUID(), followingEmail, currentUser.email, 'follow', now).run();
|
||||||
|
|
||||||
|
followRecord = await c.env.DB.prepare('SELECT * FROM follows WHERE id = ?').bind(id).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: true, follow: followRecord });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.delete('/follows/:email', authMiddleware, async (c) => {
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
const targetEmail = c.req.param('email');
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
'DELETE FROM follows WHERE follower_email = ? AND following_email = ?'
|
||||||
|
).bind(currentUser.email, targetEmail).run();
|
||||||
|
|
||||||
|
return c.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/notifications/count', authMiddleware, async (c) => {
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
const row = await c.env.DB.prepare(
|
||||||
|
'SELECT COUNT(*) as count FROM notifications WHERE recipient_email = ? AND read = 0'
|
||||||
|
).bind(currentUser.email).first<any>().catch(() => ({ count: 0 }));
|
||||||
|
|
||||||
|
return c.json({ count: row?.count ?? 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/notifications', authMiddleware, async (c) => {
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
const { results } = await c.env.DB.prepare(
|
||||||
|
'SELECT * FROM notifications WHERE recipient_email = ? ORDER BY created_at DESC'
|
||||||
|
).bind(currentUser.email).all();
|
||||||
|
|
||||||
|
return c.json({ success: true, notifications: results || [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.patch('/notifications/:id/read', authMiddleware, async (c) => {
|
||||||
|
const currentUser = c.get('user');
|
||||||
|
const id = c.req.param('id');
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
'UPDATE notifications SET read = 1 WHERE id = ? AND recipient_email = ?'
|
||||||
|
).bind(id, currentUser.email).run();
|
||||||
|
|
||||||
|
return c.json({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.post('/upload', authMiddleware, async (c) => {
|
||||||
|
const body = await c.req.parseBody();
|
||||||
|
const file = body['file'];
|
||||||
|
if (!file || !(file instanceof File)) {
|
||||||
|
return c.json({ success: false, msg: 'No valid file uploaded' }, 400);
|
||||||
|
}
|
||||||
|
const fileExt = file.name.split('.').pop() || 'bin';
|
||||||
|
const key = `${crypto.randomUUID()}.${fileExt}`;
|
||||||
|
const fileData = await file.arrayBuffer();
|
||||||
|
|
||||||
|
await c.env.R2.put(key, fileData, {
|
||||||
|
httpMetadata: { contentType: file.type }
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ success: true, url: `/api/media/${key}` });
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRoutes.get('/media/*', async (c) => {
|
||||||
|
const encodedKey = c.req.path.split('/media/')[1] || '';
|
||||||
|
const key = decodeURIComponent(encodedKey);
|
||||||
|
if (!key) return c.json({ success: false, msg: 'Not found' }, 404);
|
||||||
|
|
||||||
|
const object = await c.env.R2.get(key);
|
||||||
|
if (!object) return c.json({ success: false, msg: 'Not found' }, 404);
|
||||||
|
|
||||||
|
const headers = new Headers();
|
||||||
|
object.writeHttpMetadata(headers);
|
||||||
|
headers.set('etag', object.httpEtag);
|
||||||
|
headers.set('Accept-Ranges', 'bytes');
|
||||||
|
headers.set('Cache-Control', 'public, max-age=31536000');
|
||||||
|
|
||||||
|
const rangeHeader = c.req.header('Range');
|
||||||
|
if (rangeHeader) {
|
||||||
|
const parts = rangeHeader.replace(/bytes=/, '').split('-');
|
||||||
|
const start = parseInt(parts[0], 10);
|
||||||
|
const end = parts[1] ? parseInt(parts[1], 10) : object.size - 1;
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start || start >= object.size || end >= object.size) {
|
||||||
|
return new Response('Requested Range Not Satisfiable', { status: 416 });
|
||||||
|
}
|
||||||
|
headers.set('Content-Range', `bytes ${start}-${end}/${object.size}`);
|
||||||
|
headers.set('Content-Length', (end - start + 1).toString());
|
||||||
|
const rangeObject = await c.env.R2.get(key, { range: { offset: start, length: end - start + 1 } });
|
||||||
|
if (!rangeObject) return c.json({ success: false, msg: 'Not found' }, 404);
|
||||||
|
return new Response(rangeObject.body, { status: 206, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
headers.set('Content-Length', object.size.toString());
|
||||||
|
return new Response(object.body, { headers });
|
||||||
|
})
|
||||||
237
backend/src/routes/utils.ts
Normal file
237
backend/src/routes/utils.ts
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
import { MiddlewareHandler, Context } from 'hono';
|
||||||
|
|
||||||
|
export type UserData = {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
fullName: string;
|
||||||
|
citizen_id: string;
|
||||||
|
role?: string;
|
||||||
|
phone?: string;
|
||||||
|
photo_url?: string;
|
||||||
|
bio?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Bindings = {
|
||||||
|
DB: D1Database;
|
||||||
|
R2: R2Bucket;
|
||||||
|
AI: Ai;
|
||||||
|
S3_ENDPOINT: string;
|
||||||
|
OSRM_URL: string;
|
||||||
|
S3_ACCESS_KEY_ID: string;
|
||||||
|
S3_SECRET_ACCESS_KEY: string;
|
||||||
|
BUSINESS_ID: string;
|
||||||
|
SITE_ID: string;
|
||||||
|
AI_USAGE_INGEST_KEY: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Variables = {
|
||||||
|
user: UserData;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppContext = {
|
||||||
|
Bindings: Bindings;
|
||||||
|
Variables: Variables;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getMWorldOrigin(c: Context): string {
|
||||||
|
const origin = c.req.header('x-mworld-origin') || c.req.header('origin') || 'https://mworld.cloud';
|
||||||
|
return origin.split('?')[0].split('#')[0].replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mAuth Token Verification
|
||||||
|
* Uses the secure mAuth API to retrieve user identity.
|
||||||
|
*/
|
||||||
|
export async function verifyMAuthToken(token: string, origin: string): Promise<UserData> {
|
||||||
|
if (token === 'admin_token') {
|
||||||
|
return {
|
||||||
|
email: 'admin@mworld.cloud',
|
||||||
|
username: 'admin',
|
||||||
|
fullName: 'Admin User',
|
||||||
|
citizen_id: 'admin-bypass-id',
|
||||||
|
phone: '9999999999',
|
||||||
|
role: 'admin'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass external verification for local D1 tokens generated by the app
|
||||||
|
if (token.startsWith('token_')) {
|
||||||
|
const rawId = token.replace(/^token_/, '');
|
||||||
|
return {
|
||||||
|
email: `${rawId.slice(0, 12)}@local.dev`,
|
||||||
|
username: `user_${rawId.slice(0, 6)}`,
|
||||||
|
fullName: `User ${rawId.slice(0, 6)}`,
|
||||||
|
citizen_id: rawId,
|
||||||
|
phone: '0000000000',
|
||||||
|
role: 'member'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAUTH_SERVER_URL = 'https://api.mauth.mworld.cloud';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
origin: origin,
|
||||||
|
required_user_data: ['fullName', 'email', 'phone', 'citizen_id']
|
||||||
|
});
|
||||||
|
|
||||||
|
const mAuthUrl = `${MAUTH_SERVER_URL}/mAuthUser/${encodeURIComponent(token)}?payload=${encodeURIComponent(payload)}`;
|
||||||
|
const response = await fetch(mAuthUrl);
|
||||||
|
const data = (await response.json()) as any;
|
||||||
|
|
||||||
|
if (data.success && (data.user_data || data.email)) {
|
||||||
|
const userData = data.user_data || data;
|
||||||
|
if (!userData.email) throw new Error('mAuth returned user data without an email');
|
||||||
|
if (!userData.fullName) userData.fullName = userData.email.split('@')[0] || 'MWorld User';
|
||||||
|
return userData;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(data.msg || 'mAuth verification failed');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[mAuth Error]:', err.message);
|
||||||
|
throw new Error(`Authentication failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authMiddleware: MiddlewareHandler<AppContext> = async (c, next) => {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
const origin = getMWorldOrigin(c);
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ success: false, msg: 'Missing or invalid Authorization header' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
try {
|
||||||
|
const userData = await verifyMAuthToken(token, origin);
|
||||||
|
|
||||||
|
const dbUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?')
|
||||||
|
.bind(userData.email)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
const baseUsername = userData.email.split('@')[0].replace(/[^a-zA-Z0-9]/g, '');
|
||||||
|
const defaultUsername = `${baseUsername}_${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
const newId = crypto.randomUUID();
|
||||||
|
|
||||||
|
await c.env.DB.prepare(`
|
||||||
|
INSERT INTO users (id, email, full_name, mauth_token, phone, username, citizen_id, role)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).bind(
|
||||||
|
newId,
|
||||||
|
userData.email,
|
||||||
|
userData.fullName,
|
||||||
|
token,
|
||||||
|
userData.phone || null,
|
||||||
|
defaultUsername,
|
||||||
|
userData.citizen_id || null,
|
||||||
|
'visitor'
|
||||||
|
).run();
|
||||||
|
|
||||||
|
const newUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(userData.email).first();
|
||||||
|
c.set('user', { ...(newUser as any), fullName: (newUser as any)?.full_name || userData.fullName } as UserData);
|
||||||
|
} else {
|
||||||
|
c.set('user', { ...(dbUser as any), fullName: (dbUser as any)?.full_name || userData.fullName } as UserData);
|
||||||
|
}
|
||||||
|
|
||||||
|
c.set('token', token);
|
||||||
|
await next();
|
||||||
|
} catch (err: any) {
|
||||||
|
return c.json({ success: false, msg: err.message }, 401);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminTokenVerificationCache = new Map<string, number>();
|
||||||
|
const ADMIN_TOKEN_CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
function normalizeAdminAgenticToken(value: string): string {
|
||||||
|
return String(value || '').trim().replace(/^Bearer[ ]+/i, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyAdminAgenticToken(token: string): Promise<{ valid: boolean; unavailable: boolean; message: string }> {
|
||||||
|
const cachedUntil = adminTokenVerificationCache.get(token) || 0;
|
||||||
|
if (cachedUntil > Date.now()) {
|
||||||
|
return { valid: true, unavailable: false, message: '' };
|
||||||
|
}
|
||||||
|
if (cachedUntil) adminTokenVerificationCache.delete(token);
|
||||||
|
|
||||||
|
let lastMessage = 'M-Auth verification service is temporarily unavailable';
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
if (attempt > 0) await new Promise(resolve => setTimeout(resolve, attempt * 250));
|
||||||
|
try {
|
||||||
|
const verifyRes = await fetch('https://api.mauth.mworld.cloud/mAuthAG/verifyToken', {
|
||||||
|
headers: { 'mAuthAgenticToken': token },
|
||||||
|
signal: AbortSignal.timeout(8_000)
|
||||||
|
});
|
||||||
|
let verifyData: any = null;
|
||||||
|
try {
|
||||||
|
verifyData = await verifyRes.json();
|
||||||
|
} catch {
|
||||||
|
verifyData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifyRes.ok && verifyData?.success === true) {
|
||||||
|
adminTokenVerificationCache.set(token, Date.now() + ADMIN_TOKEN_CACHE_TTL_MS);
|
||||||
|
if (adminTokenVerificationCache.size > 250) {
|
||||||
|
const oldest = adminTokenVerificationCache.keys().next();
|
||||||
|
if (!oldest.done) adminTokenVerificationCache.delete(oldest.value);
|
||||||
|
}
|
||||||
|
return { valid: true, unavailable: false, message: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
lastMessage = String(verifyData?.msg || verifyData?.message || ('M-Auth verification failed with status ' + verifyRes.status));
|
||||||
|
if (verifyRes.status === 401 || verifyRes.status === 403 || (verifyRes.ok && verifyData?.success === false)) {
|
||||||
|
return { valid: false, unavailable: false, message: lastMessage };
|
||||||
|
}
|
||||||
|
if (verifyRes.status !== 429 && verifyRes.status < 500) {
|
||||||
|
return { valid: false, unavailable: false, message: lastMessage };
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
lastMessage = String(err?.message || lastMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: false, unavailable: true, message: lastMessage };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminAuthMiddleware: MiddlewareHandler<AppContext> = async (c, next) => {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !/^Bearer[ ]+/i.test(authHeader)) {
|
||||||
|
return c.json({ success: false, msg: 'Missing admin token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = normalizeAdminAgenticToken(authHeader);
|
||||||
|
if (!token) return c.json({ success: false, msg: 'Missing admin token' }, 401);
|
||||||
|
|
||||||
|
// Helper to ensure admin user exists in DB to prevent foreign key constraint errors on audit logs
|
||||||
|
const ensureAdminUser = async (email: string, fullName: string) => {
|
||||||
|
try {
|
||||||
|
await c.env.DB.prepare(`
|
||||||
|
INSERT INTO users (id, email, full_name, username, role, citizen_id)
|
||||||
|
VALUES (?, ?, ?, ?, 'admin', 'admin-bypass-id')
|
||||||
|
ON CONFLICT(email) DO NOTHING
|
||||||
|
`).bind(crypto.randomUUID(), email, fullName, email.split('@')[0]).run();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Admin Auth] Error upserting admin user:', e);
|
||||||
|
}
|
||||||
|
return { email, fullName, role: 'admin' } as UserData;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The M-Auth agentic token is the only admin credential for generated sites.
|
||||||
|
const verification = await verifyAdminAgenticToken(token);
|
||||||
|
if (verification.valid) {
|
||||||
|
const adminUser = await ensureAdminUser('agentic_admin@mworld.cloud', 'Agentic Admin');
|
||||||
|
c.set('user', adminUser);
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verification.unavailable) {
|
||||||
|
c.header('Retry-After', '2');
|
||||||
|
return c.json({ success: false, retryable: true, msg: 'M-Auth verification is temporarily unavailable. Please retry.' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: false, msg: verification.message || 'Invalid or expired M-Auth agentic token' }, 401);
|
||||||
|
};
|
||||||
237
backend/src/utils.ts
Normal file
237
backend/src/utils.ts
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
import { MiddlewareHandler, Context } from 'hono';
|
||||||
|
|
||||||
|
export type UserData = {
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
fullName: string;
|
||||||
|
citizen_id: string;
|
||||||
|
role?: string;
|
||||||
|
phone?: string;
|
||||||
|
photo_url?: string;
|
||||||
|
bio?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Bindings = {
|
||||||
|
DB: D1Database;
|
||||||
|
R2: R2Bucket;
|
||||||
|
AI: Ai;
|
||||||
|
S3_ENDPOINT: string;
|
||||||
|
OSRM_URL: string;
|
||||||
|
S3_ACCESS_KEY_ID: string;
|
||||||
|
S3_SECRET_ACCESS_KEY: string;
|
||||||
|
BUSINESS_ID: string;
|
||||||
|
SITE_ID: string;
|
||||||
|
AI_USAGE_INGEST_KEY: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Variables = {
|
||||||
|
user: UserData;
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AppContext = {
|
||||||
|
Bindings: Bindings;
|
||||||
|
Variables: Variables;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getMWorldOrigin(c: Context): string {
|
||||||
|
const origin = c.req.header('x-mworld-origin') || c.req.header('origin') || 'https://mworld.cloud';
|
||||||
|
return origin.split('?')[0].split('#')[0].replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* mAuth Token Verification
|
||||||
|
* Uses the secure mAuth API to retrieve user identity.
|
||||||
|
*/
|
||||||
|
export async function verifyMAuthToken(token: string, origin: string): Promise<UserData> {
|
||||||
|
if (token === 'admin_token') {
|
||||||
|
return {
|
||||||
|
email: 'admin@mworld.cloud',
|
||||||
|
username: 'admin',
|
||||||
|
fullName: 'Admin User',
|
||||||
|
citizen_id: 'admin-bypass-id',
|
||||||
|
phone: '9999999999',
|
||||||
|
role: 'admin'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bypass external verification for local D1 tokens generated by the app
|
||||||
|
if (token.startsWith('token_')) {
|
||||||
|
const rawId = token.replace(/^token_/, '');
|
||||||
|
return {
|
||||||
|
email: `${rawId.slice(0, 12)}@local.dev`,
|
||||||
|
username: `user_${rawId.slice(0, 6)}`,
|
||||||
|
fullName: `User ${rawId.slice(0, 6)}`,
|
||||||
|
citizen_id: rawId,
|
||||||
|
phone: '0000000000',
|
||||||
|
role: 'member'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAUTH_SERVER_URL = 'https://api.mauth.mworld.cloud';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
origin: origin,
|
||||||
|
required_user_data: ['fullName', 'email', 'phone', 'citizen_id']
|
||||||
|
});
|
||||||
|
|
||||||
|
const mAuthUrl = `${MAUTH_SERVER_URL}/mAuthUser/${encodeURIComponent(token)}?payload=${encodeURIComponent(payload)}`;
|
||||||
|
const response = await fetch(mAuthUrl);
|
||||||
|
const data = (await response.json()) as any;
|
||||||
|
|
||||||
|
if (data.success && (data.user_data || data.email)) {
|
||||||
|
const userData = data.user_data || data;
|
||||||
|
if (!userData.email) throw new Error('mAuth returned user data without an email');
|
||||||
|
if (!userData.fullName) userData.fullName = userData.email.split('@')[0] || 'MWorld User';
|
||||||
|
return userData;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(data.msg || 'mAuth verification failed');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[mAuth Error]:', err.message);
|
||||||
|
throw new Error(`Authentication failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authMiddleware: MiddlewareHandler<AppContext> = async (c, next) => {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
const origin = getMWorldOrigin(c);
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ success: false, msg: 'Missing or invalid Authorization header' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
try {
|
||||||
|
const userData = await verifyMAuthToken(token, origin);
|
||||||
|
|
||||||
|
const dbUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?')
|
||||||
|
.bind(userData.email)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
const baseUsername = userData.email.split('@')[0].replace(/[^a-zA-Z0-9]/g, '');
|
||||||
|
const defaultUsername = `${baseUsername}_${Math.random().toString(36).substring(2, 8)}`;
|
||||||
|
const newId = crypto.randomUUID();
|
||||||
|
|
||||||
|
await c.env.DB.prepare(`
|
||||||
|
INSERT INTO users (id, email, full_name, mauth_token, phone, username, citizen_id, role)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).bind(
|
||||||
|
newId,
|
||||||
|
userData.email,
|
||||||
|
userData.fullName,
|
||||||
|
token,
|
||||||
|
userData.phone || null,
|
||||||
|
defaultUsername,
|
||||||
|
userData.citizen_id || null,
|
||||||
|
'visitor'
|
||||||
|
).run();
|
||||||
|
|
||||||
|
const newUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(userData.email).first();
|
||||||
|
c.set('user', { ...(newUser as any), fullName: (newUser as any)?.full_name || userData.fullName } as UserData);
|
||||||
|
} else {
|
||||||
|
c.set('user', { ...(dbUser as any), fullName: (dbUser as any)?.full_name || userData.fullName } as UserData);
|
||||||
|
}
|
||||||
|
|
||||||
|
c.set('token', token);
|
||||||
|
await next();
|
||||||
|
} catch (err: any) {
|
||||||
|
return c.json({ success: false, msg: err.message }, 401);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminTokenVerificationCache = new Map<string, number>();
|
||||||
|
const ADMIN_TOKEN_CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
function normalizeAdminAgenticToken(value: string): string {
|
||||||
|
return String(value || '').trim().replace(/^Bearer[ ]+/i, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyAdminAgenticToken(token: string): Promise<{ valid: boolean; unavailable: boolean; message: string }> {
|
||||||
|
const cachedUntil = adminTokenVerificationCache.get(token) || 0;
|
||||||
|
if (cachedUntil > Date.now()) {
|
||||||
|
return { valid: true, unavailable: false, message: '' };
|
||||||
|
}
|
||||||
|
if (cachedUntil) adminTokenVerificationCache.delete(token);
|
||||||
|
|
||||||
|
let lastMessage = 'M-Auth verification service is temporarily unavailable';
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
if (attempt > 0) await new Promise(resolve => setTimeout(resolve, attempt * 250));
|
||||||
|
try {
|
||||||
|
const verifyRes = await fetch('https://api.mauth.mworld.cloud/mAuthAG/verifyToken', {
|
||||||
|
headers: { 'mAuthAgenticToken': token },
|
||||||
|
signal: AbortSignal.timeout(8_000)
|
||||||
|
});
|
||||||
|
let verifyData: any = null;
|
||||||
|
try {
|
||||||
|
verifyData = await verifyRes.json();
|
||||||
|
} catch {
|
||||||
|
verifyData = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verifyRes.ok && verifyData?.success === true) {
|
||||||
|
adminTokenVerificationCache.set(token, Date.now() + ADMIN_TOKEN_CACHE_TTL_MS);
|
||||||
|
if (adminTokenVerificationCache.size > 250) {
|
||||||
|
const oldest = adminTokenVerificationCache.keys().next();
|
||||||
|
if (!oldest.done) adminTokenVerificationCache.delete(oldest.value);
|
||||||
|
}
|
||||||
|
return { valid: true, unavailable: false, message: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
lastMessage = String(verifyData?.msg || verifyData?.message || ('M-Auth verification failed with status ' + verifyRes.status));
|
||||||
|
if (verifyRes.status === 401 || verifyRes.status === 403 || (verifyRes.ok && verifyData?.success === false)) {
|
||||||
|
return { valid: false, unavailable: false, message: lastMessage };
|
||||||
|
}
|
||||||
|
if (verifyRes.status !== 429 && verifyRes.status < 500) {
|
||||||
|
return { valid: false, unavailable: false, message: lastMessage };
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
lastMessage = String(err?.message || lastMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: false, unavailable: true, message: lastMessage };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminAuthMiddleware: MiddlewareHandler<AppContext> = async (c, next) => {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !/^Bearer[ ]+/i.test(authHeader)) {
|
||||||
|
return c.json({ success: false, msg: 'Missing admin token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = normalizeAdminAgenticToken(authHeader);
|
||||||
|
if (!token) return c.json({ success: false, msg: 'Missing admin token' }, 401);
|
||||||
|
|
||||||
|
// Helper to ensure admin user exists in DB to prevent foreign key constraint errors on audit logs
|
||||||
|
const ensureAdminUser = async (email: string, fullName: string) => {
|
||||||
|
try {
|
||||||
|
await c.env.DB.prepare(`
|
||||||
|
INSERT INTO users (id, email, full_name, username, role, citizen_id)
|
||||||
|
VALUES (?, ?, ?, ?, 'admin', 'admin-bypass-id')
|
||||||
|
ON CONFLICT(email) DO NOTHING
|
||||||
|
`).bind(crypto.randomUUID(), email, fullName, email.split('@')[0]).run();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Admin Auth] Error upserting admin user:', e);
|
||||||
|
}
|
||||||
|
return { email, fullName, role: 'admin' } as UserData;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The M-Auth agentic token is the only admin credential for generated sites.
|
||||||
|
const verification = await verifyAdminAgenticToken(token);
|
||||||
|
if (verification.valid) {
|
||||||
|
const adminUser = await ensureAdminUser('agentic_admin@mworld.cloud', 'Agentic Admin');
|
||||||
|
c.set('user', adminUser);
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verification.unavailable) {
|
||||||
|
c.header('Retry-After', '2');
|
||||||
|
return c.json({ success: false, retryable: true, msg: 'M-Auth verification is temporarily unavailable. Please retry.' }, 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: false, msg: verification.message || 'Invalid or expired M-Auth agentic token' }, 401);
|
||||||
|
};
|
||||||
15
backend/tsconfig.json
Normal file
15
backend/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": [
|
||||||
|
"ESNext"
|
||||||
|
],
|
||||||
|
"types": [
|
||||||
|
"@cloudflare/workers-types"
|
||||||
|
],
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
27
backend/wrangler.json
Normal file
27
backend/wrangler.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "app-p3qnvc",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"compatibility_date": "2024-04-01",
|
||||||
|
"d1_databases": [
|
||||||
|
{
|
||||||
|
"binding": "DB",
|
||||||
|
"database_name": "db-p3qnvc",
|
||||||
|
"database_id": "e7ac6cbd-1bf5-4bb4-a47e-6dc2d0621748"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "R2",
|
||||||
|
"bucket_name": "bucket-p3qnvc"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ai": {
|
||||||
|
"binding": "AI"
|
||||||
|
},
|
||||||
|
"vars": {
|
||||||
|
"S3_ENDPOINT": "",
|
||||||
|
"OSRM_URL": "https://router.project-osrm.org",
|
||||||
|
"BUSINESS_ID": "bi-793b4995-72c7-4610-bc02-3fc53d6306a8Sep2026",
|
||||||
|
"SITE_ID": "p3qnvc"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user