TiH0 commited on
Commit
948d549
Β·
verified Β·
1 Parent(s): a4d4ed1

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile(1) +16 -0
  2. README.md +4 -6
  3. package.json +19 -0
  4. server.js +544 -0
Dockerfile(1) ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-slim
2
+
3
+ # Install SQLite dependencies
4
+ RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
5
+
6
+ WORKDIR /app
7
+
8
+ COPY package.json ./
9
+ RUN npm install
10
+
11
+ COPY . .
12
+
13
+ # HuggingFace Spaces requires port 7860
14
+ EXPOSE 7860
15
+
16
+ CMD ["node", "server.js"]
README.md CHANGED
@@ -1,10 +1,8 @@
1
  ---
2
- title: Findit Backend
3
- emoji: ⚑
4
- colorFrom: purple
5
  colorTo: indigo
6
  sdk: docker
7
- pinned: false
8
  ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
+ title: FindIt Backend
3
+ emoji: πŸ”
4
+ colorFrom: blue
5
  colorTo: indigo
6
  sdk: docker
7
+ pinned: true
8
  ---
 
 
package.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "findit-backend",
3
+ "version": "1.0.0",
4
+ "description": "FindIt backend β€” Express + SQLite",
5
+ "main": "server.js",
6
+ "scripts": {
7
+ "start": "node server.js"
8
+ },
9
+ "dependencies": {
10
+ "better-sqlite3": "^9.4.3",
11
+ "bcryptjs": "^2.4.3",
12
+ "cors": "^2.8.5",
13
+ "express": "^4.18.2",
14
+ "jsonwebtoken": "^9.0.2",
15
+ "multer": "^1.4.5-lts.1",
16
+ "nodemailer": "^6.9.9",
17
+ "uuid": "^9.0.0"
18
+ }
19
+ }
server.js ADDED
@@ -0,0 +1,544 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ============================================================
2
+ // FindIt Backend β€” Express + SQLite
3
+ // Runs on HuggingFace Spaces (port 7860)
4
+ // ============================================================
5
+
6
+ const express = require('express');
7
+ const cors = require('cors');
8
+ const Database = require('better-sqlite3');
9
+ const jwt = require('jsonwebtoken');
10
+ const { v4: uuid } = require('uuid');
11
+ const nodemailer = require('nodemailer');
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+
15
+ const app = express();
16
+ const PORT = 7860;
17
+
18
+ // ── ENV / CONFIG ──────────────────────────────────────────────────────────────
19
+ // Set these as HuggingFace Space secrets (Settings β†’ Variables and secrets)
20
+ const JWT_SECRET = process.env.JWT_SECRET || 'change_this_to_a_random_string';
21
+ const FRONTEND_URL = process.env.FRONTEND_URL || '*'; // your Cloudflare Pages URL
22
+ const EMAIL_HOST = process.env.EMAIL_HOST || ''; // e.g. smtp.gmail.com
23
+ const EMAIL_PORT = process.env.EMAIL_PORT || '587';
24
+ const EMAIL_USER = process.env.EMAIL_USER || ''; // your email
25
+ const EMAIL_PASS = process.env.EMAIL_PASS || ''; // app password
26
+ const EMAIL_FROM = process.env.EMAIL_FROM || 'FindIt <noreply@findit.app>';
27
+ const SUPER_ADMIN_EMAIL = process.env.SUPER_ADMIN_EMAIL || ''; // your email β€” auto gets super_admin
28
+
29
+ // ── DATABASE ──────────────────────────────────────────────────────────────────
30
+ const DB_PATH = process.env.DB_PATH || '/data/findit.db';
31
+
32
+ // Make sure /data directory exists (HuggingFace persistent storage)
33
+ const dbDir = path.dirname(DB_PATH);
34
+ if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
35
+
36
+ const db = new Database(DB_PATH);
37
+ db.pragma('journal_mode = WAL');
38
+ db.pragma('foreign_keys = ON');
39
+
40
+ // ── SCHEMA ────────────────────────────────────────────────────────────────────
41
+ db.exec(`
42
+ CREATE TABLE IF NOT EXISTS profiles (
43
+ id TEXT PRIMARY KEY,
44
+ email TEXT UNIQUE NOT NULL,
45
+ uid TEXT UNIQUE NOT NULL,
46
+ name TEXT NOT NULL,
47
+ initials TEXT NOT NULL,
48
+ color TEXT NOT NULL DEFAULT '#5b8dff',
49
+ role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('user','admin','super_admin')),
50
+ is_banned INTEGER NOT NULL DEFAULT 0,
51
+ points INTEGER NOT NULL DEFAULT 0,
52
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
53
+ );
54
+
55
+ CREATE TABLE IF NOT EXISTS magic_tokens (
56
+ id TEXT PRIMARY KEY,
57
+ email TEXT NOT NULL,
58
+ token TEXT UNIQUE NOT NULL,
59
+ used INTEGER NOT NULL DEFAULT 0,
60
+ expires_at TEXT NOT NULL,
61
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
62
+ );
63
+
64
+ CREATE TABLE IF NOT EXISTS posts (
65
+ id TEXT PRIMARY KEY,
66
+ author_id TEXT NOT NULL REFERENCES profiles(id),
67
+ title TEXT NOT NULL,
68
+ description TEXT NOT NULL,
69
+ location TEXT NOT NULL,
70
+ category TEXT NOT NULL,
71
+ status TEXT NOT NULL DEFAULT 'found' CHECK(status IN ('found','lost','waiting','recovered')),
72
+ image_url TEXT,
73
+ is_deleted INTEGER NOT NULL DEFAULT 0,
74
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
75
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
76
+ );
77
+
78
+ CREATE TABLE IF NOT EXISTS comments (
79
+ id TEXT PRIMARY KEY,
80
+ post_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
81
+ author_id TEXT NOT NULL REFERENCES profiles(id),
82
+ parent_id TEXT REFERENCES comments(id) ON DELETE CASCADE,
83
+ body TEXT NOT NULL,
84
+ image_url TEXT,
85
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
86
+ );
87
+
88
+ CREATE TABLE IF NOT EXISTS admin_requests (
89
+ id TEXT PRIMARY KEY,
90
+ user_id TEXT NOT NULL REFERENCES profiles(id),
91
+ email TEXT NOT NULL,
92
+ name TEXT NOT NULL,
93
+ role_title TEXT NOT NULL,
94
+ reason TEXT NOT NULL,
95
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected')),
96
+ reviewed_by TEXT REFERENCES profiles(id),
97
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
98
+ reviewed_at TEXT
99
+ );
100
+
101
+ CREATE TABLE IF NOT EXISTS mod_logs (
102
+ id TEXT PRIMARY KEY,
103
+ admin_id TEXT NOT NULL REFERENCES profiles(id),
104
+ target_user TEXT REFERENCES profiles(id),
105
+ target_post TEXT REFERENCES posts(id),
106
+ action TEXT NOT NULL,
107
+ note TEXT,
108
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
109
+ );
110
+ `);
111
+
112
+ // ── MIDDLEWARE ────────────────────────────────────────────────────────────────
113
+ app.use(cors({
114
+ origin: FRONTEND_URL === '*' ? true : FRONTEND_URL.split(',').map(s => s.trim()),
115
+ credentials: true,
116
+ }));
117
+ app.use(express.json({ limit: '10mb' }));
118
+
119
+ // JWT auth middleware
120
+ function auth(req, res, next) {
121
+ const header = req.headers.authorization || '';
122
+ const token = header.replace('Bearer ', '');
123
+ if (!token) return res.status(401).json({ error: 'No token' });
124
+ try {
125
+ req.user = jwt.verify(token, JWT_SECRET);
126
+ // Refresh profile from DB (role/banned may have changed)
127
+ const profile = db.prepare('SELECT * FROM profiles WHERE id = ?').get(req.user.id);
128
+ if (!profile) return res.status(401).json({ error: 'User not found' });
129
+ if (profile.is_banned) return res.status(403).json({ error: 'Account banned' });
130
+ req.profile = profile;
131
+ next();
132
+ } catch {
133
+ return res.status(401).json({ error: 'Invalid token' });
134
+ }
135
+ }
136
+
137
+ function adminOnly(req, res, next) {
138
+ if (!['admin','super_admin'].includes(req.profile.role))
139
+ return res.status(403).json({ error: 'Admin only' });
140
+ next();
141
+ }
142
+
143
+ function superAdminOnly(req, res, next) {
144
+ if (req.profile.role !== 'super_admin')
145
+ return res.status(403).json({ error: 'Super admin only' });
146
+ next();
147
+ }
148
+
149
+ // ── EMAIL ─────────────────────────────────────────────────────────────────────
150
+ let transporter = null;
151
+ if (EMAIL_HOST && EMAIL_USER && EMAIL_PASS) {
152
+ transporter = nodemailer.createTransport({
153
+ host: EMAIL_HOST, port: parseInt(EMAIL_PORT), secure: false,
154
+ auth: { user: EMAIL_USER, pass: EMAIL_PASS },
155
+ });
156
+ }
157
+
158
+ async function sendEmail(to, subject, html) {
159
+ if (!transporter) {
160
+ console.log(`[EMAIL SKIPPED β€” no SMTP config]\nTo: ${to}\nSubject: ${subject}`);
161
+ return true;
162
+ }
163
+ await transporter.sendMail({ from: EMAIL_FROM, to, subject, html });
164
+ return true;
165
+ }
166
+
167
+ // ── HELPERS ───────────────────────────────────────────────────────────────────
168
+ function makeUid(email) {
169
+ return email.split('@')[0].toLowerCase().replace(/[^a-z0-9]/g, '_');
170
+ }
171
+ function makeInitials(name) {
172
+ return name.split(/[\s._@]/).filter(Boolean).slice(0,2).map(w => w[0].toUpperCase()).join('') || name.substring(0,2).toUpperCase();
173
+ }
174
+ const COLORS = ['#5b8dff','#22c97a','#e05a5a','#a084f5','#ffb347','#4da6ff','#e8a838','#ff6b6b'];
175
+ function randomColor() { return COLORS[Math.floor(Math.random()*COLORS.length)]; }
176
+
177
+ function issueJWT(profile) {
178
+ return jwt.sign(
179
+ { id: profile.id, uid: profile.uid, role: profile.role },
180
+ JWT_SECRET,
181
+ { expiresIn: '30d' }
182
+ );
183
+ }
184
+
185
+ // ── ROUTES: HEALTH ────────────────────────────────────────────────────────────
186
+ app.get('/', (req, res) => res.json({ ok: true, service: 'FindIt API', version: '1.0' }));
187
+ app.get('/health', (req, res) => res.json({ ok: true }));
188
+
189
+ // ── ROUTES: AUTH ──────────────────────────────────────────────────────────────
190
+
191
+ // Request magic link
192
+ app.post('/auth/magic', async (req, res) => {
193
+ const { email } = req.body;
194
+ if (!email || !email.includes('@')) return res.status(400).json({ error: 'Invalid email' });
195
+
196
+ const lowerEmail = email.toLowerCase().trim();
197
+
198
+ // Create profile if first time
199
+ let profile = db.prepare('SELECT * FROM profiles WHERE email = ?').get(lowerEmail);
200
+ if (!profile) {
201
+ const id = uuid();
202
+ const uid = makeUid(lowerEmail);
203
+ const name = lowerEmail.split('@')[0];
204
+ const initials = makeInitials(name);
205
+ const color = randomColor();
206
+ // Auto-promote super admin
207
+ const role = (SUPER_ADMIN_EMAIL && lowerEmail === SUPER_ADMIN_EMAIL.toLowerCase()) ? 'super_admin' : 'user';
208
+ db.prepare(`INSERT INTO profiles (id,email,uid,name,initials,color,role) VALUES (?,?,?,?,?,?,?)`)
209
+ .run(id, lowerEmail, uid, name, initials, color, role);
210
+ profile = db.prepare('SELECT * FROM profiles WHERE id = ?').get(id);
211
+ }
212
+
213
+ // Generate token
214
+ const token = uuid() + uuid(); // 72-char random string
215
+ const expires = new Date(Date.now() + 15 * 60 * 1000).toISOString(); // 15 min
216
+ db.prepare(`INSERT INTO magic_tokens (id,email,token,expires_at) VALUES (?,?,?,?)`)
217
+ .run(uuid(), lowerEmail, token, expires);
218
+
219
+ // Build magic link
220
+ const frontendUrl = FRONTEND_URL === '*' ? 'http://localhost:8080' : FRONTEND_URL.split(',')[0].trim();
221
+ const magicLink = `${frontendUrl}?token=${token}`;
222
+
223
+ await sendEmail(lowerEmail, 'Your FindIt sign-in link', `
224
+ <div style="font-family:sans-serif;max-width:400px;margin:0 auto;padding:24px">
225
+ <h2 style="font-size:22px;margin-bottom:8px">Sign in to FindIt</h2>
226
+ <p style="color:#666;margin-bottom:20px">Click the button below to sign in. This link expires in 15 minutes.</p>
227
+ <a href="${magicLink}" style="display:inline-block;background:#5b8dff;color:#fff;padding:12px 24px;border-radius:10px;text-decoration:none;font-weight:600">Sign in to FindIt</a>
228
+ <p style="color:#aaa;font-size:12px;margin-top:20px">If you didn't request this, ignore this email.</p>
229
+ </div>
230
+ `);
231
+
232
+ res.json({ ok: true });
233
+ });
234
+
235
+ // Verify magic link token β†’ return JWT
236
+ app.post('/auth/verify', (req, res) => {
237
+ const { token } = req.body;
238
+ if (!token) return res.status(400).json({ error: 'No token' });
239
+
240
+ const row = db.prepare('SELECT * FROM magic_tokens WHERE token = ? AND used = 0').get(token);
241
+ if (!row) return res.status(400).json({ error: 'Invalid or expired token' });
242
+ if (new Date(row.expires_at) < new Date()) {
243
+ return res.status(400).json({ error: 'Token expired' });
244
+ }
245
+
246
+ // Mark used
247
+ db.prepare('UPDATE magic_tokens SET used = 1 WHERE id = ?').run(row.id);
248
+
249
+ const profile = db.prepare('SELECT * FROM profiles WHERE email = ?').get(row.email);
250
+ if (!profile) return res.status(400).json({ error: 'Profile not found' });
251
+
252
+ const jwt_token = issueJWT(profile);
253
+ res.json({ token: jwt_token, profile });
254
+ });
255
+
256
+ // Get current user
257
+ app.get('/auth/me', auth, (req, res) => {
258
+ res.json({ profile: req.profile });
259
+ });
260
+
261
+ // Sign out (client just discards token β€” stateless JWT)
262
+ app.post('/auth/signout', (req, res) => res.json({ ok: true }));
263
+
264
+ // ── ROUTES: PROFILES ──────────────────────────────────────────────────────────
265
+
266
+ app.get('/profiles', auth, adminOnly, (req, res) => {
267
+ const profiles = db.prepare('SELECT * FROM profiles ORDER BY created_at ASC').all();
268
+ res.json(profiles);
269
+ });
270
+
271
+ app.get('/profiles/:uid', (req, res) => {
272
+ const p = db.prepare('SELECT id,uid,name,initials,color,role,points,created_at FROM profiles WHERE uid = ?').get(req.params.uid);
273
+ if (!p) return res.status(404).json({ error: 'Not found' });
274
+ // post count
275
+ const postCount = db.prepare('SELECT COUNT(*) as c FROM posts WHERE author_id = ? AND is_deleted = 0').get(p.id).c;
276
+ res.json({ ...p, postCount });
277
+ });
278
+
279
+ app.patch('/profiles/:id', auth, (req, res) => {
280
+ const { id } = req.params;
281
+ // Only self or admin can update
282
+ if (req.profile.id !== id && !['admin','super_admin'].includes(req.profile.role))
283
+ return res.status(403).json({ error: 'Forbidden' });
284
+ const allowed = ['name','initials','color'];
285
+ // admins can also change role / is_banned
286
+ if (['admin','super_admin'].includes(req.profile.role)) {
287
+ allowed.push('role','is_banned','points');
288
+ }
289
+ const fields = {};
290
+ for (const k of allowed) if (req.body[k] !== undefined) fields[k] = req.body[k];
291
+ if (!Object.keys(fields).length) return res.status(400).json({ error: 'Nothing to update' });
292
+
293
+ const sets = Object.keys(fields).map(k => `${k} = ?`).join(', ');
294
+ db.prepare(`UPDATE profiles SET ${sets} WHERE id = ?`).run(...Object.values(fields), id);
295
+ res.json({ ok: true });
296
+ });
297
+
298
+ // ── ROUTES: POSTS ─────────────────────────────────────────────────────────────
299
+
300
+ // List posts (with author info + comment count)
301
+ app.get('/posts', (req, res) => {
302
+ const { status, location, search, author_uid } = req.query;
303
+ let sql = `
304
+ SELECT p.*, pr.uid as author_uid, pr.name as author_name,
305
+ pr.initials as author_initials, pr.color as author_color,
306
+ pr.role as author_role,
307
+ (SELECT COUNT(*) FROM comments c WHERE c.post_id = p.id) as comment_count
308
+ FROM posts p
309
+ JOIN profiles pr ON pr.id = p.author_id
310
+ WHERE p.is_deleted = 0`;
311
+ const params = [];
312
+ if (status) { sql += ' AND p.status = ?'; params.push(status); }
313
+ if (location) { sql += ' AND p.location LIKE ?'; params.push('%'+location+'%'); }
314
+ if (author_uid) { sql += ' AND pr.uid = ?'; params.push(author_uid); }
315
+ if (search) { sql += ' AND (p.title LIKE ? OR p.description LIKE ? OR p.location LIKE ?)';
316
+ params.push('%'+search+'%','%'+search+'%','%'+search+'%'); }
317
+ sql += ' ORDER BY p.created_at DESC';
318
+ res.json(db.prepare(sql).all(...params));
319
+ });
320
+
321
+ // Create post
322
+ app.post('/posts', auth, (req, res) => {
323
+ if (req.profile.is_banned) return res.status(403).json({ error: 'Banned' });
324
+ const { title, description, location, category, status, image_url } = req.body;
325
+ if (!title || !description || !location || !category)
326
+ return res.status(400).json({ error: 'Missing required fields' });
327
+ const id = uuid();
328
+ db.prepare(`INSERT INTO posts (id,author_id,title,description,location,category,status,image_url) VALUES (?,?,?,?,?,?,?,?)`)
329
+ .run(id, req.profile.id, title, description, location, category, status||'found', image_url||null);
330
+ // award points
331
+ db.prepare('UPDATE profiles SET points = points + 50 WHERE id = ?').run(req.profile.id);
332
+ res.json(db.prepare('SELECT * FROM posts WHERE id = ?').get(id));
333
+ });
334
+
335
+ // Update post
336
+ app.patch('/posts/:id', auth, (req, res) => {
337
+ const post = db.prepare('SELECT * FROM posts WHERE id = ? AND is_deleted = 0').get(req.params.id);
338
+ if (!post) return res.status(404).json({ error: 'Not found' });
339
+ const isOwner = post.author_id === req.profile.id;
340
+ const isAdmin = ['admin','super_admin'].includes(req.profile.role);
341
+ if (!isOwner && !isAdmin) return res.status(403).json({ error: 'Forbidden' });
342
+
343
+ const allowed = ['title','description','location','category','status','image_url'];
344
+ const fields = {};
345
+ for (const k of allowed) if (req.body[k] !== undefined) fields[k] = req.body[k];
346
+ fields.updated_at = new Date().toISOString();
347
+ const sets = Object.keys(fields).map(k => `${k} = ?`).join(', ');
348
+ db.prepare(`UPDATE posts SET ${sets} WHERE id = ?`).run(...Object.values(fields), req.params.id);
349
+ res.json({ ok: true });
350
+ });
351
+
352
+ // Delete post (soft)
353
+ app.delete('/posts/:id', auth, (req, res) => {
354
+ const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
355
+ if (!post) return res.status(404).json({ error: 'Not found' });
356
+ const isOwner = post.author_id === req.profile.id;
357
+ const isAdmin = ['admin','super_admin'].includes(req.profile.role);
358
+ if (!isOwner && !isAdmin) return res.status(403).json({ error: 'Forbidden' });
359
+ db.prepare('UPDATE posts SET is_deleted = 1 WHERE id = ?').run(req.params.id);
360
+ if (isAdmin && !isOwner) {
361
+ db.prepare(`INSERT INTO mod_logs (id,admin_id,target_post,action,note) VALUES (?,?,?,?,?)`)
362
+ .run(uuid(), req.profile.id, req.params.id, 'delete_post', 'Deleted via admin action');
363
+ }
364
+ res.json({ ok: true });
365
+ });
366
+
367
+ // ── ROUTES: COMMENTS ──────────────────────────────────────────────────────────
368
+
369
+ app.get('/posts/:id/comments', (req, res) => {
370
+ const comments = db.prepare(`
371
+ SELECT c.*, pr.uid as author_uid, pr.name as author_name,
372
+ pr.initials as author_initials, pr.color as author_color
373
+ FROM comments c
374
+ JOIN profiles pr ON pr.id = c.author_id
375
+ WHERE c.post_id = ?
376
+ ORDER BY c.created_at ASC
377
+ `).all(req.params.id);
378
+ res.json(comments);
379
+ });
380
+
381
+ app.post('/posts/:id/comments', auth, (req, res) => {
382
+ if (req.profile.is_banned) return res.status(403).json({ error: 'Banned' });
383
+ const { body, parent_id, image_url } = req.body;
384
+ if (!body?.trim()) return res.status(400).json({ error: 'Empty comment' });
385
+ const id = uuid();
386
+ db.prepare(`INSERT INTO comments (id,post_id,author_id,parent_id,body,image_url) VALUES (?,?,?,?,?,?)`)
387
+ .run(id, req.params.id, req.profile.id, parent_id||null, body, image_url||null);
388
+ db.prepare('UPDATE profiles SET points = points + 10 WHERE id = ?').run(req.profile.id);
389
+ res.json(db.prepare(`
390
+ SELECT c.*, pr.uid as author_uid, pr.name as author_name,
391
+ pr.initials as author_initials, pr.color as author_color
392
+ FROM comments c JOIN profiles pr ON pr.id = c.author_id WHERE c.id = ?
393
+ `).get(id));
394
+ });
395
+
396
+ // ── ROUTES: IMAGE UPLOAD ──────────────────────────────────────────────────────
397
+
398
+ const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
399
+ if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
400
+ app.use('/uploads', express.static(UPLOAD_DIR));
401
+
402
+ app.post('/upload', auth, (req, res) => {
403
+ const { data, filename } = req.body; // base64 data URL
404
+ if (!data) return res.status(400).json({ error: 'No data' });
405
+ try {
406
+ const [meta, b64] = data.split(',');
407
+ const ext = (meta.match(/\/(\w+);/) || ['','jpg'])[1].replace('jpeg','jpg');
408
+ const name = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
409
+ fs.writeFileSync(path.join(UPLOAD_DIR, name), Buffer.from(b64, 'base64'));
410
+ const baseUrl = process.env.SPACE_URL || `http://localhost:${PORT}`;
411
+ res.json({ url: `${baseUrl}/uploads/${name}` });
412
+ } catch(e) {
413
+ console.error('Upload error:', e);
414
+ res.status(500).json({ error: 'Upload failed' });
415
+ }
416
+ });
417
+
418
+ // ── ROUTES: ADMIN REQUESTS ────────────────────────────────────────────────────
419
+
420
+ app.post('/admin-requests', auth, (req, res) => {
421
+ const { name, role_title, reason } = req.body;
422
+ if (!name || !role_title || !reason) return res.status(400).json({ error: 'Missing fields' });
423
+ // Check no duplicate pending
424
+ const existing = db.prepare(`SELECT id FROM admin_requests WHERE user_id = ? AND status = 'pending'`).get(req.profile.id);
425
+ if (existing) return res.status(400).json({ error: 'You already have a pending request' });
426
+ const id = uuid();
427
+ db.prepare(`INSERT INTO admin_requests (id,user_id,email,name,role_title,reason) VALUES (?,?,?,?,?,?)`)
428
+ .run(id, req.profile.id, req.profile.email, name, role_title, reason);
429
+ // Email super admin
430
+ if (SUPER_ADMIN_EMAIL) {
431
+ sendEmail(SUPER_ADMIN_EMAIL, `[FindIt] New admin request from ${name}`,
432
+ `<p><strong>${name}</strong> (${req.profile.email}) has requested admin access.</p>
433
+ <p><strong>Role:</strong> ${role_title}</p>
434
+ <p><strong>Reason:</strong> ${reason}</p>
435
+ <p>Log in to the admin dashboard to review.</p>`
436
+ );
437
+ }
438
+ res.json({ ok: true });
439
+ });
440
+
441
+ app.get('/admin-requests', auth, adminOnly, (req, res) => {
442
+ const rows = db.prepare(`
443
+ SELECT ar.*, pr.uid as requester_uid, pr.role as current_role
444
+ FROM admin_requests ar
445
+ JOIN profiles pr ON pr.id = ar.user_id
446
+ WHERE ar.status = 'pending'
447
+ ORDER BY ar.created_at ASC
448
+ `).all();
449
+ res.json(rows);
450
+ });
451
+
452
+ app.patch('/admin-requests/:id', auth, adminOnly, (req, res) => {
453
+ const { status } = req.body;
454
+ if (!['approved','rejected'].includes(status)) return res.status(400).json({ error: 'Invalid status' });
455
+ const row = db.prepare('SELECT * FROM admin_requests WHERE id = ?').get(req.params.id);
456
+ if (!row) return res.status(404).json({ error: 'Not found' });
457
+ db.prepare(`UPDATE admin_requests SET status=?,reviewed_by=?,reviewed_at=? WHERE id=?`)
458
+ .run(status, req.profile.id, new Date().toISOString(), req.params.id);
459
+ if (status === 'approved') {
460
+ db.prepare(`UPDATE profiles SET role='admin' WHERE id=?`).run(row.user_id);
461
+ db.prepare(`INSERT INTO mod_logs (id,admin_id,target_user,action,note) VALUES (?,?,?,?,?)`)
462
+ .run(uuid(), req.profile.id, row.user_id, 'promote', 'Approved admin request');
463
+ sendEmail(row.email, '[FindIt] Your admin request was approved',
464
+ `<p>Hi ${row.name}, your request for admin access on FindIt has been <strong>approved</strong>.</p>
465
+ <p>Sign in again and you'll see the Admin Dashboard in your menu.</p>`
466
+ );
467
+ } else {
468
+ sendEmail(row.email, '[FindIt] Your admin request was reviewed',
469
+ `<p>Hi ${row.name}, your request for admin access was reviewed and was not approved at this time.</p>`
470
+ );
471
+ }
472
+ res.json({ ok: true });
473
+ });
474
+
475
+ // ── ROUTES: MODERATION ────────────────────────────────────────────────────────
476
+
477
+ app.post('/mod/ban/:userId', auth, adminOnly, (req, res) => {
478
+ db.prepare('UPDATE profiles SET is_banned=1 WHERE id=?').run(req.params.userId);
479
+ db.prepare(`INSERT INTO mod_logs (id,admin_id,target_user,action,note) VALUES (?,?,?,?,?)`)
480
+ .run(uuid(), req.profile.id, req.params.userId, 'ban', req.body.note||'');
481
+ res.json({ ok: true });
482
+ });
483
+
484
+ app.post('/mod/unban/:userId', auth, adminOnly, (req, res) => {
485
+ db.prepare('UPDATE profiles SET is_banned=0 WHERE id=?').run(req.params.userId);
486
+ db.prepare(`INSERT INTO mod_logs (id,admin_id,target_user,action,note) VALUES (?,?,?,?,?)`)
487
+ .run(uuid(), req.profile.id, req.params.userId, 'unban', req.body.note||'');
488
+ res.json({ ok: true });
489
+ });
490
+
491
+ app.post('/mod/role/:userId', auth, superAdminOnly, (req, res) => {
492
+ const { role } = req.body;
493
+ if (!['user','admin','super_admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
494
+ db.prepare('UPDATE profiles SET role=? WHERE id=?').run(role, req.params.userId);
495
+ db.prepare(`INSERT INTO mod_logs (id,admin_id,target_user,action,note) VALUES (?,?,?,?,?)`)
496
+ .run(uuid(), req.profile.id, req.params.userId, role==='user'?'demote':'promote', `Set role to ${role}`);
497
+ res.json({ ok: true });
498
+ });
499
+
500
+ app.post('/mod/alert/:userId', auth, adminOnly, (req, res) => {
501
+ const target = db.prepare('SELECT * FROM profiles WHERE id=?').get(req.params.userId);
502
+ if (!target) return res.status(404).json({ error: 'Not found' });
503
+ db.prepare(`INSERT INTO mod_logs (id,admin_id,target_user,action,note) VALUES (?,?,?,?,?)`)
504
+ .run(uuid(), req.profile.id, req.params.userId, 'alert', req.body.note||'');
505
+ if (target.email) {
506
+ sendEmail(target.email, '[FindIt] A notice from the moderation team',
507
+ `<p>The FindIt moderation team has sent you a notice:</p>
508
+ <blockquote style="border-left:3px solid #ccc;padding-left:12px;color:#555">${req.body.note||'Please review our community guidelines.'}</blockquote>`
509
+ );
510
+ }
511
+ res.json({ ok: true });
512
+ });
513
+
514
+ app.get('/mod/logs', auth, adminOnly, (req, res) => {
515
+ const logs = db.prepare(`
516
+ SELECT ml.*,
517
+ a.uid as admin_uid, a.name as admin_name,
518
+ t.uid as target_uid, t.name as target_name
519
+ FROM mod_logs ml
520
+ JOIN profiles a ON a.id = ml.admin_id
521
+ LEFT JOIN profiles t ON t.id = ml.target_user
522
+ ORDER BY ml.created_at DESC LIMIT 150
523
+ `).all();
524
+ res.json(logs);
525
+ });
526
+
527
+ // ── ROUTES: STATS ─────────────────────────────────────────────────────────────
528
+
529
+ app.get('/stats', auth, adminOnly, (req, res) => {
530
+ const totalPosts = db.prepare('SELECT COUNT(*) as c FROM posts WHERE is_deleted=0').get().c;
531
+ const activePosts = db.prepare("SELECT COUNT(*) as c FROM posts WHERE is_deleted=0 AND status!='recovered'").get().c;
532
+ const totalUsers = db.prepare('SELECT COUNT(*) as c FROM profiles').get().c;
533
+ const bannedUsers = db.prepare('SELECT COUNT(*) as c FROM profiles WHERE is_banned=1').get().c;
534
+ const admins = db.prepare("SELECT COUNT(*) as c FROM profiles WHERE role IN ('admin','super_admin')").get().c;
535
+ const pendingRequests = db.prepare("SELECT COUNT(*) as c FROM admin_requests WHERE status='pending'").get().c;
536
+ res.json({ totalPosts, activePosts, totalUsers, bannedUsers, admins, pendingRequests });
537
+ });
538
+
539
+ // ── START ─────────────────────────────────────────────────────────────────────
540
+ app.listen(PORT, '0.0.0.0', () => {
541
+ console.log(`FindIt backend running on port ${PORT}`);
542
+ console.log(`DB: ${DB_PATH}`);
543
+ console.log(`SUPER_ADMIN_EMAIL: ${SUPER_ADMIN_EMAIL || '(not set)'}`);
544
+ });