first commit

This commit is contained in:
2026-05-29 10:23:25 +03:00
commit e4b25fe4b7
48 changed files with 9112 additions and 0 deletions

1872
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
server/package.json Normal file
View 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
View 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
View 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);

View 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
View 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
View 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;

View 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
View 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"]
}