fixed bugs
This commit is contained in:
parent
f64ea867bc
commit
2f9909f73d
@ -1,8 +1,8 @@
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
export const API_URL = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
|
||||
? "http://localhost:8787"
|
||||
export const API_URL = import.meta.env.DEV
|
||||
? `http://${window.location.hostname}:8787`
|
||||
: "https://app-d9wl4z.laflabs-tech.workers.dev";
|
||||
|
||||
/**
|
||||
|
||||
@ -20,7 +20,7 @@ app.use(
|
||||
|
||||
// Optional: Handle pre-flight requests
|
||||
app.options('*', (c) => {
|
||||
return c.text('', 204);
|
||||
return c.text('', 204 as any);
|
||||
});
|
||||
|
||||
// Mount the routes
|
||||
|
||||
@ -17,10 +17,10 @@ adminRouter.get('/dashboard', async (c) => {
|
||||
]);
|
||||
|
||||
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,
|
||||
total_users: (users.results[0] as any)?.count ?? 0,
|
||||
total_posts: (posts.results[0] as any)?.count ?? 0,
|
||||
total_likes: (likes.results[0] as any)?.count ?? 0,
|
||||
total_comments: (comments.results[0] as any)?.count ?? 0,
|
||||
};
|
||||
|
||||
return c.json({ stats });
|
||||
@ -60,7 +60,7 @@ adminRouter.get('/users', async (c) => {
|
||||
countQuery,
|
||||
]);
|
||||
|
||||
const total = countResult[0]?.count ?? 0;
|
||||
const total = (countResult[0] as any)?.count ?? 0;
|
||||
|
||||
return c.json({ users: users ?? [], total });
|
||||
} catch (e: any) {
|
||||
@ -128,7 +128,7 @@ adminRouter.get('/posts', async (c) => {
|
||||
countQuery,
|
||||
]);
|
||||
|
||||
const total = countResult[0]?.count ?? 0;
|
||||
const total = (countResult[0] as any)?.count ?? 0;
|
||||
|
||||
return c.json({ posts: posts ?? [], total });
|
||||
} catch (e: any) {
|
||||
|
||||
@ -1,13 +1,15 @@
|
||||
import { Hono } from 'hono';
|
||||
import { AppContext, authMiddleware } from '../utils';
|
||||
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;
|
||||
profile_picture_url: string | null;
|
||||
avatar_url: string | null;
|
||||
};
|
||||
|
||||
type Post = {
|
||||
@ -45,29 +47,9 @@ publicRouter.post('/user/check-in', async (c) => {
|
||||
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 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>();
|
||||
|
||||
@ -79,27 +61,26 @@ publicRouter.post('/user/check-in', async (c) => {
|
||||
if (!success) {
|
||||
return c.json({ success: false, msg: 'Failed to update user' }, 401);
|
||||
}
|
||||
user = { ...existingUser, fullName: fullName, citizen_id: citizen_id };
|
||||
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 id FROM users WHERE username = ?').bind(username).first();
|
||||
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 id FROM users WHERE username = ?').bind(username).first();
|
||||
isUsernameTaken = await c.env.DB.prepare('SELECT email 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 (?, ?, ?, ?, ?)'
|
||||
'INSERT INTO users (email, username, fullName, citizen_id, bio, avatar_url) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.bind(newUserId, email, username, 'Welcome to my profile!', null)
|
||||
.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 = { id: newUserId, email, username, bio: 'Welcome to my profile!', profile_picture_url: null };
|
||||
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 });
|
||||
@ -175,7 +156,7 @@ publicRouter.get('/geocode', authMiddleware, async (c) => {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return c.json({ success: false, msg: 'Failed to fetch geocoding data' }, response.status);
|
||||
return c.json({ success: false, msg: 'Failed to fetch geocoding data' }, response.status as any);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@ -196,7 +177,7 @@ publicRouter.get('/reverse-geocode', authMiddleware, async (c) => {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return c.json({ success: false, msg: 'Failed to fetch reverse geocoding data' }, response.status);
|
||||
return c.json({ success: false, msg: 'Failed to fetch reverse geocoding data' }, response.status as any);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
@ -208,8 +189,35 @@ publicRouter.get('/reverse-geocode', authMiddleware, async (c) => {
|
||||
// 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 ?? [] });
|
||||
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);
|
||||
}
|
||||
@ -225,18 +233,17 @@ publicRouter.post('/posts', authMiddleware, async (c) => {
|
||||
}
|
||||
|
||||
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();
|
||||
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 (!success) {
|
||||
if (!result.success) {
|
||||
return c.json({ success: false, msg: 'Failed to create post' }, 500);
|
||||
}
|
||||
|
||||
const newPost: Post = {
|
||||
id,
|
||||
id: String(result.meta.last_row_id),
|
||||
caption: body.caption ?? null,
|
||||
media_url: body.media_url,
|
||||
media_type: body.media_type,
|
||||
@ -254,21 +261,71 @@ publicRouter.post('/posts', authMiddleware, async (c) => {
|
||||
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 }>();
|
||||
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 { results } = await c.env.DB.prepare('SELECT * FROM posts WHERE user_email = ? ORDER BY created_at DESC')
|
||||
.bind(user.email || null)
|
||||
.all<Post>();
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({ success: true, results: results ?? [] });
|
||||
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');
|
||||
@ -280,23 +337,22 @@ publicRouter.post('/posts/:id/like', authMiddleware, async (c) => {
|
||||
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)
|
||||
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 (!success) {
|
||||
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 });
|
||||
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, user_email: user.email, post_id: postId };
|
||||
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
|
||||
@ -308,6 +364,19 @@ publicRouter.post('/posts/:id/like', authMiddleware, async (c) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
@ -325,18 +394,17 @@ publicRouter.post('/posts/:id/comments', authMiddleware, async (c) => {
|
||||
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();
|
||||
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 (!success) {
|
||||
if (!result.success) {
|
||||
return c.json({ success: false, msg: 'Failed to create comment' }, 500);
|
||||
}
|
||||
|
||||
const newComment: Comment = {
|
||||
id,
|
||||
id: String(result.meta.last_row_id),
|
||||
content: body.content,
|
||||
created_at: createdAt,
|
||||
user_email: user.email,
|
||||
|
||||
@ -1,168 +0,0 @@
|
||||
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);
|
||||
};
|
||||
@ -43,6 +43,7 @@ export async function verifyMAuthToken(token: string, origin: string): Promise<U
|
||||
if (token === 'admin_token') {
|
||||
return {
|
||||
email: 'admin@mworld.cloud',
|
||||
username: 'admin',
|
||||
fullName: 'Admin User',
|
||||
citizen_id: 'admin-bypass-id',
|
||||
phone: '9999999999',
|
||||
|
||||
2699
frontend/package-lock.json
generated
2699
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -18,7 +18,6 @@
|
||||
"date-fns": "^3.6.0",
|
||||
"framer-motion": "^11.0.20",
|
||||
"lucide-react": "^0.363.0",
|
||||
"mauth-react": "0.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.51.2",
|
||||
@ -29,6 +28,7 @@
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
export const API_URL = (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1')
|
||||
? "http://127.0.0.1:8787"
|
||||
export const API_URL = import.meta.env.DEV
|
||||
? `http://${window.location.hostname}:8787`
|
||||
: "https://app-d9wl4z.laflabs-tech.workers.dev";
|
||||
|
||||
export const api = axios.create({
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { X } from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { api } from '../api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
@ -26,11 +27,20 @@ export default function CommentSheet({ postId, onClose }: CommentSheetProps) {
|
||||
resolver: zodResolver(commentSchema),
|
||||
});
|
||||
|
||||
const { data: comments, isLoading: commentsLoading } = useQuery({
|
||||
queryKey: ['comments', postId],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get(`/api/posts/${postId}/comments`);
|
||||
return data.results || [];
|
||||
}
|
||||
});
|
||||
|
||||
const { mutate: addComment, isPending } = useMutation({
|
||||
mutationFn: (data: CommentFormData) => api.post(`/api/posts/${postId}/comments`, data),
|
||||
onSuccess: () => {
|
||||
toast.success('Comment added!');
|
||||
queryClient.invalidateQueries({ queryKey: ['posts'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['comments', postId] });
|
||||
reset();
|
||||
},
|
||||
onError: (error) => {
|
||||
@ -44,7 +54,7 @@ export default function CommentSheet({ postId, onClose }: CommentSheetProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex justify-center items-end" onClick={onClose}>
|
||||
<div className="fixed inset-0 bg-black/50 z-[60] flex justify-center items-end" onClick={onClose}>
|
||||
<motion.div
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: '0%' }}
|
||||
@ -58,11 +68,30 @@ export default function CommentSheet({ postId, onClose }: CommentSheetProps) {
|
||||
<button onClick={onClose}><X size={24} /></button>
|
||||
</header>
|
||||
<div className="flex-grow p-4 overflow-y-auto">
|
||||
{/* NOTE: API to GET comments is not available in the spec. */}
|
||||
{commentsLoading ? (
|
||||
<div className="flex justify-center py-16"><span className="text-muted animate-pulse">Loading comments...</span></div>
|
||||
) : comments && comments.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{comments.map((comment: any) => (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
<img src={comment.avatar_url || `https://api.dicebear.com/7.x/pixel-art/svg?seed=${comment.user_email}`} alt={comment.username} className="w-8 h-8 rounded-full" />
|
||||
<div>
|
||||
<p className="text-sm">
|
||||
<span className="font-semibold text-ink mr-2">{comment.username}</span>
|
||||
<span className="text-body break-all">{comment.text}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-soft mt-1">
|
||||
{formatDistanceToNow(new Date(comment.created_at), { addSuffix: true })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-muted py-16">
|
||||
<p>Be the first to comment!</p>
|
||||
<p className="text-sm">(Viewing comments is not yet supported)</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-4 border-t border-hairline-soft flex items-center gap-2">
|
||||
<img src={`https://api.dicebear.com/7.x/pixel-art/svg?seed=${user?.email}`} alt="Your avatar" className="w-10 h-10 rounded-full"/>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Heart, MessageCircle, Send, MoreHorizontal } from 'lucide-react';
|
||||
@ -56,6 +57,28 @@ export default function PostCard({ post }: PostCardProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
const shareUrl = `${window.location.origin}/post/${post.id}`;
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: 'Post from InstaClone',
|
||||
text: post.caption || 'Check out this post!',
|
||||
url: shareUrl,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error sharing:', err);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
toast.success('Link copied to clipboard!');
|
||||
} catch (err) {
|
||||
toast.error('Failed to copy link');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const mediaUrl = post.media_url.startsWith('/api/') ? `${API_URL}${post.media_url}` : post.media_url;
|
||||
|
||||
return (
|
||||
@ -85,7 +108,7 @@ export default function PostCard({ post }: PostCardProps) {
|
||||
<button onClick={() => setShowCommentSheet(true)}>
|
||||
<MessageCircle className="text-ink" size={24} />
|
||||
</button>
|
||||
<button>
|
||||
<button onClick={handleShare}>
|
||||
<Send className="text-ink" size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -12,10 +12,9 @@ const fetchPosts = async (): Promise<Post[]> => {
|
||||
username: post.user_email.split('@')[0],
|
||||
avatarUrl: `https://api.dicebear.com/7.x/pixel-art/svg?seed=${post.user_email}`
|
||||
},
|
||||
// Mocking counts as API does not provide them
|
||||
likes_count: Math.floor(Math.random() * 1000),
|
||||
comments_count: Math.floor(Math.random() * 100),
|
||||
is_liked_by_user: false, // Cannot determine from API, so default to false
|
||||
likes_count: post.likes_count ?? 0,
|
||||
comments_count: post.comments_count ?? 0,
|
||||
is_liked_by_user: !!post.is_liked_by_user,
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
@ -1,32 +1,46 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, API_URL } from '../api';
|
||||
import type { Post } from '../types';
|
||||
import { Image, Video } from 'lucide-react';
|
||||
import { Image, Video, Check, X, Camera } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ProfileData {
|
||||
username: string;
|
||||
email: string;
|
||||
posts: Post[];
|
||||
// User details are not available from the specified API endpoint
|
||||
bio?: string;
|
||||
avatarUrl?: string;
|
||||
bio: string | null;
|
||||
avatarUrl: string | null;
|
||||
postCount: number;
|
||||
}
|
||||
|
||||
const fetchProfileData = async (username: string): Promise<ProfileData> => {
|
||||
const { data } = await api.get(`/api/users/${username}/posts`);
|
||||
const user = data.user;
|
||||
const posts: Post[] = data.results || [];
|
||||
return {
|
||||
username,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
posts,
|
||||
avatarUrl: `https://api.dicebear.com/7.x/pixel-art/svg?seed=${username}`,
|
||||
bio: 'Bio is not available from API.',
|
||||
avatarUrl: user.avatar_url || `https://api.dicebear.com/7.x/pixel-art/svg?seed=${user.email}`,
|
||||
bio: user.bio,
|
||||
postCount: posts.length,
|
||||
};
|
||||
};
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { username } = useParams<{ username: string }>();
|
||||
const { user: authUser, updateUser } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editBio, setEditBio] = useState('');
|
||||
const [editAvatarUrl, setEditAvatarUrl] = useState('');
|
||||
const [editUsername, setEditUsername] = useState('');
|
||||
|
||||
const { data: profile, isLoading, error } = useQuery({
|
||||
queryKey: ['profile', username],
|
||||
@ -34,17 +48,120 @@ export default function ProfilePage() {
|
||||
enabled: !!username,
|
||||
});
|
||||
|
||||
const { mutate: updateProfile, isPending: isUpdating } = useMutation({
|
||||
mutationFn: async (data: { bio?: string, avatar_url?: string, username?: string }) => {
|
||||
return api.put('/api/users/profile', data);
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
toast.success('Profile updated!');
|
||||
if (variables.username && variables.username !== username) {
|
||||
if (authUser) {
|
||||
updateUser({ ...authUser, username: variables.username });
|
||||
}
|
||||
navigate(`/profile/${variables.username}`, { replace: true });
|
||||
} else {
|
||||
queryClient.invalidateQueries({ queryKey: ['profile', username] });
|
||||
}
|
||||
setIsEditing(false);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.msg || 'Failed to update profile');
|
||||
}
|
||||
});
|
||||
|
||||
const { mutate: uploadAvatar, isPending: isUploading } = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const { data } = await api.post('/api/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
return data.url;
|
||||
},
|
||||
onSuccess: (url) => {
|
||||
setEditAvatarUrl(url);
|
||||
updateProfile({ avatar_url: url, bio: editBio });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to upload image');
|
||||
}
|
||||
});
|
||||
|
||||
const handleEditClick = () => {
|
||||
if (profile) {
|
||||
setEditBio(profile.bio || '');
|
||||
setEditAvatarUrl(profile.avatarUrl || '');
|
||||
setEditUsername(profile.username || '');
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateProfile({ bio: editBio, avatar_url: editAvatarUrl, username: editUsername });
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
uploadAvatar(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="p-4 text-center">Loading profile...</div>;
|
||||
if (error) return <div className="p-4 text-center text-red-500">Error loading profile.</div>;
|
||||
if (!profile) return <div className="p-4 text-center text-muted">User not found.</div>;
|
||||
|
||||
const isOwnProfile = authUser?.username === profile.username;
|
||||
const displayAvatar = isEditing ? editAvatarUrl : profile.avatarUrl;
|
||||
const absoluteAvatarUrl = displayAvatar?.startsWith('/api/') ? `${API_URL}${displayAvatar}` : displayAvatar;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="p-4 flex flex-col items-center border-b border-hairline-soft">
|
||||
<img src={profile.avatarUrl} alt={profile.username} className="w-24 h-24 rounded-full mb-4 border-2 border-hairline"/>
|
||||
<header className="p-4 flex flex-col items-center border-b border-hairline-soft relative">
|
||||
<div className="relative group cursor-pointer" onClick={() => isOwnProfile && fileInputRef.current?.click()}>
|
||||
<img src={absoluteAvatarUrl || ''} alt={profile.username} className="w-24 h-24 rounded-full mb-4 border-2 border-hairline object-cover"/>
|
||||
{isOwnProfile && (
|
||||
<div className="absolute inset-0 bg-black/40 rounded-full mb-4 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{isUploading ? <span className="text-white text-xs">...</span> : <Camera className="text-white" size={24}/>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input type="file" ref={fileInputRef} className="hidden" accept="image/*" onChange={handleFileChange} />
|
||||
|
||||
<h1 className="text-xl font-bold text-ink">{profile.username}</h1>
|
||||
<p className="text-muted text-sm my-2">{profile.bio}</p>
|
||||
<div className="flex space-x-8 mt-2">
|
||||
|
||||
{isEditing ? (
|
||||
<div className="w-full max-w-xs mt-2 flex flex-col items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editUsername}
|
||||
onChange={(e) => setEditUsername(e.target.value)}
|
||||
className="w-full bg-transparent border border-hairline rounded p-2 text-center focus:outline-none focus:border-primary text-lg font-bold"
|
||||
placeholder="Username"
|
||||
/>
|
||||
<textarea
|
||||
value={editBio}
|
||||
onChange={(e) => setEditBio(e.target.value)}
|
||||
className="w-full bg-transparent border border-hairline rounded p-2 text-center resize-none focus:outline-none focus:border-primary text-sm"
|
||||
rows={2}
|
||||
placeholder="Add a bio..."
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={handleSave} disabled={isUpdating} className="p-2 rounded-full bg-primary/10 text-primary hover:bg-primary/20"><Check size={18}/></button>
|
||||
<button onClick={() => setIsEditing(false)} disabled={isUpdating} className="p-2 rounded-full bg-red-500/10 text-red-500 hover:bg-red-500/20"><X size={18}/></button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-muted text-sm my-2 text-center max-w-xs">{profile.bio}</p>
|
||||
{isOwnProfile && (
|
||||
<button onClick={handleEditClick} className="mt-1 px-4 py-1 rounded bg-surface-soft border border-hairline hover:bg-hairline-soft text-sm font-semibold transition-colors">
|
||||
Edit Profile
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-8 mt-4">
|
||||
<div className="text-center">
|
||||
<p className="font-bold text-ink">{profile.postCount}</p>
|
||||
<p className="text-muted-soft text-sm">posts</p>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user