refactor(api): refactor API module with route definitions and response utilities
Restructure the core API module to improve clarity, consistency, and maintainability: - Introduce `defineApiRoutes()` helper for declarative route definitions with built-in config validation at startup - Add `apiSuccess()` / `apiError()` response utilities; enforce their use across all handlers (core and modules) - Move auth enforcement to route definitions (`auth: 'public' | 'user' | 'admin'`), removing manual auth checks from handlers - Extract core routes into `core-routes.js`; router now has no knowledge of specific features - Rename `nx-route.js` to `route-handler.js` and update package.json export accordingly - Update ARCHITECTURE.md to reflect new API conventions and point to `src/core/api/README.md` for details
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Auth Feature — API Routes
|
||||
*
|
||||
* User management endpoints (profile, admin CRUD).
|
||||
* Auth is enforced by the router before any handler is called — no manual
|
||||
* session validation is needed here. The validated session is injected via
|
||||
* the context argument: (request, params, { session }).
|
||||
*/
|
||||
|
||||
import { query, updateById } from '@zen/core/database';
|
||||
import { updateUser } from './lib/auth.js';
|
||||
import { uploadImage, deleteFile, generateUniqueFilename, generateUserFilePath, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage';
|
||||
import { fail, info } from '../../shared/lib/logger.js';
|
||||
import { defineApiRoutes } from '../../core/api/define.js';
|
||||
import { apiSuccess, apiError } from '../../core/api/respond.js';
|
||||
|
||||
/** Maximum number of users returned per paginated request */
|
||||
const MAX_PAGE_LIMIT = 100;
|
||||
|
||||
/**
|
||||
* Extension → MIME type map derived from the validated file extension.
|
||||
* The client-supplied file.type is NEVER trusted.
|
||||
*/
|
||||
const EXTENSION_TO_MIME = {
|
||||
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp',
|
||||
};
|
||||
|
||||
/**
|
||||
* Log the raw error server-side and return an opaque fallback.
|
||||
* Never forward internal error details to the client.
|
||||
*/
|
||||
function logAndObscureError(error, fallback) {
|
||||
fail(`Internal handler error: ${error.message}`);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /zen/api/users/me
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleGetCurrentUser(_request, _params, { session }) {
|
||||
return apiSuccess({
|
||||
user: {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
role: session.user.role,
|
||||
image: session.user.image,
|
||||
emailVerified: session.user.email_verified,
|
||||
createdAt: session.user.created_at
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /zen/api/users/:id (admin only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleGetUserById(_request, { id: userId }) {
|
||||
const result = await query(
|
||||
'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return apiError('Not Found', 'User not found');
|
||||
}
|
||||
|
||||
return apiSuccess({ user: result.rows[0] });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /zen/api/users/:id (admin only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleUpdateUserById(request, { id: userId }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const allowedFields = ['name', 'role', 'email_verified'];
|
||||
const updateData = { updated_at: new Date() };
|
||||
|
||||
for (const field of allowedFields) {
|
||||
if (body[field] !== undefined) {
|
||||
if (field === 'email_verified') {
|
||||
updateData[field] = Boolean(body[field]);
|
||||
} else if (field === 'role') {
|
||||
const role = String(body[field]).toLowerCase();
|
||||
if (['admin', 'user'].includes(role)) {
|
||||
updateData[field] = role;
|
||||
}
|
||||
} else if (field === 'name' && body[field] != null) {
|
||||
updateData[field] = String(body[field]).trim() || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateById('zen_auth_users', userId, updateData);
|
||||
if (!updated) {
|
||||
return apiError('Not Found', 'User not found');
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
return apiSuccess({
|
||||
success: true,
|
||||
user: result.rows[0],
|
||||
message: 'User updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
logAndObscureError(error, null);
|
||||
return apiError('Internal Server Error', 'Failed to update user');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /zen/api/users (admin only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleListUsers(request) {
|
||||
// Both page and limit are clamped server-side; client-supplied values
|
||||
// cannot force full-table scans or negative offsets.
|
||||
const url = new URL(request.url);
|
||||
const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10) || 1);
|
||||
const limit = Math.min(
|
||||
Math.max(1, parseInt(url.searchParams.get('limit') || '10', 10) || 10),
|
||||
MAX_PAGE_LIMIT
|
||||
);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const sortBy = url.searchParams.get('sortBy') || 'created_at';
|
||||
const sortOrder = url.searchParams.get('sortOrder') || 'desc';
|
||||
|
||||
// Whitelist allowed sort columns to prevent SQL injection via identifier injection.
|
||||
const allowedSortColumns = ['id', 'email', 'name', 'role', 'email_verified', 'created_at'];
|
||||
const sortColumn = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at';
|
||||
const order = sortOrder.toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
// Wrap the whitelisted column in double-quotes to enforce identifier boundaries.
|
||||
const quotedSortColumn = `"${sortColumn}"`;
|
||||
|
||||
const result = await query(
|
||||
`SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users ORDER BY ${quotedSortColumn} ${order} LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
const countResult = await query('SELECT COUNT(*) FROM zen_auth_users');
|
||||
const total = parseInt(countResult.rows[0].count);
|
||||
|
||||
return apiSuccess({
|
||||
users: result.rows,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) }
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /zen/api/users/profile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleUpdateProfile(request, _params, { session }) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name } = body;
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
return apiError('Bad Request', 'Name is required');
|
||||
}
|
||||
|
||||
const updatedUser = await updateUser(session.user.id, { name: name.trim() });
|
||||
|
||||
return apiSuccess({
|
||||
success: true,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
email: updatedUser.email,
|
||||
name: updatedUser.name,
|
||||
role: updatedUser.role,
|
||||
image: updatedUser.image,
|
||||
email_verified: updatedUser.email_verified,
|
||||
created_at: updatedUser.created_at
|
||||
},
|
||||
message: 'Profile updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
logAndObscureError(error, null);
|
||||
return apiError('Internal Server Error', 'Failed to update profile');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /zen/api/users/profile/picture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleUploadProfilePicture(request, _params, { session }) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file');
|
||||
|
||||
if (!file) {
|
||||
return apiError('Bad Request', 'No file provided');
|
||||
}
|
||||
|
||||
// Read the buffer once — used for both content inspection and the upload.
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// Validate file — passes buffer for magic-byte / content-pattern inspection.
|
||||
const validation = validateUpload({
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
allowedTypes: FILE_TYPE_PRESETS.IMAGES,
|
||||
maxSize: FILE_SIZE_LIMITS.AVATAR,
|
||||
buffer,
|
||||
});
|
||||
|
||||
if (!validation.valid) {
|
||||
return apiError('Bad Request', validation.errors.join(', '));
|
||||
}
|
||||
|
||||
// Check for an existing profile picture to replace later.
|
||||
const currentUser = await query(
|
||||
'SELECT image FROM zen_auth_users WHERE id = $1',
|
||||
[session.user.id]
|
||||
);
|
||||
const oldImageKey = currentUser.rows[0]?.image ?? null;
|
||||
|
||||
// Generate storage path.
|
||||
const uniqueFilename = generateUniqueFilename(file.name, 'avatar');
|
||||
const key = generateUserFilePath(session.user.id, 'profile', uniqueFilename);
|
||||
|
||||
// Derive content-type from the validated extension — never from file.type,
|
||||
// which is fully attacker-controlled.
|
||||
const ext = getFileExtension(file.name).toLowerCase();
|
||||
const contentType = EXTENSION_TO_MIME[ext] ?? 'application/octet-stream';
|
||||
|
||||
const uploadResult = await uploadImage({
|
||||
key,
|
||||
body: buffer,
|
||||
contentType,
|
||||
metadata: { userId: session.user.id, originalName: file.name }
|
||||
});
|
||||
|
||||
if (!uploadResult.success) {
|
||||
return apiError('Internal Server Error', 'Failed to upload image');
|
||||
}
|
||||
|
||||
// Commit the DB write first. Only on success do we remove the old object —
|
||||
// an orphaned old object is preferable to a broken DB reference.
|
||||
let updatedUser;
|
||||
try {
|
||||
updatedUser = await updateUser(session.user.id, { image: key });
|
||||
} catch (dbError) {
|
||||
// Roll back the newly uploaded object so storage stays clean.
|
||||
try {
|
||||
await deleteFile(key);
|
||||
} catch (rollbackError) {
|
||||
fail(`Rollback delete of newly uploaded object failed: ${rollbackError.message}`);
|
||||
}
|
||||
throw dbError;
|
||||
}
|
||||
|
||||
if (oldImageKey) {
|
||||
try {
|
||||
await deleteFile(oldImageKey);
|
||||
info(`Deleted old profile picture: ${oldImageKey}`);
|
||||
} catch (deleteError) {
|
||||
// Non-fatal: log for operator cleanup; the DB reference is consistent.
|
||||
fail(`Failed to delete old profile picture (orphaned object): ${deleteError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
success: true,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
email: updatedUser.email,
|
||||
name: updatedUser.name,
|
||||
role: updatedUser.role,
|
||||
image: updatedUser.image,
|
||||
email_verified: updatedUser.email_verified,
|
||||
created_at: updatedUser.created_at
|
||||
},
|
||||
message: 'Profile picture uploaded successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
logAndObscureError(error, null);
|
||||
return apiError('Internal Server Error', 'Failed to upload profile picture');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /zen/api/users/profile/picture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function handleDeleteProfilePicture(_request, _params, { session }) {
|
||||
try {
|
||||
const currentUser = await query(
|
||||
'SELECT image FROM zen_auth_users WHERE id = $1',
|
||||
[session.user.id]
|
||||
);
|
||||
|
||||
if (currentUser.rows.length === 0) {
|
||||
return apiError('Not Found', 'User not found');
|
||||
}
|
||||
|
||||
const imageKey = currentUser.rows[0].image;
|
||||
if (!imageKey) {
|
||||
return apiError('Bad Request', 'No profile picture to delete');
|
||||
}
|
||||
|
||||
const updatedUser = await updateUser(session.user.id, { image: null });
|
||||
|
||||
try {
|
||||
await deleteFile(imageKey);
|
||||
info(`Deleted profile picture: ${imageKey}`);
|
||||
} catch (deleteError) {
|
||||
// Non-fatal: the DB is already updated; log for operator cleanup.
|
||||
fail(`Failed to delete profile picture from storage: ${deleteError.message}`);
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
success: true,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
email: updatedUser.email,
|
||||
name: updatedUser.name,
|
||||
role: updatedUser.role,
|
||||
image: updatedUser.image,
|
||||
email_verified: updatedUser.email_verified,
|
||||
created_at: updatedUser.created_at
|
||||
},
|
||||
message: 'Profile picture deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
logAndObscureError(error, null);
|
||||
return apiError('Internal Server Error', 'Failed to delete profile picture');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Order matters: specific paths (/users/me, /users/profile) must come before
|
||||
// parameterised paths (/users/:id) so they match first.
|
||||
|
||||
export const routes = defineApiRoutes([
|
||||
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' },
|
||||
{ path: '/users/me', method: 'GET', handler: handleGetCurrentUser, auth: 'user' },
|
||||
{ path: '/users/profile', method: 'PUT', handler: handleUpdateProfile, auth: 'user' },
|
||||
{ path: '/users/profile/picture', method: 'POST', handler: handleUploadProfilePicture, auth: 'user' },
|
||||
{ path: '/users/profile/picture', method: 'DELETE', handler: handleDeleteProfilePicture, auth: 'user' },
|
||||
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin' },
|
||||
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin' },
|
||||
]);
|
||||
Reference in New Issue
Block a user