import { Hono } from 'hono'; import { AppContext, authMiddleware, getMWorldOrigin, verifyMAuthToken } 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; fullName: string; citizen_id: string | null; bio: string | null; avatar_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(); // --- 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); } const origin = getMWorldOrigin(c); const userData = await verifyMAuthToken(token, origin); const { email, fullName, citizen_id } = userData; const existingUser = await c.env.DB.prepare('SELECT * FROM users WHERE email = ?').bind(email).first(); 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 } as any; } 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 email FROM users WHERE username = ?').bind(username).first(); while(isUsernameTaken) { username = `${usernameBase}${Math.floor(Math.random() * 1000)}`; isUsernameTaken = await c.env.DB.prepare('SELECT email FROM users WHERE username = ?').bind(username).first(); } const { success } = await c.env.DB.prepare( 'INSERT INTO users (email, username, fullName, citizen_id, bio, avatar_url) VALUES (?, ?, ?, ?, ?, ?)' ) .bind(email, username, fullName ?? 'Unknown', citizen_id ?? null, 'Welcome to my profile!', null) .run(); if(!success) { return c.json({ success: false, msg: 'Failed to create user' }, 401); } user = { email, username, fullName: fullName ?? 'Unknown', citizen_id: citizen_id ?? null, bio: 'Welcome to my profile!', avatar_url: null } as any; } 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 as any); } 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 as any); } const data = await response.json(); return c.json(data); }); // --- API Contract Routes --- // GET /api/posts publicRouter.get('/posts', async (c) => { try { const authHeader = c.req.header('Authorization'); let userEmail: string | null = null; if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.split(' ')[1]; try { const origin = getMWorldOrigin(c); const userData = await verifyMAuthToken(token, origin); userEmail = userData.email; } catch (e) { // Ignore invalid tokens for public feed } } const { results } = await c.env.DB.prepare(` SELECT p.*, (SELECT COUNT(*) FROM likes WHERE post_id = p.id) as likes_count, (SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comments_count, (SELECT 1 FROM likes WHERE post_id = p.id AND user_email = ?) as is_liked_by_user FROM posts p ORDER BY p.created_at DESC LIMIT 50 `).bind(userEmail).all(); const formattedResults = results?.map((post: any) => ({ ...post, is_liked_by_user: !!post.is_liked_by_user })); return c.json({ success: true, results: formattedResults ?? [] }); } 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 createdAt = new Date().toISOString(); const result = await c.env.DB.prepare( 'INSERT INTO posts (caption, media_url, media_type, created_at, user_email) VALUES (?, ?, ?, ?, ?)' ).bind(body.caption ?? null, body.media_url || null, body.media_type || null, createdAt, user.email || null).run(); if (!result.success) { return c.json({ success: false, msg: 'Failed to create post' }, 500); } const newPost: Post = { id: String(result.meta.last_row_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, username, bio, avatar_url FROM users WHERE username = ?').bind(username).first<{ email: string, username: string, bio: string | null, avatar_url: string | null }>(); if (!user) { return c.json({ success: false, msg: 'User not found' }, 404); } const authHeader = c.req.header('Authorization'); let viewerEmail: string | null = null; if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.split(' ')[1]; try { const origin = getMWorldOrigin(c); const userData = await verifyMAuthToken(token, origin); viewerEmail = userData.email; } catch (e) { // Ignore } } const { results } = await c.env.DB.prepare(` SELECT p.*, (SELECT COUNT(*) FROM likes WHERE post_id = p.id) as likes_count, (SELECT COUNT(*) FROM comments WHERE post_id = p.id) as comments_count, (SELECT 1 FROM likes WHERE post_id = p.id AND user_email = ?) as is_liked_by_user FROM posts p WHERE p.user_email = ? ORDER BY p.created_at DESC `).bind(viewerEmail, user.email).all(); const formattedResults = results?.map((post: any) => ({ ...post, is_liked_by_user: !!post.is_liked_by_user })); return c.json({ success: true, user, results: formattedResults ?? [] }); } catch (e: any) { return c.json({ success: false, msg: 'Failed to fetch user posts', error: e.message }, 500); } }); // PUT /api/users/profile publicRouter.put('/users/profile', authMiddleware, async (c) => { const user = c.get('user'); const body = await c.req.json<{ bio?: string; avatar_url?: string; username?: string }>(); try { if (body.username && body.username !== user.username) { const existing = await c.env.DB.prepare('SELECT email FROM users WHERE username = ? AND email != ?') .bind(body.username, user.email) .first(); if (existing) { return c.json({ success: false, msg: 'Username is already taken' }, 400); } } const newUsername = body.username || user.username; await c.env.DB.prepare('UPDATE users SET bio = ?, avatar_url = ?, username = ? WHERE email = ?') .bind(body.bio ?? null, body.avatar_url ?? null, newUsername, user.email) .run(); return c.json({ success: true, msg: 'Profile updated successfully' }); } catch (e: any) { return c.json({ success: false, msg: 'Failed to update profile', 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 result = await c.env.DB.prepare('INSERT INTO likes (user_email, post_id) VALUES (?, ?)') .bind(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 (!result.success) { const existingLike = await c.env.DB.prepare('SELECT * FROM likes WHERE user_email = ? AND post_id = ?').bind(user.email || null, postId).first(); if(existingLike){ return c.json({ success: true, like: { ...existingLike, id: String(existingLike.id) } }); } return c.json({ success: false, msg: 'Failed to like post' }, 500); } const newLike: Like = { id: String(result.meta.last_row_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(); return c.json({ success: true, like: existingLike }); } return c.json({ success: false, msg: 'Failed to process like', error: e.message }, 500); } }); // GET /api/posts/:id/comments publicRouter.get('/posts/:id/comments', async (c) => { const postId = c.req.param('id'); try { const { results } = await c.env.DB.prepare( 'SELECT c.*, u.username, u.avatar_url FROM comments c JOIN users u ON c.user_email = u.email WHERE c.post_id = ? ORDER BY c.created_at ASC' ).bind(postId).all(); return c.json({ success: true, results: results ?? [] }); } catch (e: any) { return c.json({ success: false, msg: 'Failed to fetch comments', 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 createdAt = new Date().toISOString(); const result = await c.env.DB.prepare( 'INSERT INTO comments (text, created_at, user_email, post_id) VALUES (?, ?, ?, ?)' ).bind(body.content || null, createdAt, user.email || null, postId).run(); if (!result.success) { return c.json({ success: false, msg: 'Failed to create comment' }, 500); } const newComment: Comment = { id: String(result.meta.last_row_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;