feat: add backend logic in /backend
This commit is contained in:
parent
17b4d8aaa5
commit
aa2c3683d7
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-d9wl4z",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "latest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"wrangler": "^3.109.2",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"@cloudflare/workers-types": "^4.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
48
backend/src/index.ts
Normal file
48
backend/src/index.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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 configuration
|
||||||
|
app.use(
|
||||||
|
'*',
|
||||||
|
cors({
|
||||||
|
origin: '*', // In a real app, restrict this to your frontend's origin
|
||||||
|
allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
||||||
|
allowHeaders: ['Content-Type', 'Authorization', 'X-Admin-API-Key', 'x-mworld-origin'],
|
||||||
|
exposeHeaders: ['Content-Length'],
|
||||||
|
maxAge: 600,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optional: Handle pre-flight requests
|
||||||
|
app.options('*', (c) => {
|
||||||
|
return c.text('', 204);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mount the routes
|
||||||
|
app.route('/api', publicRoutes);
|
||||||
|
app.route('/api/admin', adminRoutes);
|
||||||
|
|
||||||
|
app.get('/', (c) => {
|
||||||
|
return c.json({
|
||||||
|
message: 'Welcome to the Cloudflare Social API!',
|
||||||
|
siteId: 'd9wl4z'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.notFound((c) => {
|
||||||
|
return c.json({ success: false, msg: 'Not Found' }, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.onError((err, c) => {
|
||||||
|
console.error(`Unhandled error: ${err}`, err);
|
||||||
|
// In production, you might not want to expose the error message
|
||||||
|
const message = err instanceof Error ? err.message : 'Internal Server Error';
|
||||||
|
return c.json({ success: false, msg: message }, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
189
backend/src/routes/admin.ts
Normal file
189
backend/src/routes/admin.ts
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { AppContext, adminAuthMiddleware } from '../utils';
|
||||||
|
|
||||||
|
const adminRouter = new Hono<AppContext>();
|
||||||
|
|
||||||
|
// Apply admin auth to all routes in this file
|
||||||
|
adminRouter.use('*', adminAuthMiddleware);
|
||||||
|
|
||||||
|
// GET /api/admin/dashboard
|
||||||
|
adminRouter.get('/dashboard', async (c) => {
|
||||||
|
try {
|
||||||
|
const [users, posts, likes, comments] = await c.env.DB.batch([
|
||||||
|
c.env.DB.prepare('SELECT COUNT(*) as count FROM users'),
|
||||||
|
c.env.DB.prepare('SELECT COUNT(*) as count FROM posts'),
|
||||||
|
c.env.DB.prepare('SELECT COUNT(*) as count FROM likes'),
|
||||||
|
c.env.DB.prepare('SELECT COUNT(*) as count FROM comments'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total_users: users.results[0]?.count ?? 0,
|
||||||
|
total_posts: posts.results[0]?.count ?? 0,
|
||||||
|
total_likes: likes.results[0]?.count ?? 0,
|
||||||
|
total_comments: comments.results[0]?.count ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json({ stats });
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ success: false, msg: 'Failed to fetch dashboard stats', error: e.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/admin/users
|
||||||
|
adminRouter.get('/users', async (c) => {
|
||||||
|
const page = parseInt(c.req.query('page') || '1', 10);
|
||||||
|
const pageSize = parseInt(c.req.query('pageSize') || '20', 10);
|
||||||
|
const search = c.req.query('search');
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let usersQuery, countQuery;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const searchTerm = `%${search}%`;
|
||||||
|
usersQuery = c.env.DB.prepare(
|
||||||
|
`SELECT id, email, username, profile_picture_url, bio, (SELECT COUNT(*) FROM posts WHERE user_email = users.email) as post_count FROM users
|
||||||
|
WHERE username LIKE ?1 OR email LIKE ?1
|
||||||
|
ORDER BY created_at DESC LIMIT ?2 OFFSET ?3`
|
||||||
|
).bind(searchTerm, pageSize, offset);
|
||||||
|
countQuery = c.env.DB.prepare('SELECT COUNT(*) as count FROM users WHERE username LIKE ?1 OR email LIKE ?1').bind(searchTerm);
|
||||||
|
} else {
|
||||||
|
usersQuery = c.env.DB.prepare(
|
||||||
|
`SELECT id, email, username, profile_picture_url, bio, (SELECT COUNT(*) FROM posts WHERE user_email = users.email) as post_count FROM users
|
||||||
|
ORDER BY created_at DESC LIMIT ?1 OFFSET ?2`
|
||||||
|
).bind(pageSize, offset);
|
||||||
|
countQuery = c.env.DB.prepare('SELECT COUNT(*) as count FROM users');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ results: users }, { results: countResult }] = await c.env.DB.batch([
|
||||||
|
usersQuery,
|
||||||
|
countQuery,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = countResult[0]?.count ?? 0;
|
||||||
|
|
||||||
|
return c.json({ users: users ?? [], total });
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ success: false, msg: 'Failed to fetch users', error: e.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/admin/users/:id (Here, id is treated as the user's email)
|
||||||
|
adminRouter.delete('/users/:id', async (c) => {
|
||||||
|
const userIdOrEmail = c.req.param('id'); // This will be the user's email due to schema.
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await c.env.DB.prepare('SELECT id FROM users WHERE email = ? OR id = ?').bind(userIdOrEmail, userIdOrEmail).first();
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ success: false, msg: 'User not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletion will cascade to posts, likes, comments due to schema.
|
||||||
|
const { success } = await c.env.DB.prepare('DELETE FROM users WHERE email = ? OR id = ?').bind(userIdOrEmail, userIdOrEmail).run();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return c.json({ success: false, msg: 'Failed to delete user' }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.body(null, 204);
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ success: false, msg: 'Error deleting user', error: e.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/admin/posts
|
||||||
|
adminRouter.get('/posts', async (c) => {
|
||||||
|
const page = parseInt(c.req.query('page') || '1', 10);
|
||||||
|
const pageSize = parseInt(c.req.query('pageSize') || '20', 10);
|
||||||
|
const search = c.req.query('search');
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let postsQuery, countQuery;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
const searchTerm = `%${search}%`;
|
||||||
|
postsQuery = c.env.DB.prepare(
|
||||||
|
`SELECT p.*, u.username as author_username,
|
||||||
|
(SELECT COUNT(*) FROM likes WHERE post_id = p.id) as like_count,
|
||||||
|
(SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comment_count
|
||||||
|
FROM posts p JOIN users u ON p.user_email = u.email
|
||||||
|
WHERE p.caption LIKE ?1
|
||||||
|
ORDER BY p.created_at DESC LIMIT ?2 OFFSET ?3`
|
||||||
|
).bind(searchTerm, pageSize, offset);
|
||||||
|
countQuery = c.env.DB.prepare('SELECT COUNT(*) as count FROM posts WHERE caption LIKE ?1').bind(searchTerm);
|
||||||
|
} else {
|
||||||
|
postsQuery = c.env.DB.prepare(
|
||||||
|
`SELECT p.*, u.username as author_username,
|
||||||
|
(SELECT COUNT(*) FROM likes WHERE post_id = p.id) as like_count,
|
||||||
|
(SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comment_count
|
||||||
|
FROM posts p JOIN users u ON p.user_email = u.email
|
||||||
|
ORDER BY p.created_at DESC LIMIT ?1 OFFSET ?2`
|
||||||
|
).bind(pageSize, offset);
|
||||||
|
countQuery = c.env.DB.prepare('SELECT COUNT(*) as count FROM posts');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ results: posts }, { results: countResult }] = await c.env.DB.batch([
|
||||||
|
postsQuery,
|
||||||
|
countQuery,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = countResult[0]?.count ?? 0;
|
||||||
|
|
||||||
|
return c.json({ posts: posts ?? [], total });
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ success: false, msg: 'Failed to fetch posts', error: e.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/admin/posts/:id
|
||||||
|
adminRouter.delete('/posts/:id', async (c) => {
|
||||||
|
const postId = c.req.param('id');
|
||||||
|
try {
|
||||||
|
const post = await c.env.DB.prepare('SELECT media_url FROM posts WHERE id = ?').bind(postId).first<{media_url: string}>();
|
||||||
|
if (!post) {
|
||||||
|
return c.json({ success: false, msg: 'Post not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from R2
|
||||||
|
try {
|
||||||
|
const key = post.media_url.split('/api/media/')[1];
|
||||||
|
if (key) {
|
||||||
|
await c.env.R2.delete(key);
|
||||||
|
}
|
||||||
|
} catch(r2Error: any) {
|
||||||
|
console.error(`Admin: Failed to delete R2 object ${post.media_url}: ${r2Error.message}`);
|
||||||
|
// Do not stop the DB deletion
|
||||||
|
}
|
||||||
|
|
||||||
|
const { success } = await c.env.DB.prepare('DELETE FROM posts WHERE id = ?').bind(postId).run();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return c.json({ success: false, msg: 'Failed to delete post' }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.body(null, 204);
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ success: false, msg: 'Error deleting post', error: e.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
adminRouter.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 adminRouter;
|
||||||
417
backend/src/routes/public.ts
Normal file
417
backend/src/routes/public.ts
Normal file
@ -0,0 +1,417 @@
|
|||||||
|
import { Hono } from 'hono';
|
||||||
|
import { AppContext, authMiddleware } from '../utils';
|
||||||
|
|
||||||
|
// Types based on the D1 schema
|
||||||
|
type User = {
|
||||||
|
id: string; // The API contract uses 'id', but DB uses 'email'. We will map it. Let's use email as ID.
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
bio: string | null;
|
||||||
|
profile_picture_url: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Post = {
|
||||||
|
id: string;
|
||||||
|
caption: string | null;
|
||||||
|
media_url: string;
|
||||||
|
media_type: string;
|
||||||
|
created_at: string;
|
||||||
|
user_email: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Like = {
|
||||||
|
id: string;
|
||||||
|
user_email: string;
|
||||||
|
post_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Comment = {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
created_at: string;
|
||||||
|
user_email: string;
|
||||||
|
post_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const publicRouter = new Hono<AppContext>();
|
||||||
|
|
||||||
|
// --- Mandatory: mAuth User Check-in ---
|
||||||
|
publicRouter.post('/user/check-in', async (c) => {
|
||||||
|
try {
|
||||||
|
const body = await c.req.json();
|
||||||
|
const token = body.mauth_token || body.user_access_token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return c.json({ success: false, msg: "Token is required." }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is a placeholder for the actual mAuth verification function
|
||||||
|
// In a real scenario, this function would call the mAuth API
|
||||||
|
const verifyMAuthToken = async (token: string, origin: string) => {
|
||||||
|
// Dummy implementation for demonstration
|
||||||
|
// This would call `https://api.mauth.mworld.cloud/mAuthUser/...`
|
||||||
|
if (token === "invalid-token") throw new Error("Invalid token");
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user_data: {
|
||||||
|
email: `user_${crypto.randomUUID().substring(0,8)}@example.com`,
|
||||||
|
fullName: "Test User",
|
||||||
|
citizen_id: crypto.randomUUID(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const origin = new URL(c.req.url).origin;
|
||||||
|
const mAuthResponse = await verifyMAuthToken(token, origin);
|
||||||
|
|
||||||
|
if (!mAuthResponse.success || !mAuthResponse.user_data) {
|
||||||
|
return c.json({ success: false, msg: 'Invalid mAuth token' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, fullName, citizen_id } = mAuthResponse.user_data;
|
||||||
|
|
||||||
|
const existingUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(email).first<User>();
|
||||||
|
|
||||||
|
let user: User;
|
||||||
|
if (existingUser) {
|
||||||
|
const { success } = await c.env.DB.prepare('UPDATE users SET fullName = ?, citizen_id = ? WHERE email = ?')
|
||||||
|
.bind(fullName ?? null, citizen_id ?? null, email)
|
||||||
|
.run();
|
||||||
|
if (!success) {
|
||||||
|
return c.json({ success: false, msg: 'Failed to update user' }, 401);
|
||||||
|
}
|
||||||
|
user = { ...existingUser, fullName: fullName, citizen_id: citizen_id };
|
||||||
|
} else {
|
||||||
|
const usernameBase = (email.split('@')[0] || 'user').replace(/[^a-zA-Z0-9]/g, '');
|
||||||
|
let username = `${usernameBase}${Math.floor(Math.random() * 1000)}`;
|
||||||
|
let isUsernameTaken = await c.env.DB.prepare('SELECT id FROM users WHERE username = ?').bind(username).first();
|
||||||
|
while(isUsernameTaken) {
|
||||||
|
username = `${usernameBase}${Math.floor(Math.random() * 1000)}`;
|
||||||
|
isUsernameTaken = await c.env.DB.prepare('SELECT id FROM users WHERE username = ?').bind(username).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUserId = crypto.randomUUID();
|
||||||
|
const { success } = await c.env.DB.prepare(
|
||||||
|
'INSERT INTO users (id, email, username, bio, profile_picture_url) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
)
|
||||||
|
.bind(newUserId, email, username, 'Welcome to my profile!', null)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
if(!success) {
|
||||||
|
return c.json({ success: false, msg: 'Failed to create user' }, 401);
|
||||||
|
}
|
||||||
|
user = { id: newUserId, email, username, bio: 'Welcome to my profile!', profile_picture_url: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: true, user: user });
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ success: false, msg: e.message }, 401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// --- Mandatory: File Upload & Media Proxy ---
|
||||||
|
publicRouter.post('/upload', authMiddleware, async (c) => {
|
||||||
|
const body = await c.req.parseBody();
|
||||||
|
const file = body.file as File;
|
||||||
|
|
||||||
|
if (!file || !(file instanceof File)) {
|
||||||
|
return c.json({ success: false, msg: 'File is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileExtension = file.name.split('.').pop() || '';
|
||||||
|
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) {
|
||||||
|
return c.json({ success: false, msg: 'Failed to upload file', error: e.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRouter.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 });
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Mandatory: Geolocation Proxies ---
|
||||||
|
publicRouter.get('/geocode', authMiddleware, async (c) => {
|
||||||
|
const q = c.req.query('q');
|
||||||
|
if (!q) {
|
||||||
|
return c.json({ success: false, msg: 'Query parameter "q" is required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=jsonv2&addressdetails=1`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': 'MWorld-Fullstack-Builder/1.0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return c.json({ success: false, msg: 'Failed to fetch geocoding data' }, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return c.json(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
publicRouter.get('/reverse-geocode', authMiddleware, async (c) => {
|
||||||
|
const lat = c.req.query('lat');
|
||||||
|
const lon = c.req.query('lon');
|
||||||
|
|
||||||
|
if (!lat || !lon) {
|
||||||
|
return c.json({ success: false, msg: 'Query parameters "lat" and "lon" are required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=jsonv2&addressdetails=1`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': 'MWorld-Fullstack-Builder/1.0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return c.json({ success: false, msg: 'Failed to fetch reverse geocoding data' }, response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return c.json(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- API Contract Routes ---
|
||||||
|
|
||||||
|
// GET /api/posts
|
||||||
|
publicRouter.get('/posts', async (c) => {
|
||||||
|
try {
|
||||||
|
const { results } = await c.env.DB.prepare('SELECT * FROM posts ORDER BY created_at DESC LIMIT 50').all<Post>();
|
||||||
|
return c.json({ success: true, results: results ?? [] });
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ success: false, msg: 'Failed to fetch posts', error: e.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/posts
|
||||||
|
publicRouter.post('/posts', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const body = await c.req.json<{ caption?: string; media_url: string; media_type: string }>();
|
||||||
|
|
||||||
|
if (!body.media_url || !body.media_type) {
|
||||||
|
return c.json({ success: false, msg: 'media_url and media_type are required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
const { success } = await c.env.DB.prepare(
|
||||||
|
'INSERT INTO posts (id, caption, media_url, media_type, created_at, user_email) VALUES (?, ?, ?, ?, ?, ?)'
|
||||||
|
).bind(id, body.caption ?? null, body.media_url || null, body.media_type || null, createdAt, user.email || null).run();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return c.json({ success: false, msg: 'Failed to create post' }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPost: Post = {
|
||||||
|
id,
|
||||||
|
caption: body.caption ?? null,
|
||||||
|
media_url: body.media_url,
|
||||||
|
media_type: body.media_type,
|
||||||
|
created_at: createdAt,
|
||||||
|
user_email: user.email,
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json({ success: true, post: newPost }, 201);
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ success: false, msg: 'Database error', error: e.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/users/:username/posts
|
||||||
|
publicRouter.get('/users/:username/posts', async (c) => {
|
||||||
|
const { username } = c.req.param();
|
||||||
|
try {
|
||||||
|
const user = await c.env.DB.prepare('SELECT email FROM users WHERE username = ?').bind(username).first<{ email: string }>();
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ success: false, msg: 'User not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { results } = await c.env.DB.prepare('SELECT * FROM posts WHERE user_email = ? ORDER BY created_at DESC')
|
||||||
|
.bind(user.email || null)
|
||||||
|
.all<Post>();
|
||||||
|
|
||||||
|
return c.json({ success: true, results: results ?? [] });
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ success: false, msg: 'Failed to fetch user posts', error: e.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/posts/:id/like
|
||||||
|
publicRouter.post('/posts/:id/like', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const postId = c.req.param('id');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const post = await c.env.DB.prepare('SELECT id FROM posts WHERE id = ?').bind(postId).first();
|
||||||
|
if (!post) {
|
||||||
|
return c.json({ success: false, msg: 'Post not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const { success } = await c.env.DB.prepare('INSERT INTO likes (id, user_email, post_id) VALUES (?, ?, ?)')
|
||||||
|
.bind(id, user.email || null, postId)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
// If it fails because of unique constraint, it's not an error in this context.
|
||||||
|
// A more robust implementation would use ON CONFLICT DO NOTHING.
|
||||||
|
// D1 doesn't support that directly in write queries. A pre-check is an option.
|
||||||
|
if (!success) {
|
||||||
|
const existingLike = await c.env.DB.prepare('SELECT * FROM likes WHERE user_email = ? AND post_id = ?').bind(user.email || null, postId).first<Like>();
|
||||||
|
if(existingLike){
|
||||||
|
return c.json({ success: true, like: existingLike });
|
||||||
|
}
|
||||||
|
return c.json({ success: false, msg: 'Failed to like post' }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLike: Like = { id, user_email: user.email, post_id: postId };
|
||||||
|
return c.json({ success: true, like: newLike }, 201);
|
||||||
|
} catch (e: any) {
|
||||||
|
// Catch unique constraint violation
|
||||||
|
if (e.message?.includes('UNIQUE constraint failed')) {
|
||||||
|
const existingLike = await c.env.DB.prepare('SELECT * FROM likes WHERE user_email = ? AND post_id = ?').bind(user.email || null, postId).first<Like>();
|
||||||
|
return c.json({ success: true, like: existingLike });
|
||||||
|
}
|
||||||
|
return c.json({ success: false, msg: 'Failed to process like', error: e.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// POST /api/posts/:id/comments
|
||||||
|
publicRouter.post('/posts/:id/comments', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const postId = c.req.param('id');
|
||||||
|
const body = await c.req.json<{ content: string }>();
|
||||||
|
|
||||||
|
if (!body.content || typeof body.content !== 'string' || body.content.trim() === '') {
|
||||||
|
return c.json({ success: false, msg: 'Comment content cannot be empty' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const post = await c.env.DB.prepare('SELECT id FROM posts WHERE id = ?').bind(postId).first();
|
||||||
|
if (!post) {
|
||||||
|
return c.json({ success: false, msg: 'Post not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const createdAt = new Date().toISOString();
|
||||||
|
const { success } = await c.env.DB.prepare(
|
||||||
|
'INSERT INTO comments (id, content, created_at, user_email, post_id) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
).bind(id, body.content || null, createdAt, user.email || null, postId).run();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return c.json({ success: false, msg: 'Failed to create comment' }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newComment: Comment = {
|
||||||
|
id,
|
||||||
|
content: body.content,
|
||||||
|
created_at: createdAt,
|
||||||
|
user_email: user.email,
|
||||||
|
post_id: postId,
|
||||||
|
};
|
||||||
|
|
||||||
|
return c.json({ success: true, comment: newComment }, 201);
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ success: false, msg: 'Database error', error: e.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// DELETE /api/posts/:id
|
||||||
|
publicRouter.delete('/posts/:id', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const postId = c.req.param('id');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const post = await c.env.DB.prepare('SELECT user_email, media_url FROM posts WHERE id = ?')
|
||||||
|
.bind(postId)
|
||||||
|
.first<{ user_email: string; media_url: string }>();
|
||||||
|
|
||||||
|
if (!post) {
|
||||||
|
return c.json({ success: false, msg: 'Post not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.user_email !== user.email) {
|
||||||
|
return c.json({ success: false, msg: 'You are not authorized to delete this post' }, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from R2
|
||||||
|
try {
|
||||||
|
const key = post.media_url.split('/api/media/')[1];
|
||||||
|
if (key) {
|
||||||
|
await c.env.R2.delete(key);
|
||||||
|
}
|
||||||
|
} catch (r2Error: any) {
|
||||||
|
console.error(`Failed to delete R2 object: ${r2Error.message}`);
|
||||||
|
// Don't block DB deletion if R2 deletion fails
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from DB (likes and comments will cascade)
|
||||||
|
const { success } = await c.env.DB.prepare('DELETE FROM posts WHERE id = ?').bind(postId).run();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
return c.json({ success: false, msg: 'Failed to delete post from database' }, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ success: false, msg: 'Failed to delete post', error: e.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/posts/:id/like (for unliking, based on technical context)
|
||||||
|
publicRouter.delete('/posts/:id/like', authMiddleware, async (c) => {
|
||||||
|
const user = c.get('user');
|
||||||
|
const postId = c.req.param('id');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { success } = await c.env.DB.prepare('DELETE FROM likes WHERE user_email = ? AND post_id = ?')
|
||||||
|
.bind(user.email || null, postId)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
// This can happen if the like doesn't exist, which is fine.
|
||||||
|
// We check meta.changes to be sure.
|
||||||
|
// But let's assume it worked unless an error is thrown.
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: true, message: 'Post unliked.' });
|
||||||
|
} catch (e: any) {
|
||||||
|
return c.json({ success: false, msg: 'Failed to unlike post', error: e.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default publicRouter;
|
||||||
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-d9wl4z",
|
||||||
|
"main": "src/index.ts",
|
||||||
|
"compatibility_date": "2024-04-01",
|
||||||
|
"d1_databases": [
|
||||||
|
{
|
||||||
|
"binding": "DB",
|
||||||
|
"database_name": "db-d9wl4z",
|
||||||
|
"database_id": "7ba46300-3cc2-4c2b-946c-918414b18b0e"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"r2_buckets": [
|
||||||
|
{
|
||||||
|
"binding": "R2",
|
||||||
|
"bucket_name": "bucket-d9wl4z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vars": {
|
||||||
|
"S3_ENDPOINT": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
1
database/wipe_db.sql
Normal file
1
database/wipe_db.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS "comments"; DROP TABLE IF EXISTS "likes"; DROP TABLE IF EXISTS "posts"; DROP TABLE IF EXISTS "users"; DROP TABLE IF EXISTS "roles"; DROP TABLE IF EXISTS "settings";
|
||||||
Loading…
Reference in New Issue
Block a user