-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.js
More file actions
462 lines (415 loc) · 17.9 KB
/
index.js
File metadata and controls
462 lines (415 loc) · 17.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const nodemailer = require('nodemailer');
const { db, init } = require('./src/db');
const llm = require('./src/llm');
const PORT = process.env.PORT || 3000;
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
console.error('JWT_SECRET must be set');
process.exit(1);
}
const DEFAULT_STORAGE_LIMIT = parseInt(process.env.DEFAULT_STORAGE_LIMIT || '25', 10);
const DEFAULT_USER_CREDITS = parseInt(process.env.DEFAULT_USER_CREDITS || '20', 10);
const DEFAULT_TEMPERATURE = parseFloat(process.env.DEFAULT_TEMPERATURE || '0.7');
const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL || 'nousresearch/deephermes-3-mistral-24b-preview:free';
const SMTP_HOST = process.env.SMTP_HOST;
const SMTP_PORT = process.env.SMTP_PORT;
const SMTP_USER = process.env.SMTP_USER;
const SMTP_PASS = process.env.SMTP_PASS;
const ADMIN_EMAIL = process.env.ADMIN_EMAIL;
const BOOST_CHECKOUT_URL = process.env.BOOST_CHECKOUT_URL;
const app = express();
app.use(express.json());
app.use(express.static('public'));
const transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
auth: SMTP_USER && SMTP_PASS ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
});
init();
const authMiddleware = async (req, res, next) => {
const auth = req.headers.authorization;
if (!auth) return res.status(401).json({ error: 'token required' });
const token = auth.split(' ')[1];
try {
const decoded = jwt.verify(token, JWT_SECRET);
const user = await get('SELECT id, email FROM users WHERE id=?', [decoded.userId]);
if (!user) throw new Error('not found');
req.user = user;
next();
} catch (err) {
res.status(401).json({ error: 'invalid token' });
}
};
const run = (sql, params=[]) => new Promise((resolve, reject) => {
db.run(sql, params, function(err) {
if (err) reject(err); else resolve(this);
});
});
const get = (sql, params=[]) => new Promise((resolve, reject) => {
db.get(sql, params, (err, row) => {
if (err) reject(err); else resolve(row);
});
});
const all = (sql, params=[]) => new Promise((resolve, reject) => {
db.all(sql, params, (err, rows) => {
if (err) reject(err); else resolve(rows);
});
});
const getUserBlockCount = async (userId) => {
const row = await get(
'SELECT COUNT(*) AS c FROM blocks WHERE story_id IN (SELECT id FROM stories WHERE user_id=?)',
[userId]
);
return row ? row.c : 0;
};
const isStorageFull = async (userId) => {
const user = await get('SELECT storage_limit FROM users WHERE id=?', [userId]);
if (!user || user.storage_limit == null || user.storage_limit < 0) return false;
const count = await getUserBlockCount(userId);
return count >= user.storage_limit;
};
const hasCredits = async (userId) => {
const row = await get('SELECT credits FROM users WHERE id=?', [userId]);
if (!row) return false;
if (row.credits == null || row.credits < 0) return true;
return row.credits > 0;
};
const useCredit = async (userId) => {
const row = await get('SELECT credits FROM users WHERE id=?', [userId]);
if (!row) return false;
if (row.credits == null || row.credits < 0) return true;
if (row.credits <= 0) return false;
await run('UPDATE users SET credits=credits-1 WHERE id=?', [userId]);
return true;
};
const isAdmin = (user) => ADMIN_EMAIL && user && user.email === ADMIN_EMAIL;
const adminMiddleware = (req, res, next) => {
if (!isAdmin(req.user)) return res.status(403).json({ error: 'forbidden' });
next();
};
(async () => {
const adminPassword = process.env.ADMIN_PASSWORD;
if (!ADMIN_EMAIL || !adminPassword) return;
const user = await get('SELECT * FROM users WHERE email=?', [ADMIN_EMAIL]);
if (!user) {
const hashed = await bcrypt.hash(adminPassword, 10);
await run(
'INSERT INTO users(email,password_hash,verified,storage_limit,credits,model,credit_limit,temperature) VALUES(?,?,1,?,?,?,?,?)',
[ADMIN_EMAIL, hashed, -1, -1, OPENROUTER_MODEL, -1, DEFAULT_TEMPERATURE]
);
}
})();
app.post('/api/register', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'username and password required' });
}
const existing = await get('SELECT id FROM users WHERE email=?', [username]);
if (existing) {
return res.status(409).json({ error: 'user exists' });
}
const hashed = await bcrypt.hash(password, 10);
const code = Math.floor(100000 + Math.random() * 900000).toString();
await run(
'INSERT INTO users(email,password_hash,verification_code,storage_limit,credits,model,credit_limit,temperature) VALUES(?,?,?,?,?,?,?,?)',
[username, hashed, code, DEFAULT_STORAGE_LIMIT, DEFAULT_USER_CREDITS, OPENROUTER_MODEL, DEFAULT_USER_CREDITS, DEFAULT_TEMPERATURE]
);
if (SMTP_HOST) {
try {
await transporter.sendMail({
from: `StoryBlocks <${SMTP_USER}>`,
to: username,
subject: 'Your verification code',
text: `Your StoryBlocks verification code is ${code}`,
});
} catch (err) {
console.error('Email error:', err);
}
}
res.status(201).json({ message: 'registered' });
});
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
const user = await get('SELECT * FROM users WHERE email=?', [username]);
if (!user) {
return res.status(401).json({ error: 'invalid credentials' });
}
if (user.locked) {
return res.status(401).json({ error: 'account locked' });
}
const match = await bcrypt.compare(password, user.password_hash);
if (!match) {
return res.status(401).json({ error: 'invalid credentials' });
}
if (!user.verified) {
return res.status(401).json({ error: 'email not verified' });
}
const token = jwt.sign({ userId: user.id, username: user.email }, JWT_SECRET, { expiresIn: '1h' });
res.json({ token });
});
app.post('/api/verify', async (req, res) => {
const { username, code } = req.body;
const user = await get('SELECT * FROM users WHERE email=?', [username]);
if (!user) return res.status(404).json({ error: 'user not found' });
if (user.verified) return res.json({ message: 'already verified' });
if (user.verification_code === code) {
await run('UPDATE users SET verified=1, verification_code=NULL WHERE id=?', [user.id]);
return res.json({ message: 'verified' });
}
res.status(400).json({ error: 'invalid code' });
});
app.get('/api/profile', authMiddleware, async (req, res) => {
const user = await get('SELECT email, created_at, credits, credit_limit, storage_limit, temperature, model FROM users WHERE id=?', [req.user.id]);
if (!user) return res.status(404).json({ error: 'not found' });
const used = await getUserBlockCount(req.user.id);
res.json({
email: user.email,
created_at: user.created_at,
credits: user.credits,
credit_limit: user.credit_limit,
storage_used: used,
storage_limit: user.storage_limit,
temperature: user.temperature,
model: user.model,
is_admin: isAdmin({ email: user.email })
});
});
app.put('/api/profile', authMiddleware, async (req, res) => {
const { model, temperature } = req.body || {};
const updates = [];
const params = [];
if (model != null) {
updates.push('model=?');
params.push(model);
}
if (temperature != null) {
updates.push('temperature=?');
params.push(temperature);
}
if (updates.length) {
params.push(req.user.id);
await run(`UPDATE users SET ${updates.join(', ')} WHERE id=?`, params);
}
res.json({ success: true });
});
app.get('/api/user/storage', authMiddleware, async (req, res) => {
const count = await getUserBlockCount(req.user.id);
const row = await get('SELECT storage_limit FROM users WHERE id=?', [req.user.id]);
res.json({ used: count, limit: row ? row.storage_limit : null });
});
app.get('/api/user/stats', authMiddleware, async (req, res) => {
const row = await get('SELECT credits FROM users WHERE id=?', [req.user.id]);
res.json({ credits: row ? row.credits : 0 });
});
app.get('/api/models', authMiddleware, async (req, res) => {
const models = process.env.OPENROUTER_MODELS
? process.env.OPENROUTER_MODELS.split(',').map(s => s.trim()).filter(Boolean)
: [OPENROUTER_MODEL];
res.json(models);
});
app.get('/api/purchase/checkout', authMiddleware, (req, res) => {
if (!BOOST_CHECKOUT_URL) {
return res.status(500).json({ error: 'checkout not configured' });
}
res.json({ url: BOOST_CHECKOUT_URL });
});
app.post('/api/purchase/boost', authMiddleware, async (req, res) => {
await run('UPDATE users SET storage_limit=?, credits=?, credit_limit=? WHERE id=?', [100, 250, 250, req.user.id]);
res.json({ success: true });
});
// Admin APIs
app.get('/api/admin/users', authMiddleware, adminMiddleware, async (req, res) => {
const users = await all('SELECT id, email, verified, credits, credit_limit, storage_limit, locked FROM users');
for (const u of users) {
u.storage_used = await getUserBlockCount(u.id);
}
res.json(users);
});
app.post('/api/admin/users/:id/verify', authMiddleware, adminMiddleware, async (req, res) => {
await run('UPDATE users SET verified=1 WHERE id=?', [req.params.id]);
res.json({ success: true });
});
app.post('/api/admin/users/:id/reset-password', authMiddleware, adminMiddleware, async (req, res) => {
const pass = Math.random().toString(36).slice(-8);
const hashed = await bcrypt.hash(pass, 10);
await run('UPDATE users SET password_hash=? WHERE id=?', [hashed, req.params.id]);
const user = await get('SELECT email FROM users WHERE id=?', [req.params.id]);
if (user && SMTP_HOST) {
try {
await transporter.sendMail({
from: `StoryBlocks <${SMTP_USER}>`,
to: user.email,
subject: 'Password reset',
text: `Your new password is ${pass}`
});
} catch (err) {
console.error('Email error:', err);
}
}
res.json({ success: true });
});
app.post('/api/admin/users/:id/lock', authMiddleware, adminMiddleware, async (req, res) => {
const { locked } = req.body || {};
await run('UPDATE users SET locked=? WHERE id=?', [locked ? 1 : 0, req.params.id]);
res.json({ success: true });
});
app.post('/api/admin/users/:id/boost', authMiddleware, adminMiddleware, async (req, res) => {
await run('UPDATE users SET storage_limit=?, credits=?, credit_limit=? WHERE id=?', [100, 250, 250, req.params.id]);
res.json({ success: true });
});
// Story CRUD
app.get('/api/stories', authMiddleware, async (req, res) => {
db.all('SELECT *, (SELECT COUNT(*) FROM blocks WHERE story_id=stories.id) AS block_count FROM stories WHERE user_id=?', [req.user.id], (err, rows) => {
if (err) return res.status(500).json({ error: 'db error' });
res.json(rows);
});
});
app.post('/api/stories', authMiddleware, async (req, res) => {
const s = req.body;
await run(`INSERT INTO stories(user_id,title,genre,writing_style,plot_summary,pages_per_block,perspective,interactive_mode) VALUES(?,?,?,?,?,?,?,?)`, [req.user.id, s.title, s.genre, s.writing_style, s.plot_summary, s.pages_per_block, s.perspective, s.interactive_mode ? 1:0]);
const story = await get('SELECT * FROM stories WHERE id=last_insert_rowid()');
res.status(201).json(story);
});
app.get('/api/stories/:id', authMiddleware, async (req,res)=>{
const story = await get('SELECT * FROM stories WHERE id=? AND user_id=?', [req.params.id, req.user.id]);
if (!story) return res.status(404).json({ error: 'not found' });
res.json(story);
});
app.put('/api/stories/:id', authMiddleware, async (req,res)=>{
const s = req.body;
await run(`UPDATE stories SET title=?, genre=?, writing_style=?, plot_summary=?, pages_per_block=?, perspective=?, interactive_mode=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND user_id=?`, [s.title, s.genre, s.writing_style, s.plot_summary, s.pages_per_block, s.perspective, s.interactive_mode ? 1:0, req.params.id, req.user.id]);
const story = await get('SELECT * FROM stories WHERE id=? AND user_id=?', [req.params.id, req.user.id]);
res.json(story);
});
app.delete('/api/stories/:id', authMiddleware, async (req,res)=>{
await run('DELETE FROM blocks WHERE story_id=?', [req.params.id]);
await run('DELETE FROM stories WHERE id=? AND user_id=?', [req.params.id, req.user.id]);
res.json({ success:true });
});
// Blocks
app.get('/api/stories/:id/blocks', authMiddleware, async (req,res)=>{
try {
const blocks = await all('SELECT * FROM blocks WHERE story_id=? ORDER BY position', [req.params.id]);
for (const block of blocks) {
block.choices = await all('SELECT * FROM choices WHERE block_id=? ORDER BY id', [block.id]);
}
res.json(blocks);
} catch (err) {
console.error('blocks load error', err);
res.status(500).json({ error:'db error' });
}
});
app.post('/api/stories/:id/blocks', authMiddleware, async (req,res)=>{
if (await isStorageFull(req.user.id)) {
return res.status(403).json({ error: 'storage limit reached' });
}
const { title, content, position } = req.body;
// determine next position if not provided
let pos = position;
if (pos == null) {
const row = await get('SELECT MAX(position) as p FROM blocks WHERE story_id=?', [req.params.id]);
pos = row && row.p != null ? row.p + 1 : 0;
}
await run('INSERT INTO blocks(story_id,position,title,content) VALUES(?,?,?,?)', [req.params.id, pos, title, content]);
const block = await get('SELECT * FROM blocks WHERE id=last_insert_rowid()');
res.status(201).json(block);
});
app.delete('/api/stories/:id/blocks', authMiddleware, async (req,res)=>{
await run('DELETE FROM blocks WHERE story_id=?', [req.params.id]);
res.json({ success:true });
});
app.get('/api/blocks/:id', authMiddleware, async (req,res)=>{
const block = await get('SELECT * FROM blocks WHERE id=?', [req.params.id]);
if (!block) return res.status(404).json({ error:'not found' });
res.json(block);
});
app.put('/api/blocks/:id', authMiddleware, async (req,res)=>{
const { title, content } = req.body;
await run('UPDATE blocks SET title=?, content=? WHERE id=?', [title, content, req.params.id]);
const block = await get('SELECT * FROM blocks WHERE id=?', [req.params.id]);
res.json(block);
});
app.delete('/api/blocks/:id', authMiddleware, async (req,res)=>{
await run('DELETE FROM choices WHERE block_id=?', [req.params.id]);
await run('DELETE FROM blocks WHERE id=?', [req.params.id]);
res.json({ success:true });
});
app.post('/api/blocks/:id/move', authMiddleware, async (req,res)=>{
const dir = req.body.direction;
const block = await get('SELECT * FROM blocks WHERE id=?', [req.params.id]);
if (!block) return res.status(404).json({ error:'not found' });
const offset = dir === 'up' ? -1 : 1;
const swap = await get('SELECT * FROM blocks WHERE story_id=? AND position=?', [block.story_id, block.position + offset]);
if (!swap) return res.json({ success:false, message:'edge' });
await run('UPDATE blocks SET position=? WHERE id=?', [block.position + offset, swap.id]);
await run('UPDATE blocks SET position=? WHERE id=?', [swap.position, block.id]);
res.json({ success:true });
});
app.post('/api/blocks/:id/generate', authMiddleware, async (req,res)=>{
if (!(await hasCredits(req.user.id))) {
return res.status(403).json({ error: 'out of credits' });
}
try {
const prefs = await get('SELECT model, temperature FROM users WHERE id=?', [req.user.id]);
let content = await llm.generateBlock({ ...(req.body || {}), model: prefs.model, temperature: prefs.temperature });
if (content) {
content = content.replace(/[—–]/g, '');
}
await run('UPDATE blocks SET content=? WHERE id=?', [content, req.params.id]);
await useCredit(req.user.id);
const block = await get('SELECT * FROM blocks WHERE id=?', [req.params.id]);
res.json(block);
} catch (err) {
console.error('LLM error', err);
res.status(500).json({ error: 'generation failed' });
}
});
app.post('/api/blocks/:id/choices', authMiddleware, async (req,res)=>{
const { choice_text, replace } = req.body || {};
if (choice_text) {
await run('INSERT INTO choices(block_id, choice_text) VALUES(?,?)', [req.params.id, choice_text]);
const choice = await get('SELECT * FROM choices WHERE id=last_insert_rowid()');
return res.status(201).json([choice]);
}
try {
if (replace === true || replace === 1 || replace === '1') {
await run('DELETE FROM choices WHERE block_id=?', [req.params.id]);
}
const prefs = await get('SELECT model, temperature FROM users WHERE id=?', [req.user.id]);
const texts = await llm.generateChoices({ ...(req.body || {}), model: prefs.model, temperature: prefs.temperature });
const out = [];
for (const text of texts) {
await run('INSERT INTO choices(block_id, choice_text) VALUES(?,?)', [req.params.id, text]);
const c = await get('SELECT * FROM choices WHERE id=last_insert_rowid()');
out.push(c);
}
res.status(201).json(out);
} catch (err) {
console.error('LLM choices error', err);
res.status(500).json({ error: 'generation failed' });
}
});
app.post('/api/blocks/:id/continue', authMiddleware, async (req,res)=>{
if (await isStorageFull(req.user.id)) {
return res.status(403).json({ error: 'storage limit reached' });
}
const { choice_id } = req.body;
const block = await get('SELECT * FROM blocks WHERE id=?', [req.params.id]);
if (!block) return res.status(404).json({ error:'not found' });
const max = await get('SELECT MAX(position) as p FROM blocks WHERE story_id=?', [block.story_id]);
const pos = max && max.p != null ? max.p + 1 : 0;
await run('INSERT INTO blocks(story_id, position, title, content, from_choice_id) VALUES(?,?,?,?,?)', [block.story_id, pos, null, '', choice_id || null]);
if (choice_id) {
await run('UPDATE choices SET selected=1 WHERE id=?', [choice_id]);
await run('UPDATE choices SET selected=0 WHERE block_id=? AND id<>?', [block.id, choice_id]);
}
const newBlock = await get('SELECT * FROM blocks WHERE id=last_insert_rowid()');
res.status(201).json(newBlock);
});
if (require.main === module) {
app.listen(PORT, () => console.log(`Server on port ${PORT}`));
}
module.exports = app;