first commit
This commit is contained in:
1872
server/package-lock.json
generated
Normal file
1872
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
server/package.json
Normal file
28
server/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "catstagram-server",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"build": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.0",
|
||||
"express": "^4.21.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"sql.js": "^1.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
94
server/src/db.ts
Normal file
94
server/src/db.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import initSqlJs, { Database as SqlJsDatabase } from 'sql.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dbPath = path.join(__dirname, '..', 'data.db');
|
||||
|
||||
let db: SqlJsDatabase;
|
||||
|
||||
export async function initDb(): Promise<SqlJsDatabase> {
|
||||
const SQL = await initSqlJs();
|
||||
|
||||
if (fs.existsSync(dbPath)) {
|
||||
const buffer = fs.readFileSync(dbPath);
|
||||
db = new SQL.Database(buffer);
|
||||
} else {
|
||||
db = new SQL.Database();
|
||||
}
|
||||
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
points INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS cats (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
image_url TEXT NOT NULL,
|
||||
caption TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS likes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
cat_id INTEGER NOT NULL REFERENCES cats(id),
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
UNIQUE(cat_id, user_id)
|
||||
)
|
||||
`);
|
||||
|
||||
saveDb();
|
||||
return db;
|
||||
}
|
||||
|
||||
export function saveDb(): void {
|
||||
const data = db.export();
|
||||
const buffer = Buffer.from(data);
|
||||
fs.writeFileSync(dbPath, buffer);
|
||||
}
|
||||
|
||||
export function getDb(): SqlJsDatabase {
|
||||
return db;
|
||||
}
|
||||
|
||||
export function queryOne(sql: string, params: any[] = []): any {
|
||||
const stmt = db.prepare(sql);
|
||||
stmt.bind(params);
|
||||
const result = stmt.step() ? stmt.getAsObject() : null;
|
||||
stmt.free();
|
||||
return result;
|
||||
}
|
||||
|
||||
export function queryAll(sql: string, params: any[] = []): any[] {
|
||||
const stmt = db.prepare(sql);
|
||||
stmt.bind(params);
|
||||
const results: any[] = [];
|
||||
while (stmt.step()) {
|
||||
results.push(stmt.getAsObject());
|
||||
}
|
||||
stmt.free();
|
||||
return results;
|
||||
}
|
||||
|
||||
export function execute(sql: string, params: any[] = []): { changes: number; lastInsertRowid: number } {
|
||||
const stmt = db.prepare(sql);
|
||||
stmt.bind(params);
|
||||
stmt.step();
|
||||
const lastId = db.exec('SELECT last_insert_rowid() AS id')[0]?.values[0][0] || 0;
|
||||
const changes = db.getRowsModified();
|
||||
stmt.free();
|
||||
saveDb();
|
||||
return { changes, lastInsertRowid: Number(lastId) };
|
||||
}
|
||||
37
server/src/index.ts
Normal file
37
server/src/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { initDb } from './db.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import catRoutes from './routes/cats.js';
|
||||
import likeRoutes from './routes/likes.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function main() {
|
||||
await initDb();
|
||||
|
||||
const app = express();
|
||||
const PORT = parseInt(process.env.PORT || '3040');
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.use('/uploads', express.static(path.join(__dirname, '..', 'uploads')));
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/cats', catRoutes);
|
||||
app.use('/api/likes', likeRoutes);
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Catstagram server running on http://localhost:${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
31
server/src/middleware/auth.ts
Normal file
31
server/src/middleware/auth.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'catstagram-dev-secret-key-change-in-production';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
userId?: number;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export function generateToken(userId: number, username: string): string {
|
||||
return jwt.sign({ userId, username }, JWT_SECRET, { expiresIn: '7d' });
|
||||
}
|
||||
|
||||
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
|
||||
const header = req.headers.authorization;
|
||||
if (!header || !header.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'No token provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = header.split(' ')[1];
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { userId: number; username: string };
|
||||
req.userId = decoded.userId;
|
||||
req.username = decoded.username;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
}
|
||||
58
server/src/routes/auth.ts
Normal file
58
server/src/routes/auth.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { queryOne, execute } from '../db';
|
||||
import { generateToken, authMiddleware, AuthRequest } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/register', (req: Request, res: Response) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username and password required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (username.length < 3 || password.length < 4) {
|
||||
res.status(400).json({ error: 'Username min 3 chars, password min 4 chars' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = queryOne('SELECT id FROM users WHERE username = ?', [username]);
|
||||
if (existing) {
|
||||
res.status(409).json({ error: 'Username already taken' });
|
||||
return;
|
||||
}
|
||||
|
||||
const password_hash = bcrypt.hashSync(password, 10);
|
||||
execute('INSERT INTO users (username, password_hash) VALUES (?, ?)', [username, password_hash]);
|
||||
|
||||
const user = queryOne('SELECT id, username, points FROM users WHERE username = ?', [username]);
|
||||
|
||||
const token = generateToken(user.id, user.username);
|
||||
res.status(201).json({ token, user });
|
||||
});
|
||||
|
||||
router.post('/login', (req: Request, res: Response) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
const user = queryOne('SELECT id, username, password_hash, points FROM users WHERE username = ?', [username]);
|
||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
||||
res.status(401).json({ error: 'Invalid credentials' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = generateToken(user.id, user.username);
|
||||
res.json({ token, user: { id: user.id, username: user.username, points: user.points } });
|
||||
});
|
||||
|
||||
router.get('/me', authMiddleware, (req: AuthRequest, res: Response) => {
|
||||
const user = queryOne('SELECT id, username, points, created_at FROM users WHERE id = ?', [req.userId]);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ user });
|
||||
});
|
||||
|
||||
export default router;
|
||||
133
server/src/routes/cats.ts
Normal file
133
server/src/routes/cats.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Router, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { queryOne, queryAll, execute } from '../db';
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const uploadsDir = path.join(__dirname, '..', '..', 'uploads');
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadsDir),
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowed = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
cb(null, allowed.includes(ext));
|
||||
},
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (req: AuthRequest, res: Response) => {
|
||||
const page = Math.max(1, parseInt(req.query.page as string) || 1);
|
||||
const limit = 12;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const cats = queryAll(
|
||||
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
|
||||
u.username, u.points AS user_points,
|
||||
(SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count
|
||||
FROM cats c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
const countResult = queryOne('SELECT COUNT(*) as count FROM cats');
|
||||
const total = countResult?.count || 0;
|
||||
|
||||
res.json({ cats, total, page, totalPages: Math.ceil(total / limit) });
|
||||
});
|
||||
|
||||
router.get('/user/:userId', (req: AuthRequest, res: Response) => {
|
||||
const userId = parseInt(req.params.userId);
|
||||
const cats = queryAll(
|
||||
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
|
||||
u.username, u.points AS user_points,
|
||||
(SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count
|
||||
FROM cats c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
WHERE c.user_id = ?
|
||||
ORDER BY c.created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
res.json({ cats });
|
||||
});
|
||||
|
||||
router.get('/:id', (req: AuthRequest, res: Response) => {
|
||||
const cat = queryOne(
|
||||
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
|
||||
u.username, u.points AS user_points,
|
||||
(SELECT COUNT(*) FROM likes WHERE cat_id = c.id) AS likes_count
|
||||
FROM cats c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
WHERE c.id = ?`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
if (!cat) {
|
||||
res.status(404).json({ error: 'Cat not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ cat });
|
||||
});
|
||||
|
||||
router.post('/', authMiddleware, upload.single('image'), (req: AuthRequest, res: Response) => {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'Image required (jpg, png, gif, webp, max 10MB)' });
|
||||
return;
|
||||
}
|
||||
|
||||
const caption = (req.body.caption || '').trim();
|
||||
const image_url = `/uploads/${req.file.filename}`;
|
||||
|
||||
execute(
|
||||
'INSERT INTO cats (user_id, image_url, caption) VALUES (?, ?, ?)',
|
||||
[req.userId, image_url, caption]
|
||||
);
|
||||
|
||||
execute('UPDATE users SET points = points + 10 WHERE id = ?', [req.userId]);
|
||||
|
||||
const cat = queryOne(
|
||||
`SELECT c.id, c.image_url, c.caption, c.created_at, c.user_id,
|
||||
u.username, u.points AS user_points,
|
||||
0 AS likes_count
|
||||
FROM cats c
|
||||
JOIN users u ON u.id = c.user_id
|
||||
WHERE c.id = (SELECT MAX(id) FROM cats WHERE user_id = ?)`,
|
||||
[req.userId]
|
||||
);
|
||||
|
||||
res.status(201).json({ cat });
|
||||
});
|
||||
|
||||
router.delete('/:id', authMiddleware, (req: AuthRequest, res: Response) => {
|
||||
const cat = queryOne('SELECT user_id FROM cats WHERE id = ?', [req.params.id]);
|
||||
if (!cat) {
|
||||
res.status(404).json({ error: 'Cat not found' });
|
||||
return;
|
||||
}
|
||||
if (cat.user_id !== req.userId) {
|
||||
res.status(403).json({ error: 'Not your cat' });
|
||||
return;
|
||||
}
|
||||
|
||||
execute('DELETE FROM likes WHERE cat_id = ?', [req.params.id]);
|
||||
execute('DELETE FROM cats WHERE id = ?', [req.params.id]);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
70
server/src/routes/likes.ts
Normal file
70
server/src/routes/likes.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Router, Response } from 'express';
|
||||
import { queryOne, execute } from '../db';
|
||||
import { authMiddleware, AuthRequest } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/:catId', authMiddleware, (req: AuthRequest, res: Response) => {
|
||||
const catId = parseInt(req.params.catId);
|
||||
const userId = req.userId!;
|
||||
|
||||
const cat = queryOne('SELECT user_id FROM cats WHERE id = ?', [catId]);
|
||||
if (!cat) {
|
||||
res.status(404).json({ error: 'Cat not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (cat.user_id === userId) {
|
||||
res.status(400).json({ error: 'Cannot like your own cat' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = queryOne('SELECT id FROM likes WHERE cat_id = ? AND user_id = ?', [catId, userId]);
|
||||
if (existing) {
|
||||
res.status(409).json({ error: 'Already liked' });
|
||||
return;
|
||||
}
|
||||
|
||||
execute('INSERT INTO likes (cat_id, user_id) VALUES (?, ?)', [catId, userId]);
|
||||
execute('UPDATE users SET points = points + 1 WHERE id = ?', [cat.user_id]);
|
||||
|
||||
const countResult = queryOne('SELECT COUNT(*) as count FROM likes WHERE cat_id = ?', [catId]);
|
||||
const owner = queryOne('SELECT points FROM users WHERE id = ?', [cat.user_id]);
|
||||
|
||||
res.json({ liked: true, likesCount: countResult?.count || 0, ownerPoints: owner?.points || 0 });
|
||||
});
|
||||
|
||||
router.delete('/:catId', authMiddleware, (req: AuthRequest, res: Response) => {
|
||||
const catId = parseInt(req.params.catId);
|
||||
const userId = req.userId!;
|
||||
|
||||
const cat = queryOne('SELECT user_id FROM cats WHERE id = ?', [catId]);
|
||||
if (!cat) {
|
||||
res.status(404).json({ error: 'Cat not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = queryOne('SELECT id FROM likes WHERE cat_id = ? AND user_id = ?', [catId, userId]);
|
||||
if (!existing) {
|
||||
res.status(404).json({ error: 'Not liked yet' });
|
||||
return;
|
||||
}
|
||||
|
||||
execute('DELETE FROM likes WHERE id = ?', [existing.id]);
|
||||
execute('UPDATE users SET points = MAX(0, points - 1) WHERE id = ?', [cat.user_id]);
|
||||
|
||||
const countResult = queryOne('SELECT COUNT(*) as count FROM likes WHERE cat_id = ?', [catId]);
|
||||
const owner = queryOne('SELECT points FROM users WHERE id = ?', [cat.user_id]);
|
||||
|
||||
res.json({ liked: false, likesCount: countResult?.count || 0, ownerPoints: owner?.points || 0 });
|
||||
});
|
||||
|
||||
router.get('/:catId/status', authMiddleware, (req: AuthRequest, res: Response) => {
|
||||
const catId = parseInt(req.params.catId);
|
||||
const userId = req.userId!;
|
||||
|
||||
const liked = queryOne('SELECT id FROM likes WHERE cat_id = ? AND user_id = ?', [catId, userId]);
|
||||
res.json({ liked: !!liked });
|
||||
});
|
||||
|
||||
export default router;
|
||||
15
server/tsconfig.json
Normal file
15
server/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user