Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ build/

# Environment files
.env

# Server runtime files
server/uploads/
server/blebetalo.db
108 changes: 108 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

42 changes: 41 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1 +1,41 @@
{"name": "blebetalo", "version": "1.0.0", "description": "Platform za učenje nemačkog jezika na srpskom", "private": true, "dependencies": {"react": "^18.2.0", "react-dom": "^18.2.0", "react-scripts": "5.0.1", "axios": "^1.6.0"}, "scripts": {"start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject"}, "eslintConfig": {"extends": "react-app"}, "browserslist": {"production": [">0.2%", "not dead", "not op_mini all"], "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"]}, "author": "", "license": "MIT"}
{
"name": "blebetalo",
"version": "1.0.0",
"description": "Platform za u\u010denje nema\u010dkog jezika na srpskom",
"private": true,
"dependencies": {
"axios": "^1.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1"
},
"proxy": "http://localhost:5000",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"server": "node server/index.js",
"dev": "concurrently \"npm run server\" \"npm start\""
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"author": "",
"license": "MIT",
"devDependencies": {
"concurrently": "^9.2.1"
}
}
190 changes: 190 additions & 0 deletions server/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
'use strict';

const path = require('path');
const fs = require('fs');
const express = require('express');
const cors = require('cors');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const multer = require('multer');
const Database = require('better-sqlite3');
const rateLimit = require('express-rate-limit');

// ── Config ──────────────────────────────────────────────────────────────────
const PORT = process.env.PORT || 5000;
const JWT_SECRET = process.env.JWT_SECRET || (() => {
if (process.env.NODE_ENV === 'production') {
throw new Error('JWT_SECRET environment variable must be set in production');
}
console.warn('[WARNING] Using insecure default JWT secret. Set JWT_SECRET in production.');
return 'blebetalo-secret-change-in-prod';
})();
const UPLOADS_DIR = path.join(__dirname, 'uploads');
const DB_PATH = path.join(__dirname, 'blebetalo.db');

// Ensure uploads directory exists
if (!fs.existsSync(UPLOADS_DIR)) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
}

// ── Database ─────────────────────────────────────────────────────────────────
const db = new Database(DB_PATH);

db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'student',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS uploads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
original TEXT NOT NULL,
user_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);

// ── Multer ────────────────────────────────────────────────────────────────────
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOADS_DIR),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e6)}${ext}`);
},
});

const upload = multer({
storage,
limits: { fileSize: 20 * 1024 * 1024 }, // 20 MB
fileFilter: (_req, file, cb) => {
const allowed = /^(image|audio)\//;
if (allowed.test(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only image and audio files are allowed'));
}
},
});

// ── Helpers ───────────────────────────────────────────────────────────────────
function makeToken(user) {
return jwt.sign(
{ id: user.id, username: user.username, role: user.role },
JWT_SECRET,
{ expiresIn: '7d' }
);
}

function authMiddleware(req, res, next) {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: 'Niste prijavljeni.' });
try {
req.user = jwt.verify(token, JWT_SECRET);
next();
} catch {
return res.status(401).json({ error: 'Nevažeći ili istekao token.' });
}
}

// ── App ───────────────────────────────────────────────────────────────────────
const app = express();
app.use(cors());
app.use(express.json());

// Serve uploaded files
app.use('/uploads', express.static(UPLOADS_DIR));

// ── Rate limiters ─────────────────────────────────────────────────────────────
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Previše zahteva. Pokušajte ponovo za 15 minuta.' },
});

const uploadLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Previše zahteva za otpremanje. Pokušajte ponovo za minut.' },
});

// ── Auth routes ───────────────────────────────────────────────────────────────

// Register
app.post('/api/auth/register', authLimiter, (req, res) => {
const { username, password, role = 'student' } = req.body;

if (!username || !password) {
return res.status(400).json({ error: 'Korisničko ime i lozinka su obavezni.' });
}
if (username.trim().length < 3) {
return res.status(400).json({ error: 'Korisničko ime mora imati najmanje 3 karaktera.' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Lozinka mora imati najmanje 6 karaktera.' });
}

const allowedRoles = ['student', 'admin'];
const safeRole = allowedRoles.includes(role) ? role : 'student';

const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username.trim());
if (existing) {
return res.status(409).json({ error: 'Korisničko ime je već zauzeto.' });
}

const hash = bcrypt.hashSync(password, 10);
const info = db.prepare(
'INSERT INTO users (username, password, role) VALUES (?, ?, ?)'
).run(username.trim(), hash, safeRole);

const user = { id: info.lastInsertRowid, username: username.trim(), role: safeRole };
res.status(201).json({ token: makeToken(user), user: { username: user.username, role: user.role } });
});

// Login
app.post('/api/auth/login', authLimiter, (req, res) => {
const { username, password } = req.body;

if (!username || !password) {
return res.status(400).json({ error: 'Korisničko ime i lozinka su obavezni.' });
}

const row = db.prepare('SELECT * FROM users WHERE username = ?').get(username.trim());
if (!row || !bcrypt.compareSync(password, row.password)) {
return res.status(401).json({ error: 'Pogrešno korisničko ime ili lozinka.' });
}

const user = { id: row.id, username: row.username, role: row.role };
res.json({ token: makeToken(user), user: { username: user.username, role: user.role } });
});

// Get current user
app.get('/api/auth/me', authLimiter, authMiddleware, (req, res) => {
const row = db.prepare('SELECT id, username, role FROM users WHERE id = ?').get(req.user.id);
if (!row) return res.status(404).json({ error: 'Korisnik nije pronađen.' });
res.json({ user: { username: row.username, role: row.role } });
});

// ── Upload route ──────────────────────────────────────────────────────────────
app.post('/api/upload', uploadLimiter, authMiddleware, upload.single('file'), (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'Fajl nije priložen.' });
}
db.prepare('INSERT INTO uploads (filename, original, user_id) VALUES (?, ?, ?)')
.run(req.file.filename, req.file.originalname, req.user.id);

const url = `/uploads/${req.file.filename}`;
res.status(201).json({ url });
});

// ── Start ─────────────────────────────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`Blebetalo server running on http://localhost:${PORT}`);
});
Loading