485 lines
18 KiB
TypeScript
485 lines
18 KiB
TypeScript
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<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);
|
|
}
|
|
|
|
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<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 } 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<Like>();
|
|
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<Like>();
|
|
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; |