Skip to content

Commit 399b632

Browse files
committed
chore: wip.
1 parent f7d8b96 commit 399b632

35 files changed

Lines changed: 1794 additions & 72 deletions

.env.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ DATA_DIR=./data
88
DB_PATH=./data/just-wiki.db
99
MEDIA_DIR=./data/media
1010

11+
# ── Security ──
12+
COOKIE_SECURE=false # Set to true in production with HTTPS
13+
1114
# ── Frontend ──
1215
VITE_API_URL=http://localhost:8000
1316

backend/app/auth.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ async def get_current_user(request: Request):
5858

5959
db = await get_db()
6060
row = await db.execute_fetchall(
61-
"SELECT id, username, role FROM users WHERE id = ?", (user_id,)
61+
"SELECT id, username, role, display_name, email FROM users WHERE id = ?",
62+
(user_id,),
6263
)
6364
if not row:
6465
raise HTTPException(

backend/app/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class Settings(BaseSettings):
1212
MEDIA_DIR: str = "./data/media"
1313

1414
VITE_API_URL: str = "http://localhost:8000"
15+
COOKIE_SECURE: bool = False # Set to True in production with HTTPS
1516

1617
AI_ENABLED: bool = False
1718
GEMINI_API_KEY: str = ""

backend/app/database.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
username TEXT UNIQUE NOT NULL,
1010
password_hash TEXT NOT NULL,
1111
role TEXT NOT NULL DEFAULT 'editor',
12+
display_name TEXT DEFAULT '',
13+
email TEXT DEFAULT '',
1214
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
1315
);
1416
@@ -299,6 +301,15 @@ async def init_db():
299301
await db.execute(statement)
300302
await db.commit()
301303

304+
# Migrate: add new user columns if missing
305+
cols = await db.execute_fetchall("PRAGMA table_info(users)")
306+
col_names = {c["name"] for c in cols}
307+
if "display_name" not in col_names:
308+
await db.execute("ALTER TABLE users ADD COLUMN display_name TEXT DEFAULT ''")
309+
if "email" not in col_names:
310+
await db.execute("ALTER TABLE users ADD COLUMN email TEXT DEFAULT ''")
311+
await db.commit()
312+
302313
# Rebuild search index for existing pages
303314
await rebuild_all_search_indexes(db)
304315

backend/app/main.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,44 @@
1+
import logging
2+
import secrets
13
from contextlib import asynccontextmanager
24
from fastapi import FastAPI
35
from fastapi.middleware.cors import CORSMiddleware
46

7+
from app.config import settings
58
from app.database import init_db, close_db
69
from app.auth import ensure_admin_exists
7-
from app.routers import auth_router, pages, media, templates, search, tags, activity, bookmarks, versions, diagrams
10+
from app.routers import auth_router, pages, media, templates, search, tags, activity, bookmarks, versions, diagrams, users, comments, backup, export
11+
12+
logger = logging.getLogger("justwiki")
13+
14+
_INSECURE_SECRETS = {"change-me-to-random-string", "secret", ""}
15+
16+
17+
def _check_security():
18+
"""Warn loudly about insecure defaults on startup."""
19+
if settings.SECRET_KEY in _INSECURE_SECRETS:
20+
safe_key = secrets.token_urlsafe(32)
21+
logger.critical(
22+
"\n"
23+
"╔══════════════════════════════════════════════════════╗\n"
24+
"║ ⚠ SECRET_KEY is set to an insecure default! ║\n"
25+
"║ Anyone can forge JWT tokens with this key. ║\n"
26+
"║ Set SECRET_KEY in .env to a random value, e.g.: ║\n"
27+
"║ SECRET_KEY=%s ║\n"
28+
"╚══════════════════════════════════════════════════════╝",
29+
safe_key[:44],
30+
)
31+
if settings.ADMIN_PASS in {"admin", "password", "123456", ""}:
32+
logger.warning(
33+
"ADMIN_PASS is set to a weak default ('%s'). "
34+
"Change it in .env before deploying to production.",
35+
settings.ADMIN_PASS,
36+
)
837

938

1039
@asynccontextmanager
1140
async def lifespan(app: FastAPI):
41+
_check_security()
1242
await init_db()
1343
await ensure_admin_exists()
1444
yield
@@ -35,6 +65,10 @@ async def lifespan(app: FastAPI):
3565
app.include_router(bookmarks.router)
3666
app.include_router(versions.router)
3767
app.include_router(diagrams.router)
68+
app.include_router(users.router)
69+
app.include_router(comments.router)
70+
app.include_router(backup.router)
71+
app.include_router(export.router)
3872

3973

4074
@app.get("/api/health")

backend/app/routers/activity.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ async def list_activity(
3030

3131
rows = await db.execute_fetchall(
3232
"""SELECT a.id, a.user_id, a.action, a.target_type, a.target_id, a.metadata, a.created_at,
33-
u.username
33+
u.username, u.display_name
3434
FROM activity_log a
3535
LEFT JOIN users u ON u.id = a.user_id
3636
ORDER BY a.created_at DESC, a.id DESC

backend/app/routers/auth_router.py

Lines changed: 106 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,42 @@
1-
from fastapi import APIRouter, HTTPException, Response, Depends
1+
import time
2+
from collections import defaultdict
3+
from fastapi import APIRouter, HTTPException, Response, Depends, Request
24
from app.schemas import LoginRequest, UserResponse
3-
from app.auth import verify_password, create_token, get_current_user
5+
from typing import Optional
6+
from pydantic import BaseModel
7+
from app.auth import verify_password, create_token, get_current_user, hash_password
8+
from app.config import settings
49
from app.database import get_db
510

611
router = APIRouter(prefix="/api/auth", tags=["auth"])
712

13+
# Simple in-memory rate limiter for login: max 5 attempts per IP per 60 seconds
14+
_login_attempts: dict[str, list[float]] = defaultdict(list)
15+
_RATE_LIMIT_MAX = 5
16+
_RATE_LIMIT_WINDOW = 60 # seconds
17+
18+
19+
def _check_rate_limit(ip: str):
20+
now = time.monotonic()
21+
attempts = _login_attempts[ip]
22+
# Prune old entries
23+
_login_attempts[ip] = [t for t in attempts if now - t < _RATE_LIMIT_WINDOW]
24+
if len(_login_attempts[ip]) >= _RATE_LIMIT_MAX:
25+
raise HTTPException(
26+
status_code=429,
27+
detail="Too many login attempts. Please wait before trying again.",
28+
)
29+
_login_attempts[ip].append(now)
30+
831

932
@router.post("/login")
10-
async def login(body: LoginRequest, response: Response):
33+
async def login(body: LoginRequest, request: Request, response: Response):
34+
client_ip = request.client.host if request.client else "unknown"
35+
_check_rate_limit(client_ip)
36+
1137
db = await get_db()
1238
rows = await db.execute_fetchall(
13-
"SELECT id, username, password_hash, role FROM users WHERE username = ?",
39+
"SELECT id, username, password_hash, role, display_name, email FROM users WHERE username = ?",
1440
(body.username,),
1541
)
1642
if not rows or not verify_password(body.password, rows[0]["password_hash"]):
@@ -22,12 +48,18 @@ async def login(body: LoginRequest, response: Response):
2248
key="token",
2349
value=token,
2450
httponly=True,
51+
secure=settings.COOKIE_SECURE,
2552
samesite="lax",
2653
max_age=86400,
2754
)
2855
return {
29-
"token": token,
30-
"user": {"id": user["id"], "username": user["username"], "role": user["role"]},
56+
"user": {
57+
"id": user["id"],
58+
"username": user["username"],
59+
"role": user["role"],
60+
"display_name": user["display_name"] or "",
61+
"email": user["email"] or "",
62+
},
3163
}
3264

3365

@@ -40,3 +72,71 @@ async def logout(response: Response):
4072
@router.get("/me", response_model=UserResponse)
4173
async def me(user=Depends(get_current_user)):
4274
return user
75+
76+
77+
class ProfileUpdateRequest(BaseModel):
78+
display_name: Optional[str] = None
79+
email: Optional[str] = None
80+
81+
82+
@router.get("/profile")
83+
async def get_profile(user=Depends(get_current_user)):
84+
db = await get_db()
85+
rows = await db.execute_fetchall(
86+
"SELECT id, username, role, display_name, email, created_at FROM users WHERE id = ?",
87+
(user["id"],),
88+
)
89+
return dict(rows[0])
90+
91+
92+
@router.put("/profile")
93+
async def update_profile(body: ProfileUpdateRequest, user=Depends(get_current_user)):
94+
db = await get_db()
95+
updates = []
96+
values = []
97+
if body.display_name is not None:
98+
updates.append("display_name = ?")
99+
values.append(body.display_name)
100+
if body.email is not None:
101+
updates.append("email = ?")
102+
values.append(body.email)
103+
104+
if not updates:
105+
raise HTTPException(status_code=400, detail="No fields to update")
106+
107+
values.append(user["id"])
108+
await db.execute(
109+
f"UPDATE users SET {', '.join(updates)} WHERE id = ?", values
110+
)
111+
await db.commit()
112+
113+
rows = await db.execute_fetchall(
114+
"SELECT id, username, role, display_name, email, created_at FROM users WHERE id = ?",
115+
(user["id"],),
116+
)
117+
return dict(rows[0])
118+
119+
120+
class ChangePasswordRequest(BaseModel):
121+
old_password: str
122+
new_password: str
123+
124+
125+
@router.put("/password")
126+
async def change_password(body: ChangePasswordRequest, user=Depends(get_current_user)):
127+
if len(body.new_password) < 4:
128+
raise HTTPException(status_code=400, detail="New password must be at least 4 characters")
129+
130+
db = await get_db()
131+
rows = await db.execute_fetchall(
132+
"SELECT password_hash FROM users WHERE id = ?", (user["id"],)
133+
)
134+
if not rows or not verify_password(body.old_password, rows[0]["password_hash"]):
135+
raise HTTPException(status_code=400, detail="Current password is incorrect")
136+
137+
await db.execute(
138+
"UPDATE users SET password_hash = ? WHERE id = ?",
139+
(hash_password(body.new_password), user["id"]),
140+
)
141+
await db.commit()
142+
return {"ok": True}

backend/app/routers/backup.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import io
2+
import os
3+
import shutil
4+
import zipfile
5+
from pathlib import Path
6+
7+
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
8+
from fastapi.responses import StreamingResponse
9+
10+
from app.auth import require_admin
11+
from app.config import settings
12+
from app.database import get_db, close_db, init_db
13+
14+
router = APIRouter(prefix="/api/backup", tags=["backup"])
15+
16+
17+
@router.get("")
18+
async def create_backup(user=Depends(require_admin)):
19+
"""Create a .zip backup containing the DB and media files."""
20+
db_path = Path(settings.DB_PATH)
21+
media_dir = Path(settings.MEDIA_DIR)
22+
23+
if not db_path.exists():
24+
raise HTTPException(status_code=500, detail="Database file not found")
25+
26+
# Flush WAL to main db before backup
27+
db = await get_db()
28+
await db.execute("PRAGMA wal_checkpoint(TRUNCATE)")
29+
30+
buf = io.BytesIO()
31+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
32+
# Add database
33+
zf.write(db_path, "just-wiki.db")
34+
35+
# Add media files
36+
if media_dir.exists():
37+
for fpath in media_dir.iterdir():
38+
if fpath.is_file():
39+
zf.write(fpath, f"media/{fpath.name}")
40+
41+
buf.seek(0)
42+
return StreamingResponse(
43+
buf,
44+
media_type="application/zip",
45+
headers={"Content-Disposition": "attachment; filename=just-wiki-backup.zip"},
46+
)
47+
48+
49+
@router.post("/restore")
50+
async def restore_backup(
51+
file: UploadFile = File(...),
52+
user=Depends(require_admin),
53+
):
54+
"""Restore from a .zip backup. Replaces DB and media."""
55+
if not file.filename.endswith(".zip"):
56+
raise HTTPException(status_code=400, detail="File must be a .zip")
57+
58+
content = await file.read()
59+
max_restore_size = 500 * 1024 * 1024 # 500 MB
60+
if len(content) > max_restore_size:
61+
raise HTTPException(status_code=413, detail="Backup file too large (max 500 MB)")
62+
try:
63+
zf = zipfile.ZipFile(io.BytesIO(content))
64+
except zipfile.BadZipFile:
65+
raise HTTPException(status_code=400, detail="Invalid zip file")
66+
67+
names = zf.namelist()
68+
if "just-wiki.db" not in names:
69+
raise HTTPException(status_code=400, detail="Zip must contain just-wiki.db")
70+
71+
# Close current DB connection
72+
await close_db()
73+
74+
db_path = Path(settings.DB_PATH)
75+
media_dir = Path(settings.MEDIA_DIR)
76+
77+
# Write DB file atomically: write to temp first, then rename
78+
tmp_db = db_path.with_suffix(".db.tmp")
79+
with zf.open("just-wiki.db") as src, open(tmp_db, "wb") as dst:
80+
shutil.copyfileobj(src, dst)
81+
tmp_db.replace(db_path)
82+
83+
# Restore media files (with Zip Slip protection)
84+
media_dir.mkdir(parents=True, exist_ok=True)
85+
resolved_media = media_dir.resolve()
86+
for name in names:
87+
if name.startswith("media/") and not name.endswith("/"):
88+
fname = name.split("/", 1)[1]
89+
target = (media_dir / fname).resolve()
90+
# Prevent path traversal: target must stay within media_dir
91+
if not str(target).startswith(str(resolved_media) + "/"):
92+
continue # skip malicious entries silently
93+
target.parent.mkdir(parents=True, exist_ok=True)
94+
with zf.open(name) as src, open(target, "wb") as dst:
95+
shutil.copyfileobj(src, dst)
96+
97+
zf.close()
98+
99+
# Re-initialize DB connection
100+
await init_db()
101+
102+
return {"status": "ok", "message": "Backup restored successfully"}

0 commit comments

Comments
 (0)