feat: generic collections CRUD API endpoint
This commit is contained in:
217
src/pages/api/collections/[table].ts
Normal file
217
src/pages/api/collections/[table].ts
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
/**
|
||||||
|
* Generic Collections API - CRUD for all God Mode tables
|
||||||
|
* GET /api/collections/avatars - List items
|
||||||
|
* GET /api/collections/avatars/[id] - Get single item
|
||||||
|
* POST /api/collections/avatars - Create item
|
||||||
|
* PATCH /api/collections/avatars/[id] - Update item
|
||||||
|
* DELETE /api/collections/avatars/[id] - Delete item
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { APIRoute } from 'astro';
|
||||||
|
import pkg from 'pg';
|
||||||
|
const { Pool } = pkg;
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL
|
||||||
|
});
|
||||||
|
|
||||||
|
// God Mode Token validation
|
||||||
|
function validateGodToken(request: Request): boolean {
|
||||||
|
const token = request.headers.get('X-God-Token') ||
|
||||||
|
request.headers.get('Authorization')?.replace('Bearer ', '') ||
|
||||||
|
new URL(request.url).searchParams.get('token');
|
||||||
|
|
||||||
|
const godToken = process.env.GOD_MODE_TOKEN || import.meta.env.GOD_MODE_TOKEN;
|
||||||
|
|
||||||
|
if (!godToken) {
|
||||||
|
console.warn('⚠️ GOD_MODE_TOKEN not set - backdoor is open!');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return token === godToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed tables (whitelist for security)
|
||||||
|
const ALLOWED_TABLES = [
|
||||||
|
'sites',
|
||||||
|
'posts',
|
||||||
|
'pages',
|
||||||
|
'avatars',
|
||||||
|
'content_blocks',
|
||||||
|
'campaign_masters',
|
||||||
|
'spintax_dictionaries',
|
||||||
|
'spintax_patterns',
|
||||||
|
'generation_jobs',
|
||||||
|
'geo_clusters',
|
||||||
|
'geo_locations'
|
||||||
|
];
|
||||||
|
|
||||||
|
function json(data: any, status = 200) {
|
||||||
|
return new Response(JSON.stringify(data, null, 2), {
|
||||||
|
status,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GET: APIRoute = async ({ params, request, url }) => {
|
||||||
|
if (!validateGodToken(request)) {
|
||||||
|
return json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = params.table;
|
||||||
|
|
||||||
|
if (!table || !ALLOWED_TABLES.includes(table)) {
|
||||||
|
return json({ error: 'Invalid table name' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Pagination
|
||||||
|
const limit = parseInt(url.searchParams.get('limit') || '50');
|
||||||
|
const offset = parseInt(url.searchParams.get('offset') || '0');
|
||||||
|
|
||||||
|
// Sorting
|
||||||
|
const sort = url.searchParams.get('sort') || 'created_at';
|
||||||
|
const order = url.searchParams.get('order') || 'DESC';
|
||||||
|
|
||||||
|
// Search
|
||||||
|
const search = url.searchParams.get('search');
|
||||||
|
|
||||||
|
let query = `SELECT * FROM ${table}`;
|
||||||
|
const queryParams: any[] = [];
|
||||||
|
|
||||||
|
// Add search if provided
|
||||||
|
if (search) {
|
||||||
|
query += ` WHERE name ILIKE $1`;
|
||||||
|
queryParams.push(`%${search}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ` ORDER BY ${sort} ${order} LIMIT $${queryParams.length + 1} OFFSET $${queryParams.length + 2}`;
|
||||||
|
queryParams.push(limit, offset);
|
||||||
|
|
||||||
|
const result = await pool.query(query, queryParams);
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
const countQuery = search
|
||||||
|
? `SELECT COUNT(*) FROM ${table} WHERE name ILIKE $1`
|
||||||
|
: `SELECT COUNT(*) FROM ${table}`;
|
||||||
|
const countParams = search ? [`%${search}%`] : [];
|
||||||
|
const countResult = await pool.query(countQuery, countParams);
|
||||||
|
|
||||||
|
return json({
|
||||||
|
data: result.rows,
|
||||||
|
meta: {
|
||||||
|
total: parseInt(countResult.rows[0].count),
|
||||||
|
limit,
|
||||||
|
offset
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return json({ error: error.message }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST: APIRoute = async ({ params, request }) => {
|
||||||
|
if (!validateGodToken(request)) {
|
||||||
|
return json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = params.table;
|
||||||
|
|
||||||
|
if (!table || !ALLOWED_TABLES.includes(table)) {
|
||||||
|
return json({ error: 'Invalid table name' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Build INSERT query
|
||||||
|
const columns = Object.keys(body);
|
||||||
|
const values = Object.values(body);
|
||||||
|
const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
INSERT INTO ${table} (${columns.join(', ')})
|
||||||
|
VALUES (${placeholders})
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, values);
|
||||||
|
|
||||||
|
return json({ data: result.rows[0] }, 201);
|
||||||
|
} catch (error: any) {
|
||||||
|
return json({ error: error.message }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PATCH: APIRoute = async ({ params, request, url }) => {
|
||||||
|
if (!validateGodToken(request)) {
|
||||||
|
return json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = params.table;
|
||||||
|
const id = url.searchParams.get('id');
|
||||||
|
|
||||||
|
if (!table || !ALLOWED_TABLES.includes(table)) {
|
||||||
|
return json({ error: 'Invalid table name' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return json({ error: 'ID required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
// Build UPDATE query
|
||||||
|
const columns = Object.keys(body);
|
||||||
|
const values = Object.values(body);
|
||||||
|
const setClause = columns.map((col, i) => `${col} = $${i + 1}`).join(', ');
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
UPDATE ${table}
|
||||||
|
SET ${setClause}
|
||||||
|
WHERE id = $${values.length + 1}
|
||||||
|
RETURNING *
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await pool.query(query, [...values, id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return json({ error: 'Not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ data: result.rows[0] });
|
||||||
|
} catch (error: any) {
|
||||||
|
return json({ error: error.message }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELETE: APIRoute = async ({ params, request, url }) => {
|
||||||
|
if (!validateGodToken(request)) {
|
||||||
|
return json({ error: 'Unauthorized' }, 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const table = params.table;
|
||||||
|
const id = url.searchParams.get('id');
|
||||||
|
|
||||||
|
if (!table || !ALLOWED_TABLES.includes(table)) {
|
||||||
|
return json({ error: 'Invalid table name' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
return json({ error: 'ID required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const query = `DELETE FROM ${table} WHERE id = $1 RETURNING id`;
|
||||||
|
const result = await pool.query(query, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return json({ error: 'Not found' }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
return json({ error: error.message }, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user