From 64cfd35417cc068efdffbbde80991eb28051a879 Mon Sep 17 00:00:00 2001 From: micraj Date: Wed, 3 Sep 2025 17:11:14 +0100 Subject: [PATCH 1/8] Clean up, formatting and isort --- backend/app/api/data.py | 12 +++++------- backend/app/core/database.py | 2 +- backend/app/main.py | 16 ++++++++-------- backend/app/models/blog_post.py | 7 ++++--- backend/app/models/users.py | 15 +++++++++++++++ frontend/src/routes/+page.svelte | 4 ---- 6 files changed, 33 insertions(+), 23 deletions(-) create mode 100644 backend/app/models/users.py diff --git a/backend/app/api/data.py b/backend/app/api/data.py index bfb622e..c36c993 100644 --- a/backend/app/api/data.py +++ b/backend/app/api/data.py @@ -1,17 +1,15 @@ -from fastapi import APIRouter, File, UploadFile, Request, HTTPException - import datetime +import json +import re import shutil import time -import re - -import json +from typing import List, Optional +from fastapi import APIRouter, File, HTTPException, Request, UploadFile from sqlmodel import Session, select, text -from app.models.blog_post import BlogPost, BlogPostCreate, BlogPostUpdate from app.core.database import SessionDep -from typing import List, Optional +from app.models.blog_post import BlogPost, BlogPostCreate, BlogPostUpdate router = APIRouter() diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 78972ca..6ee2820 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -19,4 +19,4 @@ def get_session(): yield session -SessionDep = Annotated[Session, Depends(get_session)] \ No newline at end of file +SessionDep = Annotated[Session, Depends(get_session)] diff --git a/backend/app/main.py b/backend/app/main.py index 0b7055f..9dfb2e2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,9 +1,11 @@ +from contextlib import asynccontextmanager from typing import Union + from fastapi import FastAPI -from app.core.database import create_db_and_tables, SessionDep -from app.api.data import router as data_router from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager + +from app.api.data import router as data_router +from app.core.database import SessionDep, create_db_and_tables @asynccontextmanager @@ -11,14 +13,13 @@ async def lifespan(app: FastAPI): create_db_and_tables() yield + app = FastAPI(lifespan=lifespan) app.include_router(data_router) # Allowing CORS from the specific origin (localhost:5173) or any origin -origins = [ - "*" # Or you can use localhost and 127.0.0.1 as the origin -] +origins = ["*"] # Or you can use localhost and 127.0.0.1 as the origin # Adding CORSMiddleware app.add_middleware( @@ -29,8 +30,7 @@ async def lifespan(app: FastAPI): allow_headers=["*"], # Allow all headers ) + @app.get("/") def read_root(): return {"Hello": "World"} - - diff --git a/backend/app/models/blog_post.py b/backend/app/models/blog_post.py index 5bf84a3..177d505 100644 --- a/backend/app/models/blog_post.py +++ b/backend/app/models/blog_post.py @@ -1,8 +1,9 @@ -from typing import Optional +import time from datetime import datetime -from sqlmodel import SQLModel, Field +from typing import Optional + from pydantic import BaseModel -import time +from sqlmodel import Field, SQLModel class BlogPost(SQLModel, table=True): diff --git a/backend/app/models/users.py b/backend/app/models/users.py new file mode 100644 index 0000000..2e4fadd --- /dev/null +++ b/backend/app/models/users.py @@ -0,0 +1,15 @@ +from typing import Optional + +from pydantic import BaseModel +from sqlmodel import Field, SQLModel + + +class Users(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + username: str = Field(index=True, unique=True) + hashed_password: str + + +class UserCreate(BaseModel): + username: str + password: str diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index bf1782a..fd32530 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -29,7 +29,6 @@ const waveEl = document.querySelector('.hand'); if (waveEl) { waveEl.classList.remove('wave'); - void waveEl.offsetWidth; // trigger reflow waveEl.classList.add('wave'); } } else { @@ -73,10 +72,7 @@

Projects


- - -
From 24676d506862cf3c049d1bb5fcabf63ecbb4e0ee Mon Sep 17 00:00:00 2001 From: micraj Date: Wed, 3 Sep 2025 18:32:09 +0100 Subject: [PATCH 2/8] basic login api --- backend/app/api/{data.py => posts.py} | 0 backend/app/api/users.py | 130 +++++++++++++++++++++++ backend/app/main.py | 2 +- backend/app/models/session.py | 11 ++ backend/app/models/{users.py => user.py} | 7 +- 5 files changed, 148 insertions(+), 2 deletions(-) rename backend/app/api/{data.py => posts.py} (100%) create mode 100644 backend/app/api/users.py create mode 100644 backend/app/models/session.py rename backend/app/models/{users.py => user.py} (75%) diff --git a/backend/app/api/data.py b/backend/app/api/posts.py similarity index 100% rename from backend/app/api/data.py rename to backend/app/api/posts.py diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 0000000..c377e99 --- /dev/null +++ b/backend/app/api/users.py @@ -0,0 +1,130 @@ +import datetime +import json +import re +import shutil +import time +from typing import List, Optional + +from fastapi import ( + APIRouter, + Depends, + File, + HTTPException, + Request, + UploadFile, + Response, + status, + Cookie, +) +from sqlmodel import Session, select, text + +from app.core.database import SessionDep +from app.models.blog_post import BlogPost, BlogPostCreate, BlogPostUpdate +from backend.app.models.user import User, UserCreate, UserLogin +from backend.app.models.session import SessionModel +from backend.app.core.database import get_session +from itsdangerous import TimestampSigner + + +SECRET_KEY = "super-secret" # load from env in real app +signer = TimestampSigner(SECRET_KEY) + +router = APIRouter() + + +def hash_password(password: str) -> str: + return password # Replace with actual hashing logic + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return plain_password == hashed_password # Replace with actual verification logic + + +def get_current_user( + session_id: str = Cookie(None), db: SessionDep = Depends(get_session) +): + if not session_id: + raise HTTPException(status_code=401, detail="Not logged in") + + try: + unsigned_id = signer.unsign(session_id, max_age=86400).decode() + except Exception: + raise HTTPException(status_code=401, detail="Invalid or expired session") + + session_obj = db.get(SessionModel, unsigned_id) + if not session_obj or session_obj.expires_at < time.time(): + raise HTTPException(status_code=401, detail="Session expired") + + user = db.get(User, session_obj.user_id) + if not user: + raise HTTPException(status_code=401, detail="User not found") + + return user + + +def cleanup_expired_sessions(db: Session): + now = int(time.time()) + expired_sessions = db.exec( + select(SessionModel).where(SessionModel.expires_at < now) + ).all() + + for session in expired_sessions: + db.delete(session) + + db.commit() + + return len(expired_sessions) + + +@router.post("/users/", response_model=User, status_code=status.HTTP_201_CREATED) +def create_user(user: UserCreate, db: SessionDep): + existing_user = db.exec(select(User).where(User.email == user.email)).first() + if existing_user: + raise HTTPException(status_code=400, detail="Email already registered") + new_user = User( + username=user.username, + hashed_password=hash_password(user.password), + ) + db.add(new_user) + db.commit() + db.refresh(new_user) + return new_user + + +@router.post("/login/") +def login_attempt(login: UserLogin, response: Response, db: SessionDep): + user = db.exec(select(User).where(User.email == login.email)).first() + if not user or not verify_password(login.password, user.hashed_password): + raise HTTPException(status_code=401, detail="Invalid credentials") + + session = SessionModel(user_id=user.id) + db.add(session) + db.commit() + db.refresh(session) + + # Check for existing session + existing_session = db.exec( + select(SessionModel).where(SessionModel.user_id == user.id) + ).first() + + if existing_session: + db.delete(existing_session) + db.commit() + + # Sign the session ID and set as HttpOnly cookie + signed_session_id = signer.sign(session.id).decode() + response.set_cookie( + "session_id", + signed_session_id, + httponly=True, + secure=True, + samesite="lax", + max_age=86400, # 1 day + ) + + return {"message": "Login successful"} + + +@router.get("/me") +def read_me(user=Depends(get_current_user)): + return {"id": user.id, "Username": user.username} diff --git a/backend/app/main.py b/backend/app/main.py index 9dfb2e2..85300cc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api.data import router as data_router +from backend.app.api.posts import router as data_router from app.core.database import SessionDep, create_db_and_tables diff --git a/backend/app/models/session.py b/backend/app/models/session.py new file mode 100644 index 0000000..89ac651 --- /dev/null +++ b/backend/app/models/session.py @@ -0,0 +1,11 @@ +import time, secrets +from sqlmodel import SQLModel, Field + + +class SessionModel(SQLModel, table=True): + id: str = Field(default_factory=lambda: secrets.token_hex(16), primary_key=True) + user_id: int + created_at: int = Field(default_factory=lambda: int(time.time())) + expires_at: int = Field( + default_factory=lambda: int(time.time()) + 60 * 60 * 24 + ) # 1 day diff --git a/backend/app/models/users.py b/backend/app/models/user.py similarity index 75% rename from backend/app/models/users.py rename to backend/app/models/user.py index 2e4fadd..69e55de 100644 --- a/backend/app/models/users.py +++ b/backend/app/models/user.py @@ -4,7 +4,7 @@ from sqlmodel import Field, SQLModel -class Users(SQLModel, table=True): +class User(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) username: str = Field(index=True, unique=True) hashed_password: str @@ -13,3 +13,8 @@ class Users(SQLModel, table=True): class UserCreate(BaseModel): username: str password: str + + +class UserLogin(BaseModel): + username: str + password: str From 62ba9b3a880475886a720f4ec148da3f7fbbf760 Mon Sep 17 00:00:00 2001 From: micraj Date: Fri, 5 Sep 2025 15:13:12 +0100 Subject: [PATCH 3/8] Refactor to put frontend and backend on the same port, and add basic login logic --- backend/Dockerfile | 4 +- backend/app/api/users.py | 52 +++++++---- backend/app/main.py | 8 +- backend/requirements.txt | Bin 1362 -> 881 bytes docker-compose.yml | 8 +- frontend/src/routes/login/+page.svelte | 91 +++++++++++++++++++ frontend/src/routes/post-editor/+page.svelte | 2 + frontend/src/routes/post-editor/+page.ts | 14 +++ nginx/nginx.conf | 2 +- 9 files changed, 152 insertions(+), 29 deletions(-) create mode 100644 frontend/src/routes/login/+page.svelte create mode 100644 frontend/src/routes/post-editor/+page.ts diff --git a/backend/Dockerfile b/backend/Dockerfile index 02b0839..00770d0 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -6,6 +6,6 @@ COPY ./requirements.txt /code/requirements.txt RUN pip install -r requirements.txt COPY ./app /code/app -EXPOSE 8000 +EXPOSE 3000 -CMD ["fastapi", "run", "app/main.py", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file +CMD ["fastapi", "run", "app/main.py", "--host", "0.0.0.0", "--port", "3000"] \ No newline at end of file diff --git a/backend/app/api/users.py b/backend/app/api/users.py index c377e99..369ed08 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -20,9 +20,9 @@ from app.core.database import SessionDep from app.models.blog_post import BlogPost, BlogPostCreate, BlogPostUpdate -from backend.app.models.user import User, UserCreate, UserLogin -from backend.app.models.session import SessionModel -from backend.app.core.database import get_session +from app.models.user import User, UserCreate, UserLogin +from app.models.session import SessionModel +from app.core.database import get_session from itsdangerous import TimestampSigner @@ -40,16 +40,18 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: return plain_password == hashed_password # Replace with actual verification logic -def get_current_user( - session_id: str = Cookie(None), db: SessionDep = Depends(get_session) -): +def get_current_user(db: Session, session_id: str): + print(f"session_id: {session_id}, type: {type(session_id)}") if not session_id: raise HTTPException(status_code=401, detail="Not logged in") try: unsigned_id = signer.unsign(session_id, max_age=86400).decode() - except Exception: - raise HTTPException(status_code=401, detail="Invalid or expired session") + except Exception as e: + raise HTTPException( + status_code=401, + detail=f"Invalid or expired session {session_id}, {e}", + ) session_obj = db.get(SessionModel, unsigned_id) if not session_obj or session_obj.expires_at < time.time(): @@ -78,9 +80,9 @@ def cleanup_expired_sessions(db: Session): @router.post("/users/", response_model=User, status_code=status.HTTP_201_CREATED) def create_user(user: UserCreate, db: SessionDep): - existing_user = db.exec(select(User).where(User.email == user.email)).first() + existing_user = db.exec(select(User).where(User.username == user.username)).first() if existing_user: - raise HTTPException(status_code=400, detail="Email already registered") + raise HTTPException(status_code=400, detail="Username already registered") new_user = User( username=user.username, hashed_password=hash_password(user.password), @@ -93,7 +95,7 @@ def create_user(user: UserCreate, db: SessionDep): @router.post("/login/") def login_attempt(login: UserLogin, response: Response, db: SessionDep): - user = db.exec(select(User).where(User.email == login.email)).first() + user = db.exec(select(User).where(User.username == login.username)).first() if not user or not verify_password(login.password, user.hashed_password): raise HTTPException(status_code=401, detail="Invalid credentials") @@ -107,9 +109,9 @@ def login_attempt(login: UserLogin, response: Response, db: SessionDep): select(SessionModel).where(SessionModel.user_id == user.id) ).first() - if existing_session: - db.delete(existing_session) - db.commit() + # if existing_session: + # db.delete(existing_session) + # db.commit() # Sign the session ID and set as HttpOnly cookie signed_session_id = signer.sign(session.id).decode() @@ -117,7 +119,7 @@ def login_attempt(login: UserLogin, response: Response, db: SessionDep): "session_id", signed_session_id, httponly=True, - secure=True, + secure=False, samesite="lax", max_age=86400, # 1 day ) @@ -125,6 +127,22 @@ def login_attempt(login: UserLogin, response: Response, db: SessionDep): return {"message": "Login successful"} +# @router.get("/me") +# def read_me(): +# return {"id": 1, "username": "Michal"} + + @router.get("/me") -def read_me(user=Depends(get_current_user)): - return {"id": user.id, "Username": user.username} +def read_me(db: SessionDep, session_id: str = Cookie(None)): + # user = get_current_user(db, session_id) + # return {"id": user.id, "username": user.username} + print("session_id:", session_id) + return 0 + + +@router.get("/sessions") +def get_my_sessions(db: SessionDep): + sessions = db.exec(select(SessionModel)).all() + return [ + {"id": session.id, "expires_at": session.expires_at} for session in sessions + ] diff --git a/backend/app/main.py b/backend/app/main.py index 85300cc..42affbc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,7 +4,8 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from backend.app.api.posts import router as data_router +from app.api.posts import router as post_router +from app.api.users import router as user_router from app.core.database import SessionDep, create_db_and_tables @@ -14,9 +15,10 @@ async def lifespan(app: FastAPI): yield -app = FastAPI(lifespan=lifespan) +app = FastAPI(lifespan=lifespan, root_path="/api") -app.include_router(data_router) +app.include_router(post_router) +app.include_router(user_router) # Allowing CORS from the specific origin (localhost:5173) or any origin origins = ["*"] # Or you can use localhost and 127.0.0.1 as the origin diff --git a/backend/requirements.txt b/backend/requirements.txt index 039f9c5e12921de7f33b8edcc170b4f7faf5af48..3892d18ed7addd483286596fcc6c1eb59746b579 100644 GIT binary patch literal 881 zcmY*YOP1px4BT@U^NgKeFMJpR*j5Jv12}2leoCaLU(ZrPDwRrw4k1=uo$cz8T$!d` zUS%(II4DljNva;v-D~rhrr|6VBI9zUnGpBL+~g>XCw{-lK`LRQk6CY;p-%E5Y$)ld zYYb>;*lyG0yQlVKb&Z+RBVcD;s!o9IrBW9FeJ47gbF!DWU#Ym;e-BEY#gd&1-qmRu z!BL4-sRncb@LFp!F}rC3d->7g8l!K^UgW7IzPNn2p_Oa^cfj<5O3Z)+2wP#-P9_vvf|i|3mG2DGDf+wLnR%{uT62A*>04g6V_L1Z#3THq8A;Gkn|AJpm< zQ_r-7CVwwk?Sj$y=!#`W$q}9sWp&7CV!k27m_zTzjjxggtt-+cZqK;n(PIac@3=IKeaVJmrk!Nz< n{v-(5UcsAVA7kR;fwzC@YSx)Nf1~#mU+!L_c{tXZCjs&=J)H|A literal 1362 zcmZ9MO>fg+5QO)P#7{}oIHkaWL$8QRpq@BI!FKA9*eS6?+CL9Gv+K~)iUZQDS?rluMb#s?3Fv`e%^{goDYza+^nv&z?<<_RL=_`vWd~Td9#gh@ zjhJ{fv5qP_E6R>DBwT3vAG5gfP3XeUZZLj@@rgb(nPK!T7(aStbt%R?RTJ-3ThVcb zR6i#XK3-#6I@!9`XYW1c9*#&y_#zG8?cN^ji!*9w!i2)TciN1#bY`S1W|}eWXH0(M z6OgWOPWa&IIbk(wbm}{LZcx;Ytnw~7d!pJ_?Q3kbI*hX_Hy-U9I`82rov}rI&OtTj zxGP+5zreI`R9-hVMQ5o=+of4+C**3PZr#_pGv&8ue~QKihT{>9o)$2?IFG&t(yymD zwN3H8iOL72rxT=aN_;Cq`+4h*C>y*-IU$9?l=F@W9QpqTksZhl?|Ft|f1}nv#CXJo diff --git a/docker-compose.yml b/docker-compose.yml index c66ea66..8c9728a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,10 +3,10 @@ services: image: nginx:latest container_name: nginx_proxy ports: - - "80:80" # HTTP + - "80:80" # HTTP # - "443:443" # HTTPS (for future SSL setup) volumes: - - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro # Mount custom Nginx config + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro # Mount custom Nginx config # - ./certs:/etc/nginx/ssl # SSL certificates (optional for HTTPS) depends_on: - backend @@ -18,8 +18,6 @@ services: build: context: ./frontend dockerfile: Dockerfile - ports: - - "3000:3000" depends_on: - backend networks: @@ -29,8 +27,6 @@ services: build: context: ./backend dockerfile: Dockerfile - ports: - - "8000:8000" # Exposing backend on port 8000 for frontend communication networks: - app-network diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..03e73d4 --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,91 @@ + + +
+

Login

+ {#if error} +
{error}
+ {/if} +
+ + + +
+
+ + diff --git a/frontend/src/routes/post-editor/+page.svelte b/frontend/src/routes/post-editor/+page.svelte index 21e0f23..b759cc7 100644 --- a/frontend/src/routes/post-editor/+page.svelte +++ b/frontend/src/routes/post-editor/+page.svelte @@ -1,5 +1,7 @@ +

Welcome, {JSON.stringify(data)}!

diff --git a/frontend/src/routes/post-editor/+page.ts b/frontend/src/routes/post-editor/+page.ts new file mode 100644 index 0000000..b7102b8 --- /dev/null +++ b/frontend/src/routes/post-editor/+page.ts @@ -0,0 +1,14 @@ +import { redirect } from '@sveltejs/kit'; + +export async function load({ fetch, url }) { + const res = await fetch('http://backend:3000/me', { + credentials: 'include' // important: send cookies + }); + + // if (res.status === 401) { + // // throw redirect(302, `/login?next=${url.pathname}`); + // } + + const user = await res.json(); + return { user }; +} diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 40f496b..72698cd 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -9,7 +9,7 @@ http { server_name michalrajzer.com; location /api/ { - proxy_pass http://backend:8000/; # Forward requests to backend + proxy_pass http://backend:3000/; # Forward requests to backend proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; From 1f14f6fde9ced5f50624f062ba6c860bf9edc954 Mon Sep 17 00:00:00 2001 From: micraj Date: Sat, 6 Sep 2025 12:50:41 +0100 Subject: [PATCH 4/8] Working login --- .gitignore | 1 + backend/app/api/posts.py | 1 - backend/app/api/users.py | 63 ++- backend/app/core/db_init.py | 39 ++ backend/app/main.py | 19 +- backend/app/models/session.py | 6 +- backend/requirements.txt | 1 + docker-compose.yml | 15 +- frontend/src/routes/+page.svelte | 50 +-- frontend/src/routes/login/+page.server.ts | 7 + frontend/src/routes/login/+page.svelte | 19 +- frontend/src/routes/old/+page.svelte | 375 ------------------ .../src/routes/post-editor/+page.server.ts | 20 + frontend/src/routes/post-editor/+page.ts | 14 - 14 files changed, 144 insertions(+), 486 deletions(-) create mode 100644 .gitignore create mode 100644 backend/app/core/db_init.py create mode 100644 frontend/src/routes/login/+page.server.ts delete mode 100644 frontend/src/routes/old/+page.svelte create mode 100644 frontend/src/routes/post-editor/+page.server.ts delete mode 100644 frontend/src/routes/post-editor/+page.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4ddc88 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.secrets \ No newline at end of file diff --git a/backend/app/api/posts.py b/backend/app/api/posts.py index c36c993..cfae5ae 100644 --- a/backend/app/api/posts.py +++ b/backend/app/api/posts.py @@ -84,7 +84,6 @@ def update_post( if updated_post.cover_image is not None: db_post.cover_image = updated_post.cover_image - # db_post.timestamp = int(time.time()) db.add(db_post) db.commit() db.refresh(db_post) diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 369ed08..4c4f5b9 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -1,43 +1,40 @@ import datetime import json +import os import re import shutil import time +from pathlib import Path from typing import List, Optional -from fastapi import ( - APIRouter, - Depends, - File, - HTTPException, - Request, - UploadFile, - Response, - status, - Cookie, -) +import bcrypt +from dotenv import load_dotenv +from fastapi import (APIRouter, Cookie, Depends, File, HTTPException, Request, + Response, UploadFile, status) +from itsdangerous import TimestampSigner from sqlmodel import Session, select, text -from app.core.database import SessionDep +from app.core.database import SessionDep, get_session from app.models.blog_post import BlogPost, BlogPostCreate, BlogPostUpdate -from app.models.user import User, UserCreate, UserLogin from app.models.session import SessionModel -from app.core.database import get_session -from itsdangerous import TimestampSigner - +from app.models.user import User, UserCreate, UserLogin -SECRET_KEY = "super-secret" # load from env in real app +secret_file = Path("/run/secrets/backend_secret_key") +SECRET_KEY = secret_file.read_text().strip() signer = TimestampSigner(SECRET_KEY) router = APIRouter() def hash_password(password: str) -> str: - return password # Replace with actual hashing logic + hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode() + return hashed def verify_password(plain_password: str, hashed_password: str) -> bool: - return plain_password == hashed_password # Replace with actual verification logic + return bcrypt.checkpw( + plain_password.encode("utf-8"), hashed_password.encode("utf-8") + ) def get_current_user(db: Session, session_id: str): @@ -99,19 +96,19 @@ def login_attempt(login: UserLogin, response: Response, db: SessionDep): if not user or not verify_password(login.password, user.hashed_password): raise HTTPException(status_code=401, detail="Invalid credentials") - session = SessionModel(user_id=user.id) - db.add(session) - db.commit() - db.refresh(session) - # Check for existing session existing_session = db.exec( select(SessionModel).where(SessionModel.user_id == user.id) ).first() - # if existing_session: - # db.delete(existing_session) - # db.commit() + if existing_session: + db.delete(existing_session) + db.commit() + + session = SessionModel(user_id=user.id) + db.add(session) + db.commit() + db.refresh(session) # Sign the session ID and set as HttpOnly cookie signed_session_id = signer.sign(session.id).decode() @@ -122,22 +119,16 @@ def login_attempt(login: UserLogin, response: Response, db: SessionDep): secure=False, samesite="lax", max_age=86400, # 1 day + path="/", ) return {"message": "Login successful"} -# @router.get("/me") -# def read_me(): -# return {"id": 1, "username": "Michal"} - - @router.get("/me") def read_me(db: SessionDep, session_id: str = Cookie(None)): - # user = get_current_user(db, session_id) - # return {"id": user.id, "username": user.username} - print("session_id:", session_id) - return 0 + user = get_current_user(db, session_id) + return {"id": user.id, "username": user.username} @router.get("/sessions") diff --git a/backend/app/core/db_init.py b/backend/app/core/db_init.py new file mode 100644 index 0000000..cc0a0a2 --- /dev/null +++ b/backend/app/core/db_init.py @@ -0,0 +1,39 @@ +import os +from pathlib import Path + +import bcrypt +from sqlmodel import select + +from app.core.database import get_session +from app.models.user import User + +ADMIN_USERNAME = os.getenv("ADMIN_USERNAME") +secret_file = Path("/run/secrets/admin_password") +ADMIN_PASSWORD = secret_file.read_text().strip() +print(f"ADMIN_USERNAME: {ADMIN_USERNAME}, ADMIN_PASSWORD: {ADMIN_PASSWORD}") + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + +def create_initial_admin(): + db = next(get_session()) + existing_admin = db.exec( + select(User).where(User.username == ADMIN_USERNAME) + ).first() + if not existing_admin: + admin_user = User( + username=ADMIN_USERNAME, + hashed_password=hash_password(ADMIN_PASSWORD), + # is_admin=True, + ) + db.add(admin_user) + db.commit() + print(f"Admin user '{ADMIN_USERNAME}' created.") + else: + print(f"Admin user '{ADMIN_USERNAME}' already exists.") + + +if __name__ == "__main__": + create_initial_admin() diff --git a/backend/app/main.py b/backend/app/main.py index 42affbc..b0a3a56 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,4 @@ +import os from contextlib import asynccontextmanager from typing import Union @@ -7,15 +8,31 @@ from app.api.posts import router as post_router from app.api.users import router as user_router from app.core.database import SessionDep, create_db_and_tables +from app.core.db_init import create_initial_admin @asynccontextmanager async def lifespan(app: FastAPI): create_db_and_tables() + try: + create_initial_admin() + except Exception as e: + print(f"Error creating admin user: {e}") + raise yield -app = FastAPI(lifespan=lifespan, root_path="/api") +APP_MODE = os.getenv("APP_MODE", "development") +docs_url = None if APP_MODE == "production" else "/docs" +redoc_url = None if APP_MODE == "production" else "/redoc" +openapi_url = None if APP_MODE == "production" else "/openapi.json" +app = FastAPI( + docs_url=docs_url, + redoc_url=redoc_url, + openapi_url=openapi_url, + lifespan=lifespan, + root_path="/api", +) app.include_router(post_router) app.include_router(user_router) diff --git a/backend/app/models/session.py b/backend/app/models/session.py index 89ac651..48f325d 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -1,5 +1,7 @@ -import time, secrets -from sqlmodel import SQLModel, Field +import secrets +import time + +from sqlmodel import Field, SQLModel class SessionModel(SQLModel, table=True): diff --git a/backend/requirements.txt b/backend/requirements.txt index 3892d18..f84948c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,6 @@ annotated-types==0.7.0 anyio==4.10.0 +bcrypt==4.3.0 black==25.1.0 certifi==2025.8.3 click==8.2.1 diff --git a/docker-compose.yml b/docker-compose.yml index 8c9728a..fc60255 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,7 @@ services: - "80:80" # HTTP # - "443:443" # HTTPS (for future SSL setup) volumes: - - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro # Mount custom Nginx config - # - ./certs:/etc/nginx/ssl # SSL certificates (optional for HTTPS) + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro depends_on: - backend - frontend @@ -29,7 +28,19 @@ services: dockerfile: Dockerfile networks: - app-network + secrets: + - backend_secret_key + - admin_password + environment: + ADMIN_USERNAME: "admin" + # APP_MODE: "production" # Change to "development" or comment out for dev mode networks: app-network: driver: bridge + +secrets: + backend_secret_key: + file: .secrets/backend_secret_key.env + admin_password: + file: .secrets/admin_password.env diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index fd32530..8a81396 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -194,55 +194,7 @@ } /* Projects */ - .blog-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 24px; - padding: 20px 40px; - } - - .blog-card { - background: var(--color-white); - border: 2px solid var(--color-primary); - border-radius: 10px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); - transition: - transform 0.2s ease, - box-shadow 0.2s ease; - display: flex; - flex-direction: column; - overflow: hidden; - } - .blog-card:hover { - transform: translateY(-4px); - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); - } - .blog-card img { - width: 100%; - height: 180px; - object-fit: cover; - } - .blog-card .content { - padding: 20px; - } - .blog-card h3 { - margin: 0 0 8px; - color: var(--color-heading); - font-size: 1.3rem; - } - .blog-card .date { - color: var(--color-muted); - font-size: 0.9rem; - margin-bottom: 12px; - } - .blog-card .excerpt { - color: var(--color-subtext); - font-size: 1rem; - } - .date { - font-size: 0.9em; - opacity: 0.9; - margin-bottom: 10px; + .projects-section { } /* About */ diff --git a/frontend/src/routes/login/+page.server.ts b/frontend/src/routes/login/+page.server.ts new file mode 100644 index 0000000..3ac1840 --- /dev/null +++ b/frontend/src/routes/login/+page.server.ts @@ -0,0 +1,7 @@ +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = ({ url }) => { + return { + next: url.searchParams.get('next') ?? '/' + }; +}; diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte index 03e73d4..beaba9a 100644 --- a/frontend/src/routes/login/+page.svelte +++ b/frontend/src/routes/login/+page.svelte @@ -1,5 +1,11 @@ - -
- - - -
-
-

Hi! I'm Michal,
Welcome to
my blog

-
- -
- -
- {#each blogs as blog} -
- {blog.title} -
-

{blog.title}

-

{blog.date}

-

{blog.excerpt}

-
-
- {/each} -
- -
- -
- -
-

This is a short bio about Michal. You can customize this section however you like.

-
-
-
-
-
- - diff --git a/frontend/src/routes/post-editor/+page.server.ts b/frontend/src/routes/post-editor/+page.server.ts new file mode 100644 index 0000000..d8ae63c --- /dev/null +++ b/frontend/src/routes/post-editor/+page.server.ts @@ -0,0 +1,20 @@ +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ fetch, cookies, url }) => { + const sessionId = cookies.get('session_id'); + + const res = await fetch('http://backend:3000/me', { + headers: { + cookie: `session_id=${sessionId}` + } + }); + + if (res.status === 401) { + const next = encodeURIComponent(url.pathname + url.search); + throw redirect(302, `/login?next=${next}`); + } + + const user = await res.json(); + return { user }; +}; diff --git a/frontend/src/routes/post-editor/+page.ts b/frontend/src/routes/post-editor/+page.ts deleted file mode 100644 index b7102b8..0000000 --- a/frontend/src/routes/post-editor/+page.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { redirect } from '@sveltejs/kit'; - -export async function load({ fetch, url }) { - const res = await fetch('http://backend:3000/me', { - credentials: 'include' // important: send cookies - }); - - // if (res.status === 401) { - // // throw redirect(302, `/login?next=${url.pathname}`); - // } - - const user = await res.json(); - return { user }; -} From 345e4f2878a602befadec44fb9ddeacf06e8ba96 Mon Sep 17 00:00:00 2001 From: micraj Date: Sat, 6 Sep 2025 14:19:33 +0100 Subject: [PATCH 5/8] Enhance blog post and user management features - Implement HTML sanitization in post creation - Add user ID association to blog posts - Update user creation to enforce admin role check - Modify post retrieval endpoints to use slug and ID - Improve post deletion and update authorization checks - Add is_admin field to User model - Update initial admin creation to set is_admin to True - Update PostGrid component styles for better UX - Refactor post detail page for improved layout and content rendering --- backend/app/api/posts.py | 81 ++++++++++++++++--- backend/app/api/users.py | 15 ++-- backend/app/core/db_init.py | 2 +- backend/app/models/blog_post.py | 1 + backend/app/models/user.py | 1 + backend/requirements.txt | 2 + frontend/src/lib/components/PostGrid.svelte | 5 +- frontend/src/routes/+page.svelte | 2 +- frontend/src/routes/post-editor/+page.svelte | 2 +- frontend/src/routes/posts/[slug]/+page.svelte | 58 ++++++++++--- 10 files changed, 132 insertions(+), 37 deletions(-) diff --git a/backend/app/api/posts.py b/backend/app/api/posts.py index cfae5ae..43de003 100644 --- a/backend/app/api/posts.py +++ b/backend/app/api/posts.py @@ -5,21 +5,63 @@ import time from typing import List, Optional -from fastapi import APIRouter, File, HTTPException, Request, UploadFile +from fastapi import APIRouter, File, HTTPException, Request, UploadFile, Cookie from sqlmodel import Session, select, text from app.core.database import SessionDep from app.models.blog_post import BlogPost, BlogPostCreate, BlogPostUpdate +from app.api.users import get_current_user +import bleach router = APIRouter() def sanitise_html(html: str) -> str: - return html + allowed_tags = [ + "b", + "i", + "u", + "em", + "strong", + "a", + "p", + "ul", + "ol", + "li", + "br", + "blockquote", + "code", + "pre", + "img", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + ] + + allowed_attrs = { + "a": ["href", "title", "target", "rel"], + "img": ["src", "alt", "title", "width", "height"], + } + + # Allowed protocols for href/src (data for embedded images) + allowed_protocols = ["http", "https", "mailto", "data"] + + cleaned_html = bleach.clean( + html, + tags=allowed_tags, + attributes=allowed_attrs, + protocols=allowed_protocols, + strip=True, # Strip disallowed tags instead of escaping + ) + + return cleaned_html def generate_unique_slug(title: str, db: Session) -> str: - # Slugify title (basic version; you can improve with unidecode or slugify lib) + # Slugify title (improve with unidecode or slugify lib) base_slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") slug = base_slug counter = 1 @@ -30,12 +72,14 @@ def generate_unique_slug(title: str, db: Session) -> str: @router.post("/posts/create_post", response_model=BlogPost) -def create_post(post: BlogPostCreate, db: SessionDep): +def create_post(post: BlogPostCreate, db: SessionDep, session_id: str = Cookie(None)): + user = get_current_user(db, session_id) created_post = BlogPost( title=post.title, content=sanitise_html(post.content), slug=generate_unique_slug(post.title, db), cover_image=post.cover_image, + user_id=user.id, ) db.add(created_post) db.commit() @@ -48,31 +92,37 @@ def read_posts(db: SessionDep): return db.exec(select(BlogPost)).all() -@router.get("/posts/id/{post_id}", response_model=BlogPost) -def read_post(post_id: int, db: SessionDep): - post = db.get(BlogPost, post_id) +@router.get("/posts/slug/{post_slug}", response_model=BlogPost) +def read_post(post_slug: str, db: SessionDep): + post = db.exec(select(BlogPost).where(BlogPost.slug == post_slug)).first() if not post: raise HTTPException(status_code=404, detail="Post not found") return post -@router.get("/posts/{post_slug}", response_model=BlogPost) -def read_post(post_slug: str, db: SessionDep): - post = db.exec(select(BlogPost).where(BlogPost.slug == post_slug)).first() +@router.get("/posts/id/{post_id}", response_model=BlogPost) +def read_post_by_id(post_id: int, db: SessionDep): + post = db.get(BlogPost, post_id) if not post: raise HTTPException(status_code=404, detail="Post not found") return post -@router.put("/posts/{post_id}", response_model=BlogPost) +@router.put("/posts/id/{post_id}", response_model=BlogPost) def update_post( post_id: int, updated_post: BlogPostUpdate, db: SessionDep, + session_id: str = Cookie(None), ): db_post = db.get(BlogPost, post_id) if not db_post: raise HTTPException(status_code=404, detail="Post not found") + user = get_current_user(db, session_id) + if user.id != db_post.user_id: + raise HTTPException( + status_code=403, detail="Not authorized to update this post" + ) if updated_post.title is not None: db_post.title = updated_post.title @@ -90,11 +140,16 @@ def update_post( return db_post -@router.delete("/posts/{post_id}") -def delete_post(post_id: int, db: SessionDep): +@router.delete("/posts/id/{post_id}") +def delete_post(post_id: int, db: SessionDep, session_id: str = Cookie(None)): post = db.get(BlogPost, post_id) if not post: raise HTTPException(status_code=404, detail="Post not found") + user = get_current_user(db, session_id) + if user.id != post.user_id: + raise HTTPException( + status_code=403, detail="Not authorized to delete this post" + ) db.delete(post) db.commit() return {"detail": "Post deleted"} diff --git a/backend/app/api/users.py b/backend/app/api/users.py index 4c4f5b9..6665beb 100644 --- a/backend/app/api/users.py +++ b/backend/app/api/users.py @@ -76,7 +76,10 @@ def cleanup_expired_sessions(db: Session): @router.post("/users/", response_model=User, status_code=status.HTTP_201_CREATED) -def create_user(user: UserCreate, db: SessionDep): +def create_user(user: UserCreate, db: SessionDep, session_id: str = Cookie(None)): + user = get_current_user(db, session_id) + if not user.is_admin: + raise HTTPException(status_code=403, detail="Not authorized to create users") existing_user = db.exec(select(User).where(User.username == user.username)).first() if existing_user: raise HTTPException(status_code=400, detail="Username already registered") @@ -128,12 +131,4 @@ def login_attempt(login: UserLogin, response: Response, db: SessionDep): @router.get("/me") def read_me(db: SessionDep, session_id: str = Cookie(None)): user = get_current_user(db, session_id) - return {"id": user.id, "username": user.username} - - -@router.get("/sessions") -def get_my_sessions(db: SessionDep): - sessions = db.exec(select(SessionModel)).all() - return [ - {"id": session.id, "expires_at": session.expires_at} for session in sessions - ] + return {"id": user.id, "username": user.username, "is_admin": user.is_admin} diff --git a/backend/app/core/db_init.py b/backend/app/core/db_init.py index cc0a0a2..f900f19 100644 --- a/backend/app/core/db_init.py +++ b/backend/app/core/db_init.py @@ -26,7 +26,7 @@ def create_initial_admin(): admin_user = User( username=ADMIN_USERNAME, hashed_password=hash_password(ADMIN_PASSWORD), - # is_admin=True, + is_admin=True, ) db.add(admin_user) db.commit() diff --git a/backend/app/models/blog_post.py b/backend/app/models/blog_post.py index 177d505..7d387fd 100644 --- a/backend/app/models/blog_post.py +++ b/backend/app/models/blog_post.py @@ -13,6 +13,7 @@ class BlogPost(SQLModel, table=True): slug: str = Field(index=True, unique=True) content: str cover_image: str # Path to image file, .etc '/hamster_neutral.jpg' + user_id: int = Field(foreign_key="user.id") class BlogPostCreate(BaseModel): diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 69e55de..3a4e8db 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -8,6 +8,7 @@ class User(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) username: str = Field(index=True, unique=True) hashed_password: str + is_admin: bool = Field(default=False) class UserCreate(BaseModel): diff --git a/backend/requirements.txt b/backend/requirements.txt index f84948c..a7e7686 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,6 +2,7 @@ annotated-types==0.7.0 anyio==4.10.0 bcrypt==4.3.0 black==25.1.0 +bleach==6.2.0 certifi==2025.8.3 click==8.2.1 colorama==0.4.6 @@ -48,4 +49,5 @@ urllib3==2.5.0 uvicorn==0.35.0 uvloop==0.21.0 watchfiles==1.1.0 +webencodings==0.5.1 websockets==15.0.1 diff --git a/frontend/src/lib/components/PostGrid.svelte b/frontend/src/lib/components/PostGrid.svelte index 67cd498..0524eda 100644 --- a/frontend/src/lib/components/PostGrid.svelte +++ b/frontend/src/lib/components/PostGrid.svelte @@ -48,7 +48,6 @@ gap: 24px; padding: 20px 40px; } - .post-card { background: var(--color-white); border: 2px solid var(--color-primary); @@ -61,6 +60,10 @@ flex-direction: column; overflow: hidden; } + a.post-card { + text-decoration: none; + color: inherit; + } .post-card:hover { transform: translateY(-4px); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 8a81396..8d747bd 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -84,7 +84,7 @@
-

This is a short bio about Michal. You can customize this section however you like.

+

This is a short bio

diff --git a/frontend/src/routes/post-editor/+page.svelte b/frontend/src/routes/post-editor/+page.svelte index b759cc7..49bd374 100644 --- a/frontend/src/routes/post-editor/+page.svelte +++ b/frontend/src/routes/post-editor/+page.svelte @@ -3,5 +3,5 @@ import PostEditor from '$lib/components/PostEditor.svelte'; -

Welcome, {JSON.stringify(data)}!

+

Welcome, {data.user.username}!

diff --git a/frontend/src/routes/posts/[slug]/+page.svelte b/frontend/src/routes/posts/[slug]/+page.svelte index 8b4213d..dd5225d 100644 --- a/frontend/src/routes/posts/[slug]/+page.svelte +++ b/frontend/src/routes/posts/[slug]/+page.svelte @@ -16,7 +16,7 @@ onMount(async () => { const slug = page.params.slug; // get the slug from URL try { - const res = await fetch(`/api/posts/${slug}`); + const res = await fetch(`/api/posts/slug/${slug}`); if (!res.ok) throw new Error(`Failed to fetch: ${res.status}`); post = await res.json(); } catch (e: any) { @@ -25,12 +25,50 @@ }); -{#if post} -

{post.title}

- {post.title} -

{post.content}

-{:else if error} -

{error}

-{:else} -

Loading...

-{/if} +
+ {#if post} +

{post.title}

+ +

{@html post.content}

+ {:else if error} +

{error}

+ {:else} +

Loading...

+ {/if} +
+ + From b37e5ba50567cb72c4a6ebe85a600485fa57e39c Mon Sep 17 00:00:00 2001 From: micraj Date: Sat, 6 Sep 2025 14:25:20 +0100 Subject: [PATCH 6/8] Remove CORS middleware configuration --- backend/app/main.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index b0a3a56..b129f8b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -37,18 +37,6 @@ async def lifespan(app: FastAPI): app.include_router(post_router) app.include_router(user_router) -# Allowing CORS from the specific origin (localhost:5173) or any origin -origins = ["*"] # Or you can use localhost and 127.0.0.1 as the origin - -# Adding CORSMiddleware -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # Allow all origins or specify the frontend URL - allow_credentials=True, - allow_methods=["*"], # Allow all methods - allow_headers=["*"], # Allow all headers -) - @app.get("/") def read_root(): From da78a074292c4284f01972a7af082439a0d1667f Mon Sep 17 00:00:00 2001 From: micraj Date: Sat, 6 Sep 2025 14:27:17 +0100 Subject: [PATCH 7/8] Add .svelte-kit to .gitignore --- frontend/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/.gitignore b/frontend/.gitignore index 3b462cb..fd7849a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,4 +1,5 @@ node_modules +.svelte-kit # Output .output From 6df73344ebb789baba56a63b7213f3843a881f00 Mon Sep 17 00:00:00 2001 From: micraj Date: Sat, 6 Sep 2025 14:32:20 +0100 Subject: [PATCH 8/8] Remove debug print statement for admin credentials in db_init.py --- backend/app/core/db_init.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/app/core/db_init.py b/backend/app/core/db_init.py index f900f19..5c2c5a5 100644 --- a/backend/app/core/db_init.py +++ b/backend/app/core/db_init.py @@ -10,7 +10,6 @@ ADMIN_USERNAME = os.getenv("ADMIN_USERNAME") secret_file = Path("/run/secrets/admin_password") ADMIN_PASSWORD = secret_file.read_text().strip() -print(f"ADMIN_USERNAME: {ADMIN_USERNAME}, ADMIN_PASSWORD: {ADMIN_PASSWORD}") def hash_password(password: str) -> str: