feat: add backend logic in /backend
This commit is contained in:
parent
44356cac33
commit
384eb31752
4
backend/.gitignore
vendored
Normal file
4
backend/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.wrangler
|
||||||
|
dist
|
||||||
|
.env
|
||||||
1652
backend/package-lock.json
generated
Normal file
1652
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-xd1uom",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "latest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"wrangler": "^3.109.2",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"@cloudflare/workers-types": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
111
backend/src/index.ts
Normal file
111
backend/src/index.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { cors } from 'hono/cors';
|
||||||
|
import { AppContext } from './utils';
|
||||||
|
import publicRoutes from './routes/public';
|
||||||
|
import adminRoutes from './routes/admin';
|
||||||
|
|
||||||
|
const app = new Hono<AppContext>();
|
||||||
|
|
||||||
|
// CORS Middleware
|
||||||
|
app.use(
|
||||||
|
'*',
|
||||||
|
cors({
|
||||||
|
origin: '*', // In production, restrict this to your frontend's origin
|
||||||
|
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
allowHeaders: ['Content-Type', 'Authorization', 'X-Anonymous-User-ID', 'x-mworld-origin'],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/', (c) => c.text('API is running'));
|
||||||
|
|
||||||
|
// Mount API routes
|
||||||
|
app.route('/api', publicRoutes);
|
||||||
|
app.route('/api/admin', adminRoutes);
|
||||||
|
|
||||||
|
// 404 Handler
|
||||||
|
app.notFound((c) => {
|
||||||
|
return c.json({ success: false, error: 'Not found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error Handler
|
||||||
|
app.onError((err, c) => {
|
||||||
|
console.error('An error occurred:', err);
|
||||||
|
return c.json({ success: false, error: 'An internal error occurred' }, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scheduled Task for dissolving posts
|
||||||
|
const scheduledTask = async (env: AppContext['Bindings']) => {
|
||||||
|
console.log('Running scheduled task: Dissolving expired posts...');
|
||||||
|
const { DB } = env;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { results: expiredPosts } = await DB.prepare(
|
||||||
|
`SELECT id, anonymous_user_id, emotion_tag FROM posts WHERE expires_at <= CURRENT_TIMESTAMP`
|
||||||
|
).all<{ id: string; anonymous_user_id: string; emotion_tag: string }>();
|
||||||
|
|
||||||
|
if (!expiredPosts || expiredPosts.length === 0) {
|
||||||
|
console.log('No expired posts to process.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const postIds = expiredPosts.map((p) => p.id);
|
||||||
|
|
||||||
|
// Batch fetch hold counts
|
||||||
|
const holdsCountsQuery = `
|
||||||
|
SELECT post_id, COUNT(id) as hold_count
|
||||||
|
FROM holds
|
||||||
|
WHERE post_id IN (${postIds.map(() => '?').join(',')})
|
||||||
|
GROUP BY post_id;
|
||||||
|
`;
|
||||||
|
const { results: holdsCounts } = await DB.prepare(holdsCountsQuery)
|
||||||
|
.bind(...postIds)
|
||||||
|
.all<{ post_id: string; hold_count: number }>();
|
||||||
|
|
||||||
|
const holdsMap = new Map<string, number>();
|
||||||
|
if (holdsCounts) {
|
||||||
|
for (const row of holdsCounts) {
|
||||||
|
holdsMap.set(row.post_id, row.hold_count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare batch insert for summaries
|
||||||
|
const summaryStmts = expiredPosts.map((post) => {
|
||||||
|
const holdCount = holdsMap.get(post.id) || 0;
|
||||||
|
return DB.prepare(
|
||||||
|
`INSERT INTO post_summaries (id, anonymous_user_id, emotion_tag, hold_count, dissolved_at)
|
||||||
|
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`
|
||||||
|
).bind(crypto.randomUUID(),
|
||||||
|
post.anonymous_user_id,
|
||||||
|
post.emotion_tag,
|
||||||
|
holdCount
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (summaryStmts.length > 0) {
|
||||||
|
await DB.batch(summaryStmts);
|
||||||
|
console.log(`Created ${summaryStmts.length} post summaries.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch delete expired posts
|
||||||
|
const deleteQuery = `DELETE FROM posts WHERE id IN (${postIds.map(() => '?').join(',')})`;
|
||||||
|
const deleteResult = await DB.prepare(deleteQuery).bind(...postIds).run();
|
||||||
|
|
||||||
|
if(deleteResult.success) {
|
||||||
|
console.log(`Successfully deleted ${deleteResult.meta.changes} expired posts.`);
|
||||||
|
} else {
|
||||||
|
console.error(`Failed to delete expired posts: ${deleteResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Error in scheduled task:', e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default {
|
||||||
|
fetch: app.fetch,
|
||||||
|
scheduled: async (event: ScheduledEvent, env: AppContext['Bindings'], ctx: ExecutionContext) => {
|
||||||
|
ctx.waitUntil(scheduledTask(env));
|
||||||
|
},
|
||||||
|
};
|
||||||
238
backend/src/routes/admin.ts
Normal file
238
backend/src/routes/admin.ts
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
import { Hono, MiddlewareHandler } from 'hono';
|
||||||
|
import { AppContext, UserData, adminAuthMiddleware } from '../utils';
|
||||||
|
|
||||||
|
type ReportWithPost = {
|
||||||
|
reportId: number;
|
||||||
|
postId: string;
|
||||||
|
reason: string;
|
||||||
|
reportedAt: string;
|
||||||
|
post: {
|
||||||
|
contentType: string;
|
||||||
|
contentData: string;
|
||||||
|
emotionTag: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const adminRoutes = new Hono<AppContext>();
|
||||||
|
|
||||||
|
// Apply admin authentication to all routes in this file
|
||||||
|
adminRoutes.use('*', adminAuthMiddleware);
|
||||||
|
|
||||||
|
// Middleware to check for 'admin' role specifically
|
||||||
|
const adminOnly: MiddlewareHandler<AppContext> = async (c, next) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
if (user?.role !== 'admin') {
|
||||||
|
return c.json({ success: false, error: 'Forbidden: Admins only.' }, 403);
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Get Dashboard Stats
|
||||||
|
adminRoutes.get('/dashboard', async (c) => {
|
||||||
|
try {
|
||||||
|
const activePostsStmt = c.env.DB.prepare("SELECT COUNT(*) as count FROM posts WHERE expires_at > CURRENT_TIMESTAMP");
|
||||||
|
const pendingReportsStmt = c.env.DB.prepare("SELECT COUNT(*) as count FROM reports WHERE status = 'pending'");
|
||||||
|
const totalUsersStmt = c.env.DB.prepare("SELECT COUNT(*) as count FROM users");
|
||||||
|
const postsTodayStmt = c.env.DB.prepare("SELECT COUNT(*) as count FROM posts WHERE created_at >= date('now', '-1 day')");
|
||||||
|
|
||||||
|
const [activePosts, pendingReports, totalUsers, postsToday] = await c.env.DB.batch<{ count: number }[]>([
|
||||||
|
activePostsStmt,
|
||||||
|
pendingReportsStmt,
|
||||||
|
totalUsersStmt,
|
||||||
|
postsTodayStmt
|
||||||
|
]);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
activePosts: activePosts.results[0]?.count ?? 0,
|
||||||
|
pendingReports: pendingReports.results[0]?.count ?? 0,
|
||||||
|
totalUsers: totalUsers.results[0]?.count ?? 0,
|
||||||
|
postsToday: postsToday.results[0]?.count ?? 0
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Dashboard stat query failed:", e);
|
||||||
|
return c.json({ success: false, error: 'Failed to retrieve dashboard stats.' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Get Reported Posts
|
||||||
|
adminRoutes.get('/reports', async (c) => {
|
||||||
|
const { status = 'pending', page = '1', limit = '20' } = c.req.query();
|
||||||
|
const pageNum = parseInt(page, 10);
|
||||||
|
const limitNum = parseInt(limit, 10);
|
||||||
|
const offset = (pageNum - 1) * limitNum;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const reportsQuery = `
|
||||||
|
SELECT r.id as reportId, r.post_id as postId, r.reason, r.created_at as reportedAt,
|
||||||
|
p.content_type as "post:contentType", p.content_data as "post:contentData", p.emotion_tag as "post:emotionTag"
|
||||||
|
FROM reports r
|
||||||
|
JOIN posts p ON r.post_id = p.id
|
||||||
|
WHERE r.status = ?
|
||||||
|
ORDER BY r.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`;
|
||||||
|
const totalQuery = 'SELECT COUNT(*) as count FROM reports WHERE status = ?';
|
||||||
|
|
||||||
|
const { results: reports } = await c.env.DB.prepare(reportsQuery).bind(status, limitNum, offset).all<any>();
|
||||||
|
const totalResult = await c.env.DB.prepare(totalQuery).bind(status).first<{ count: number }>();
|
||||||
|
|
||||||
|
const formattedReports = reports.map(r => ({
|
||||||
|
reportId: r.reportId,
|
||||||
|
postId: r.postId,
|
||||||
|
reason: r.reason,
|
||||||
|
reportedAt: r.reportedAt,
|
||||||
|
post: {
|
||||||
|
contentType: r['post:contentType'],
|
||||||
|
contentData: r['post:contentData'],
|
||||||
|
emotionTag: r['post:emotionTag'],
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
reports: formattedReports,
|
||||||
|
pagination: {
|
||||||
|
total: totalResult?.count ?? 0,
|
||||||
|
page: pageNum,
|
||||||
|
limit: limitNum,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Get reports failed:", e);
|
||||||
|
return c.json({ success: false, error: 'Failed to retrieve reports.' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Review a Report
|
||||||
|
adminRoutes.post('/reports/:reportId/review', async (c) => {
|
||||||
|
const reportId = parseInt(c.req.param('reportId'), 10);
|
||||||
|
const body = await c.req.json();
|
||||||
|
const { action } = body;
|
||||||
|
|
||||||
|
if (!['dismiss', 'delete_post'].includes(action)) {
|
||||||
|
return c.json({ success: false, error: 'Invalid action specified.' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (action === 'dismiss') {
|
||||||
|
await c.env.DB.prepare("UPDATE reports SET status = 'reviewed' WHERE id = ?").bind(reportId).run();
|
||||||
|
return c.json({ message: 'Report dismissed successfully.' });
|
||||||
|
} else if (action === 'delete_post') {
|
||||||
|
const report = await c.env.DB.prepare("SELECT post_id FROM reports WHERE id = ?").bind(reportId).first<{post_id: string}>();
|
||||||
|
if (!report) {
|
||||||
|
return c.json({ success: false, error: 'Report not found.' }, 404);
|
||||||
|
}
|
||||||
|
await c.env.DB.prepare("DELETE FROM posts WHERE id = ?").bind(report.post_id).run();
|
||||||
|
// ON DELETE CASCADE handles deleting the report itself.
|
||||||
|
return c.json({ message: 'Post deleted successfully.' });
|
||||||
|
}
|
||||||
|
return c.json({ success: false, error: 'Action not implemented' }, 501);
|
||||||
|
} catch(e: any) {
|
||||||
|
console.error(`Review report ${reportId} failed:`, e);
|
||||||
|
return c.json({ success: false, error: 'Database operation failed.' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 4. Manage Admin Users (CRUD)
|
||||||
|
const usersCrud = new Hono<AppContext>();
|
||||||
|
usersCrud.use('*', adminOnly); // Only admins can manage users
|
||||||
|
|
||||||
|
// GET all users
|
||||||
|
usersCrud.get('/', async (c) => {
|
||||||
|
const { results } = await c.env.DB.prepare('SELECT email, username, fullName, role, created_at FROM users').all<UserData>();
|
||||||
|
return c.json(results || []);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST a new user
|
||||||
|
usersCrud.post('/', async (c) => {
|
||||||
|
const { email, fullName, role = 'moderator' } = await c.req.json();
|
||||||
|
if (!email || !fullName) {
|
||||||
|
return c.json({ success: false, error: 'Email and full name are required.' }, 400);
|
||||||
|
}
|
||||||
|
const username = email.split('@')[0] + Math.random().toString(36).substring(2, 6);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await c.env.DB.prepare('INSERT INTO users (email, username, fullName, role) VALUES (?, ?, ?, ?)')
|
||||||
|
.bind(email, username, fullName, role).run();
|
||||||
|
const newUser = await c.env.DB.prepare('SELECT email, username, fullName, role, created_at FROM users WHERE email = ?').bind(email).first();
|
||||||
|
return c.json(newUser, 201);
|
||||||
|
} catch(e: any) {
|
||||||
|
if (e.message?.includes('UNIQUE')) {
|
||||||
|
return c.json({ success: false, error: 'User with this email already exists.' }, 409);
|
||||||
|
}
|
||||||
|
return c.json({ success: false, error: 'Could not create user.' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET a single user by email (PK)
|
||||||
|
usersCrud.get('/:email', async (c) => {
|
||||||
|
const { email } = c.req.param();
|
||||||
|
const user = await c.env.DB.prepare('SELECT email, username, fullName, role, phone, citizen_id, avatar_url, bio, id_verification_status, created_at FROM users WHERE email = ?').bind(email).first<UserData>();
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ success: false, error: 'User not found.' }, 404);
|
||||||
|
}
|
||||||
|
return c.json(user);
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT (update) a user
|
||||||
|
usersCrud.put('/:email', async (c) => {
|
||||||
|
const { email } = c.req.param();
|
||||||
|
const { fullName, role, bio } = await c.req.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { success } = await c.env.DB.prepare(
|
||||||
|
`UPDATE users SET
|
||||||
|
fullName = COALESCE(?, fullName),
|
||||||
|
role = COALESCE(?, role),
|
||||||
|
bio = COALESCE(?, bio)
|
||||||
|
WHERE email = ?`
|
||||||
|
).bind(fullName ?? null, role ?? null, bio ?? null, email).run();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return c.json({ success: false, error: 'Failed to update user.' }, 500);
|
||||||
|
}
|
||||||
|
const updatedUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(email).first();
|
||||||
|
return c.json(updatedUser);
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ success: false, error: 'Database update failed.' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE a user
|
||||||
|
usersCrud.delete('/:email', async (c) => {
|
||||||
|
const { email } = c.req.param();
|
||||||
|
const adminUser = c.get('user');
|
||||||
|
|
||||||
|
if (adminUser.email === email) {
|
||||||
|
return c.json({ success: false, error: 'You cannot delete your own account.' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { success } = await c.env.DB.prepare('DELETE FROM users WHERE email = ?').bind(email).run();
|
||||||
|
if (!success) {
|
||||||
|
return c.json({ success: false, error: 'Failed to delete user.' }, 500);
|
||||||
|
}
|
||||||
|
return c.body(null, 204);
|
||||||
|
});
|
||||||
|
|
||||||
|
adminRoutes.route('/users', usersCrud);
|
||||||
|
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export default adminRoutes;
|
||||||
309
backend/src/routes/public.ts
Normal file
309
backend/src/routes/public.ts
Normal file
@ -0,0 +1,309 @@
|
|||||||
|
import { Hono, MiddlewareHandler } from 'hono';
|
||||||
|
import { AppContext, UserData } from '../utils';
|
||||||
|
|
||||||
|
// Define types based on schema for clarity
|
||||||
|
type Post = {
|
||||||
|
id: string;
|
||||||
|
anonymous_user_id: string;
|
||||||
|
content_type: 'text' | 'voice' | 'photo';
|
||||||
|
content_data: string;
|
||||||
|
emotion_tag: 'Sadness' | 'Rage' | 'Joy' | 'Anxiety' | 'Love' | 'Grief' | 'Excitement';
|
||||||
|
created_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PostSummary = {
|
||||||
|
id: string;
|
||||||
|
anonymous_user_id: string;
|
||||||
|
emotion_tag: string;
|
||||||
|
hold_count: number;
|
||||||
|
dissolved_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMOTION_TAGS = ['Sadness', 'Rage', 'Joy', 'Anxiety', 'Love', 'Grief', 'Excitement'];
|
||||||
|
|
||||||
|
const publicRoutes = new Hono<AppContext>();
|
||||||
|
|
||||||
|
// Middleware to extract and validate the anonymous user ID
|
||||||
|
const anonymousUserMiddleware: MiddlewareHandler<AppContext> = async (c, next) => {
|
||||||
|
const anonymousUserId = c.req.header('X-Anonymous-User-ID');
|
||||||
|
if (!anonymousUserId) {
|
||||||
|
return c.json({ success: false, error: 'X-Anonymous-User-ID header is required.' }, 400);
|
||||||
|
}
|
||||||
|
c.set('anonymousUserId', anonymousUserId);
|
||||||
|
await next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// CRITICAL: R2 File upload route (as per general instructions)
|
||||||
|
publicRoutes.post('/upload', anonymousUserMiddleware, async (c) => {
|
||||||
|
const body = await c.req.parseBody();
|
||||||
|
const file = body['file'];
|
||||||
|
|
||||||
|
if (!(file instanceof File)) {
|
||||||
|
return c.json({ success: false, error: 'File is required in the form data.' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileExtension = file.name.split('.').pop()?.toLowerCase() || '';
|
||||||
|
const key = `${crypto.randomUUID()}.${fileExtension}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await c.env.R2.put(key, await file.arrayBuffer(), {
|
||||||
|
httpMetadata: { contentType: file.type },
|
||||||
|
});
|
||||||
|
const url = `/api/media/${key}`;
|
||||||
|
return c.json({ success: true, url: url });
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("R2 Upload failed:", e);
|
||||||
|
return c.json({ success: false, error: 'Failed to upload file.' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// CRITICAL: R2 Media proxy route (as per general instructions)
|
||||||
|
publicRoutes.get('/media/:key', async (c) => {
|
||||||
|
const key = c.req.param('key');
|
||||||
|
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');
|
||||||
|
|
||||||
|
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 (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 });
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// 2. Create Post
|
||||||
|
publicRoutes.post('/posts', anonymousUserMiddleware, async (c) => {
|
||||||
|
const anonymousUserId = c.get('anonymousUserId');
|
||||||
|
const body = await c.req.json();
|
||||||
|
|
||||||
|
const { contentType, contentData, emotionTag } = body;
|
||||||
|
|
||||||
|
if (!contentType || !contentData || !emotionTag) {
|
||||||
|
return c.json({ success: false, error: 'contentType, contentData, and emotionTag are required.' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['text', 'voice', 'photo'].includes(contentType)) {
|
||||||
|
return c.json({ success: false, error: 'Invalid contentType.' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!EMOTION_TAGS.includes(emotionTag)) {
|
||||||
|
return c.json({ success: false, error: 'Invalid emotionTag.' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ttlSetting = await c.env.DB.prepare("SELECT value FROM settings WHERE key = 'default_post_ttl_hours'").first<{ value: string }>();
|
||||||
|
const ttlHours = ttlSetting ? parseInt(ttlSetting.value, 10) : 24;
|
||||||
|
|
||||||
|
const expiresAt = new Date();
|
||||||
|
expiresAt.setHours(expiresAt.getHours() + ttlHours);
|
||||||
|
|
||||||
|
const postId = crypto.randomUUID();
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
const expiresAtISO = expiresAt.toISOString();
|
||||||
|
|
||||||
|
const { success } = await c.env.DB.prepare(
|
||||||
|
`INSERT INTO posts (id, anonymous_user_id, content_type, content_data, emotion_tag, created_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
).bind(postId, anonymousUserId, contentType, contentData, emotionTag, createdAt, expiresAtISO).run();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return c.json({ success: false, error: 'Failed to create post.' }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPost: Partial<Post> = {
|
||||||
|
id: postId,
|
||||||
|
contentType,
|
||||||
|
contentData,
|
||||||
|
emotionTag,
|
||||||
|
createdAt: createdAt,
|
||||||
|
expiresAt: expiresAtISO
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json(newPost, 201);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Post creation failed:", e);
|
||||||
|
return c.json({ success: false, error: 'Database operation failed.' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Get Stream of Posts
|
||||||
|
publicRoutes.get('/posts', async (c) => {
|
||||||
|
const { emotion, cursor } = c.req.query();
|
||||||
|
const limit = 20;
|
||||||
|
|
||||||
|
let query = 'SELECT id, content_type, content_data, emotion_tag, created_at FROM posts WHERE expires_at > CURRENT_TIMESTAMP';
|
||||||
|
const bindings: (string | number)[] = [];
|
||||||
|
|
||||||
|
if (emotion && EMOTION_TAGS.includes(emotion)) {
|
||||||
|
query += ' AND emotion_tag = ?';
|
||||||
|
bindings.push(emotion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursor) {
|
||||||
|
query += ' AND created_at < ?';
|
||||||
|
bindings.push(cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY created_at DESC LIMIT ?';
|
||||||
|
bindings.push(limit);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { results } = await c.env.DB.prepare(query).bind(...bindings).all<Post>();
|
||||||
|
const posts = results || [];
|
||||||
|
|
||||||
|
let nextCursor: string | null = null;
|
||||||
|
if (posts.length === limit) {
|
||||||
|
nextCursor = posts[posts.length - 1].created_at;
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ posts, nextCursor });
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Get posts failed:", e);
|
||||||
|
return c.json({ success: false, error: 'Failed to retrieve posts.' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Get User's Own Active Posts
|
||||||
|
publicRoutes.get('/posts/me', anonymousUserMiddleware, async (c) => {
|
||||||
|
const anonymousUserId = c.get('anonymousUserId');
|
||||||
|
try {
|
||||||
|
const { results } = await c.env.DB.prepare(
|
||||||
|
`SELECT p.*, (SELECT COUNT(*) FROM holds h WHERE h.post_id = p.id) as holdCount
|
||||||
|
FROM posts p
|
||||||
|
WHERE p.anonymous_user_id = ? AND p.expires_at > CURRENT_TIMESTAMP
|
||||||
|
ORDER BY p.created_at DESC`
|
||||||
|
).bind(anonymousUserId).all<Post & { holdCount: number }>();
|
||||||
|
|
||||||
|
return c.json({ posts: results || [] });
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Get user's posts failed:", e);
|
||||||
|
return c.json({ success: false, error: 'Failed to retrieve your posts.' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. Hold a Post
|
||||||
|
publicRoutes.post('/posts/:postId/hold', anonymousUserMiddleware, async (c) => {
|
||||||
|
const anonymousUserId = c.get('anonymousUserId');
|
||||||
|
const { postId } = c.req.param();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if post exists and is active
|
||||||
|
const post = await c.env.DB.prepare("SELECT id FROM posts WHERE id = ? AND expires_at > CURRENT_TIMESTAMP").bind(postId).first();
|
||||||
|
if (!post) {
|
||||||
|
return c.json({ success: false, error: 'Post not found or has expired.' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { success } = await c.env.DB.prepare(
|
||||||
|
'INSERT INTO holds (post_id, anonymous_user_id) VALUES (?, ?)'
|
||||||
|
).bind(postId, anonymousUserId).run();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
// This likely means a UNIQUE constraint violation
|
||||||
|
return c.json({ success: false, error: 'You have already held this post.' }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.body(null, 204);
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
// D1 throws a generic error for constraint violations
|
||||||
|
if (e.message?.includes('UNIQUE constraint failed')) {
|
||||||
|
return c.json({ success: false, error: 'You have already held this post.' }, 409);
|
||||||
|
}
|
||||||
|
console.error("Hold post failed:", e);
|
||||||
|
return c.json({ success: false, error: 'Database operation failed.' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// 6. Report a Post
|
||||||
|
publicRoutes.post('/posts/:postId/report', anonymousUserMiddleware, async (c) => {
|
||||||
|
const anonymousUserId = c.get('anonymousUserId');
|
||||||
|
const { postId } = c.req.param();
|
||||||
|
const body = await c.req.json();
|
||||||
|
const reason = body.reason || null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const post = await c.env.DB.prepare("SELECT id FROM posts WHERE id = ?").bind(postId).first();
|
||||||
|
if (!post) {
|
||||||
|
return c.json({ success: false, error: 'Post not found.' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingReport = await c.env.DB.prepare("SELECT id FROM reports WHERE post_id = ? AND reporter_anonymous_user_id = ?")
|
||||||
|
.bind(postId, anonymousUserId)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existingReport) {
|
||||||
|
return c.json({ success: false, error: 'You have already reported this post.' }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
'INSERT INTO reports (post_id, reporter_anonymous_user_id, reason) VALUES (?, ?, ?)'
|
||||||
|
).bind(postId, anonymousUserId, reason).run();
|
||||||
|
|
||||||
|
return c.body(null, 202);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Report post failed:", e);
|
||||||
|
return c.json({ success: false, error: 'Database operation failed.' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. Get Post Summaries
|
||||||
|
publicRoutes.get('/posts/summaries', anonymousUserMiddleware, async (c) => {
|
||||||
|
const anonymousUserId = c.get('anonymousUserId');
|
||||||
|
try {
|
||||||
|
const { results } = await c.env.DB.prepare(
|
||||||
|
`SELECT id, emotion_tag, hold_count, dissolved_at
|
||||||
|
FROM post_summaries
|
||||||
|
WHERE anonymous_user_id = ?
|
||||||
|
ORDER BY dissolved_at DESC`
|
||||||
|
).bind(anonymousUserId).all<PostSummary>();
|
||||||
|
|
||||||
|
return c.json({ summaries: results || [] });
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Get summaries failed:", e);
|
||||||
|
return c.json({ success: false, error: 'Failed to retrieve post summaries.' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 8. Clear Post Summaries
|
||||||
|
publicRoutes.post('/posts/summaries/clear', anonymousUserMiddleware, async (c) => {
|
||||||
|
const anonymousUserId = c.get('anonymousUserId');
|
||||||
|
const body = await c.req.json();
|
||||||
|
const summaryIds = body.summaryIds;
|
||||||
|
|
||||||
|
if (!Array.isArray(summaryIds) || summaryIds.length === 0) {
|
||||||
|
return c.json({ success: false, error: 'summaryIds must be a non-empty array.' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query = `DELETE FROM post_summaries WHERE anonymous_user_id = ? AND id IN (${summaryIds.map(() => '?').join(',')})`;
|
||||||
|
await c.env.DB.prepare(query).bind(anonymousUserId, ...summaryIds).run();
|
||||||
|
|
||||||
|
return c.body(null, 204);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Clear summaries failed:", e);
|
||||||
|
return c.json({ success: false, error: 'Failed to clear summaries.' }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default publicRoutes;
|
||||||
168
backend/src/routes/utils.ts
Normal file
168
backend/src/routes/utils.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
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;
|
||||||
|
S3_ENDPOINT: string;
|
||||||
|
S3_ACCESS_KEY_ID: string;
|
||||||
|
S3_SECRET_ACCESS_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',
|
||||||
|
fullName: 'Admin User',
|
||||||
|
citizen_id: 'admin-bypass-id',
|
||||||
|
phone: '9999999999',
|
||||||
|
role: 'admin'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const userData = data.user_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)}`;
|
||||||
|
|
||||||
|
await c.env.DB.prepare(`
|
||||||
|
INSERT INTO users (email, fullName, mauth_token, phone, username, citizen_id, role)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).bind(
|
||||||
|
userData.email,
|
||||||
|
userData.fullName,
|
||||||
|
token,
|
||||||
|
userData.phone || null,
|
||||||
|
defaultUsername,
|
||||||
|
userData.citizen_id,
|
||||||
|
'visitor'
|
||||||
|
).run();
|
||||||
|
|
||||||
|
const newUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(userData.email).first();
|
||||||
|
c.set('user', newUser as UserData);
|
||||||
|
} else {
|
||||||
|
c.set('user', dbUser as UserData);
|
||||||
|
}
|
||||||
|
|
||||||
|
c.set('token', token);
|
||||||
|
await next();
|
||||||
|
} catch (err: any) {
|
||||||
|
return c.json({ success: false, msg: err.message }, 401);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminAuthMiddleware: MiddlewareHandler<AppContext> = async (c, next) => {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ success: false, msg: 'Missing admin token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
// 1. Try mAuth Agentic Token verification
|
||||||
|
try {
|
||||||
|
const verifyRes = await fetch('https://api.mauth.mworld.cloud/mAuthAG/verifyToken', {
|
||||||
|
headers: { 'mAuthAgenticToken': token }
|
||||||
|
});
|
||||||
|
const verifyData = await verifyRes.json() as any;
|
||||||
|
if (verifyData.success) {
|
||||||
|
// Valid Agentic Token
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Admin Auth] Agentic verification failed:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback to Legacy Hash-based validation
|
||||||
|
try {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(token);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
|
const result = await c.env.DB.prepare("SELECT value FROM settings WHERE key = 'admin_access_token_hash'")
|
||||||
|
.first<{ value: string }>();
|
||||||
|
|
||||||
|
if (result && result.value === hashHex) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Admin Auth] Legacy verification failed:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: false, msg: 'Invalid or expired admin token' }, 401);
|
||||||
|
};
|
||||||
168
backend/src/utils.ts
Normal file
168
backend/src/utils.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
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;
|
||||||
|
S3_ENDPOINT: string;
|
||||||
|
S3_ACCESS_KEY_ID: string;
|
||||||
|
S3_SECRET_ACCESS_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',
|
||||||
|
fullName: 'Admin User',
|
||||||
|
citizen_id: 'admin-bypass-id',
|
||||||
|
phone: '9999999999',
|
||||||
|
role: 'admin'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const userData = data.user_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)}`;
|
||||||
|
|
||||||
|
await c.env.DB.prepare(`
|
||||||
|
INSERT INTO users (email, fullName, mauth_token, phone, username, citizen_id, role)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).bind(
|
||||||
|
userData.email,
|
||||||
|
userData.fullName,
|
||||||
|
token,
|
||||||
|
userData.phone || null,
|
||||||
|
defaultUsername,
|
||||||
|
userData.citizen_id,
|
||||||
|
'visitor'
|
||||||
|
).run();
|
||||||
|
|
||||||
|
const newUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(userData.email).first();
|
||||||
|
c.set('user', newUser as UserData);
|
||||||
|
} else {
|
||||||
|
c.set('user', dbUser as UserData);
|
||||||
|
}
|
||||||
|
|
||||||
|
c.set('token', token);
|
||||||
|
await next();
|
||||||
|
} catch (err: any) {
|
||||||
|
return c.json({ success: false, msg: err.message }, 401);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adminAuthMiddleware: MiddlewareHandler<AppContext> = async (c, next) => {
|
||||||
|
const authHeader = c.req.header('Authorization');
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return c.json({ success: false, msg: 'Missing admin token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
// 1. Try mAuth Agentic Token verification
|
||||||
|
try {
|
||||||
|
const verifyRes = await fetch('https://api.mauth.mworld.cloud/mAuthAG/verifyToken', {
|
||||||
|
headers: { 'mAuthAgenticToken': token }
|
||||||
|
});
|
||||||
|
const verifyData = await verifyRes.json() as any;
|
||||||
|
if (verifyData.success) {
|
||||||
|
// Valid Agentic Token
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Admin Auth] Agentic verification failed:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback to Legacy Hash-based validation
|
||||||
|
try {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(token);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
|
const result = await c.env.DB.prepare("SELECT value FROM settings WHERE key = 'admin_access_token_hash'")
|
||||||
|
.first<{ value: string }>();
|
||||||
|
|
||||||
|
if (result && result.value === hashHex) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Admin Auth] Legacy verification failed:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: false, msg: 'Invalid or expired admin 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
|
||||||
|
}
|
||||||
|
}
|
||||||
21
backend/wrangler.json
Normal file
21
backend/wrangler.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "app-xd1uom",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"compatibility_date": "2024-04-01",
|
||||||
|
"d1_databases": [
|
||||||
|
{
|
||||||
|
"binding": "DB",
|
||||||
|
"database_name": "db-xd1uom",
|
||||||
|
"database_id": "23b00c58-c2c5-4f98-abba-ce49c8cfaa0c"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "R2",
|
||||||
|
"bucket_name": "bucket-xd1uom"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vars": {
|
||||||
|
"S3_ENDPOINT": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
1
database/wipe_db.sql
Normal file
1
database/wipe_db.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS "post_summaries"; DROP TABLE IF EXISTS "reports"; DROP TABLE IF EXISTS "holds"; DROP TABLE IF EXISTS "posts"; DROP TABLE IF EXISTS "settings"; DROP TABLE IF EXISTS "users";
|
||||||
3
wrangler.toml
Normal file
3
wrangler.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
name = "xd1uom-worker"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2024-01-01"
|
||||||
Loading…
Reference in New Issue
Block a user