372 lines
14 KiB
JavaScript
372 lines
14 KiB
JavaScript
const express = require('express');
|
|
const cors = require('cors');
|
|
const { v4: uuid } = require('uuid');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const multer = require('multer');
|
|
const sharp = require('sharp');
|
|
const {
|
|
initDb,
|
|
createUser,
|
|
verifyUser,
|
|
createSession,
|
|
getSession,
|
|
deleteSession,
|
|
upsertItems,
|
|
fetchItemsSince,
|
|
upsertProfile,
|
|
getProfile,
|
|
createShare,
|
|
getSharedPattern,
|
|
patternOwnedByUser,
|
|
listPendingUsers,
|
|
setUserStatus,
|
|
bootstrapAdmin,
|
|
listAllUsers,
|
|
setPassword,
|
|
createResetToken,
|
|
consumeResetToken,
|
|
setUserAdmin
|
|
} = require('./db');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 4000;
|
|
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
|
|
|
|
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
|
|
|
app.use(cors());
|
|
app.use(express.json({ limit: '15mb' }));
|
|
|
|
// Init DB
|
|
initDb().catch((err) => {
|
|
// eslint-disable-next-line no-console
|
|
console.error('DB init failed', err);
|
|
process.exit(1);
|
|
}).then(() => {
|
|
if (process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD) {
|
|
bootstrapAdmin(process.env.ADMIN_EMAIL, process.env.ADMIN_PASSWORD)
|
|
.then(() => console.log('Admin bootstrap ready'))
|
|
.catch((err) => console.error('Admin bootstrap failed', err));
|
|
}
|
|
});
|
|
|
|
const requireAuth = (req, res, next) => {
|
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
|
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
|
getSession(token)
|
|
.then(session => {
|
|
if (!session) return res.status(401).json({ error: 'Unauthorized' });
|
|
req.user = { id: session.user_id, email: session.email, token, is_admin: session.is_admin, status: session.status };
|
|
if (req.user.status !== 'active') return res.status(403).json({ error: 'Account not active', status: req.user.status });
|
|
return next();
|
|
})
|
|
.catch(() => res.status(401).json({ error: 'Unauthorized' }));
|
|
};
|
|
|
|
const requireAdmin = (req, res, next) => {
|
|
if (!req.user?.is_admin) return res.status(403).json({ error: 'Admin only' });
|
|
next();
|
|
};
|
|
|
|
app.get('/api/health', (_req, res) => {
|
|
res.json({ status: 'ok', version: '0.1.0', time: new Date().toISOString() });
|
|
});
|
|
|
|
// Serve static front-end from project root (../)
|
|
const clientDir = path.join(__dirname, '..', '..');
|
|
app.use(express.static(clientDir));
|
|
app.use('/uploads', express.static(UPLOAD_DIR));
|
|
|
|
app.get('/', (_req, res) => {
|
|
res.sendFile(path.join(clientDir, 'index.html'));
|
|
});
|
|
|
|
app.post('/api/signup', (req, res) => {
|
|
const { email, password, displayName = '' } = req.body || {};
|
|
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
|
createUser(email, password)
|
|
.then(async (user) => {
|
|
await upsertProfile(user.id, displayName, '');
|
|
const token = await createSession(user.id);
|
|
return res.json({ token, email: user.email });
|
|
})
|
|
.catch((err) => {
|
|
// eslint-disable-next-line no-console
|
|
console.error(err);
|
|
return res.status(500).json({ error: 'Signup failed' });
|
|
});
|
|
});
|
|
|
|
app.post('/api/login', (req, res) => {
|
|
const { email, password } = req.body || {};
|
|
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
|
|
verifyUser(email, password)
|
|
.then(async (user) => {
|
|
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
|
if (user.pending) return res.status(403).json({ error: 'Account pending approval', status: user.status });
|
|
const token = await createSession(user.id);
|
|
return res.json({ token, email: user.email, is_admin: user.is_admin });
|
|
})
|
|
.catch((err) => {
|
|
// eslint-disable-next-line no-console
|
|
console.error(err);
|
|
return res.status(500).json({ error: 'Login failed' });
|
|
});
|
|
});
|
|
|
|
app.post('/api/logout', requireAuth, (req, res) => {
|
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
|
deleteSession(token).finally(() => res.json({ ok: true }));
|
|
});
|
|
|
|
app.get('/api/sync', requireAuth, async (req, res) => {
|
|
const since = req.query.since;
|
|
try {
|
|
const projects = await fetchItemsSince('projects', req.user.id, since);
|
|
const patterns = await fetchItemsSince('patterns', req.user.id, since);
|
|
res.json({ projects, patterns, since: since || null, serverTime: new Date().toISOString() });
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Sync fetch failed' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/sync', requireAuth, async (req, res) => {
|
|
const { projects = [], patterns = [] } = req.body || {};
|
|
try {
|
|
await upsertItems('projects', req.user.id, projects);
|
|
await upsertItems('patterns', req.user.id, patterns);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Sync failed' });
|
|
}
|
|
});
|
|
|
|
app.get('/api/me', requireAuth, (req, res) => {
|
|
getProfile(req.user.id)
|
|
.then(profile => res.json({ profile: profile || { email: req.user.email, displayName: '', note: '', is_admin: req.user.is_admin, status: req.user.status } }))
|
|
.catch(err => {
|
|
// eslint-disable-next-line no-console
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Profile fetch failed' });
|
|
});
|
|
});
|
|
|
|
app.post('/api/me', requireAuth, (req, res) => {
|
|
const { displayName = '', note = '' } = req.body || {};
|
|
upsertProfile(req.user.id, displayName, note)
|
|
.then(profile => res.json({ profile }))
|
|
.catch(err => {
|
|
// eslint-disable-next-line no-console
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Profile update failed' });
|
|
});
|
|
});
|
|
|
|
app.post('/api/patterns/:id/share', requireAuth, async (req, res) => {
|
|
const patternId = req.params.id;
|
|
const { isPublic = true, expiresAt = null } = req.body || {};
|
|
try {
|
|
const owns = await patternOwnedByUser(patternId, req.user.id);
|
|
if (!owns) return res.status(404).json({ error: 'Pattern not found' });
|
|
const token = uuid();
|
|
await createShare(patternId, token, isPublic, expiresAt);
|
|
res.json({ token, url: `/share/${token}` });
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Share failed' });
|
|
}
|
|
});
|
|
|
|
app.get('/share/:token', async (req, res) => {
|
|
try {
|
|
const shared = await getSharedPattern(req.params.token);
|
|
if (!shared) return res.status(404).json({ error: 'Not found' });
|
|
res.json({ pattern: shared });
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Share fetch failed' });
|
|
}
|
|
});
|
|
|
|
// Upload route (demo): resize to max 1200px, compress, save to /uploads, return URL.
|
|
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 } });
|
|
app.post('/api/upload', requireAuth, upload.single('file'), async (req, res) => {
|
|
try {
|
|
if (!req.file) return res.status(400).json({ error: 'File required' });
|
|
const ext = (req.file.originalname.split('.').pop() || 'jpg').toLowerCase();
|
|
const safeExt = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'avif'].includes(ext) ? ext : 'jpg';
|
|
const filename = `${Date.now()}-${uuid()}.${safeExt}`;
|
|
const outPath = path.join(UPLOAD_DIR, filename);
|
|
|
|
const pipeline = sharp(req.file.buffer)
|
|
.rotate()
|
|
.resize({ width: 1200, height: 1200, fit: 'inside', withoutEnlargement: true });
|
|
|
|
if (safeExt === 'png') {
|
|
pipeline.png({ quality: 80 });
|
|
} else if (safeExt === 'webp') {
|
|
pipeline.webp({ quality: 80 });
|
|
} else if (safeExt === 'avif') {
|
|
pipeline.avif({ quality: 70 });
|
|
} else {
|
|
pipeline.jpeg({ quality: 82, mozjpeg: true });
|
|
}
|
|
|
|
await pipeline.toFile(outPath);
|
|
const url = `/uploads/${filename}`;
|
|
res.json({ url });
|
|
} catch (err) {
|
|
// eslint-disable-next-line no-console
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Upload failed' });
|
|
}
|
|
});
|
|
|
|
// Admin: list pending users
|
|
app.get('/api/admin/users/pending', requireAuth, requireAdmin, async (_req, res) => {
|
|
try {
|
|
const users = await listPendingUsers();
|
|
res.json({ users });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'List pending failed' });
|
|
}
|
|
});
|
|
|
|
// Admin: update user status
|
|
app.post('/api/admin/users/:id/status', requireAuth, requireAdmin, async (req, res) => {
|
|
const { status } = req.body || {};
|
|
if (!['pending', 'active', 'suspended'].includes(status)) return res.status(400).json({ error: 'Invalid status' });
|
|
try {
|
|
await setUserStatus(req.params.id, status);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Update status failed' });
|
|
}
|
|
});
|
|
|
|
// Admin: set/unset admin
|
|
app.post('/api/admin/users/:id/admin', requireAuth, requireAdmin, async (req, res) => {
|
|
const { is_admin } = req.body || {};
|
|
try {
|
|
await setUserAdmin(req.params.id, !!is_admin);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Update admin failed' });
|
|
}
|
|
});
|
|
|
|
// Admin: list all users
|
|
app.get('/api/admin/users', requireAuth, requireAdmin, async (_req, res) => {
|
|
try {
|
|
const users = await listAllUsers();
|
|
res.json({ users });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'List users failed' });
|
|
}
|
|
});
|
|
|
|
// Password reset request (demo: returns token, logs it)
|
|
app.post('/api/password-reset/request', async (req, res) => {
|
|
const { email } = req.body || {};
|
|
if (!email) return res.status(400).json({ error: 'Email required' });
|
|
try {
|
|
const userRow = await listAllUsers().then(users => users.find(u => u.email === email));
|
|
if (!userRow) return res.json({ ok: true }); // don't leak
|
|
const reset = await createResetToken(userRow.id);
|
|
console.log(`Password reset token for ${email}: ${reset.token}`);
|
|
// TODO: send via mail backend
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Reset request failed' });
|
|
}
|
|
});
|
|
|
|
app.post('/api/password-reset/confirm', async (req, res) => {
|
|
const { token, password } = req.body || {};
|
|
if (!token || !password) return res.status(400).json({ error: 'Token and password required' });
|
|
try {
|
|
const userId = await consumeResetToken(token);
|
|
if (!userId) return res.status(400).json({ error: 'Invalid or expired token' });
|
|
await setPassword(userId, password);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Reset failed' });
|
|
}
|
|
});
|
|
|
|
// Admin: backup (JSON export)
|
|
app.get('/api/admin/backup', requireAuth, requireAdmin, async (_req, res) => {
|
|
try {
|
|
const users = await listAllUsers();
|
|
const projects = await fetchItemsSince('projects', null, null);
|
|
const patterns = await fetchItemsSince('patterns', null, null);
|
|
res.json({ users, projects, patterns, exportedAt: new Date().toISOString() });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Backup failed' });
|
|
}
|
|
});
|
|
|
|
// Admin: restore (overwrite)
|
|
app.post('/api/admin/restore', requireAuth, requireAdmin, async (req, res) => {
|
|
const { users = [], projects = [], patterns = [] } = req.body || {};
|
|
try {
|
|
await withClient(async (client) => {
|
|
await client.query('begin');
|
|
await client.query('truncate table sessions cascade');
|
|
await client.query('truncate table users cascade');
|
|
await client.query('truncate table projects cascade');
|
|
await client.query('truncate table patterns cascade');
|
|
for (const u of users) {
|
|
await client.query(
|
|
`insert into users (id, email, password_hash, display_name, note, is_admin, status, created_at, updated_at)
|
|
values ($1,$2,$3,$4,$5,$6,$7,$8,$9)`,
|
|
[u.id, u.email, u.password_hash || '', u.display_name || '', u.note || '', u.is_admin || false, u.status || 'active', u.created_at || new Date(), u.updated_at || new Date()]
|
|
);
|
|
}
|
|
for (const p of projects) {
|
|
await client.query(
|
|
`insert into projects (id, user_id, data, updated_at, deleted_at) values ($1,$2,$3,$4,$5)`,
|
|
[p.id, p.user_id, p.data || {}, p.updated_at || new Date(), p.deleted_at || null]
|
|
);
|
|
}
|
|
for (const p of patterns) {
|
|
await client.query(
|
|
`insert into patterns (id, user_id, title, slug, data, updated_at, deleted_at) values ($1,$2,$3,$4,$5,$6,$7)`,
|
|
[p.id, p.user_id, p.title || '', p.slug || null, p.data || {}, p.updated_at || new Date(), p.deleted_at || null]
|
|
);
|
|
}
|
|
await client.query('commit');
|
|
});
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Restore failed' });
|
|
}
|
|
});
|
|
|
|
app.use((err, _req, res, _next) => {
|
|
// Basic error guard
|
|
// eslint-disable-next-line no-console
|
|
console.error(err);
|
|
res.status(500).json({ error: 'Internal error' });
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
// eslint-disable-next-line no-console
|
|
console.log(`API listening on http://localhost:${PORT}`);
|
|
});
|