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/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/data.py b/backend/app/api/posts.py similarity index 56% rename from backend/app/api/data.py rename to backend/app/api/posts.py index bfb622e..43de003 100644 --- a/backend/app/api/data.py +++ b/backend/app/api/posts.py @@ -1,27 +1,67 @@ -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, Cookie 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 +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 @@ -32,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() @@ -50,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 @@ -86,18 +134,22 @@ 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) 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 new file mode 100644 index 0000000..6665beb --- /dev/null +++ b/backend/app/api/users.py @@ -0,0 +1,134 @@ +import datetime +import json +import os +import re +import shutil +import time +from pathlib import Path +from typing import List, Optional + +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, get_session +from app.models.blog_post import BlogPost, BlogPostCreate, BlogPostUpdate +from app.models.session import SessionModel +from app.models.user import User, UserCreate, UserLogin + +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: + hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode() + return hashed + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return bcrypt.checkpw( + plain_password.encode("utf-8"), hashed_password.encode("utf-8") + ) + + +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 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(): + 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, 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") + 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.username == login.username)).first() + if not user or not verify_password(login.password, user.hashed_password): + raise HTTPException(status_code=401, detail="Invalid credentials") + + # 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() + + 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() + response.set_cookie( + "session_id", + signed_session_id, + httponly=True, + secure=False, + samesite="lax", + max_age=86400, # 1 day + path="/", + ) + + return {"message": "Login successful"} + + +@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, "is_admin": user.is_admin} 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/core/db_init.py b/backend/app/core/db_init.py new file mode 100644 index 0000000..5c2c5a5 --- /dev/null +++ b/backend/app/core/db_init.py @@ -0,0 +1,38 @@ +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() + + +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 0b7055f..b129f8b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,36 +1,43 @@ +import os +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.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) -app.include_router(data_router) +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", +) -# 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 -] +app.include_router(post_router) +app.include_router(user_router) -# 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(): return {"Hello": "World"} - - diff --git a/backend/app/models/blog_post.py b/backend/app/models/blog_post.py index 5bf84a3..7d387fd 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): @@ -12,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/session.py b/backend/app/models/session.py new file mode 100644 index 0000000..48f325d --- /dev/null +++ b/backend/app/models/session.py @@ -0,0 +1,13 @@ +import secrets +import time + +from sqlmodel import Field, SQLModel + + +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/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..3a4e8db --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,21 @@ +from typing import Optional + +from pydantic import BaseModel +from sqlmodel import Field, SQLModel + + +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): + username: str + password: str + + +class UserLogin(BaseModel): + username: str + password: str diff --git a/backend/requirements.txt b/backend/requirements.txt index 039f9c5..a7e7686 100644 Binary files a/backend/requirements.txt and b/backend/requirements.txt differ diff --git a/docker-compose.yml b/docker-compose.yml index c66ea66..fc60255 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,11 +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 - # - ./certs:/etc/nginx/ssl # SSL certificates (optional for HTTPS) + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro depends_on: - backend - frontend @@ -18,8 +17,6 @@ services: build: context: ./frontend dockerfile: Dockerfile - ports: - - "3000:3000" depends_on: - backend networks: @@ -29,11 +26,21 @@ services: build: context: ./backend dockerfile: Dockerfile - ports: - - "8000:8000" # Exposing backend on port 8000 for frontend communication 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/.gitignore b/frontend/.gitignore index 3b462cb..fd7849a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,4 +1,5 @@ node_modules +.svelte-kit # Output .output 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 bf1782a..8d747bd 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


- - -
@@ -88,7 +84,7 @@
-

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

+

This is a short bio

@@ -198,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 new file mode 100644 index 0000000..beaba9a --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,98 @@ + + +
+

Login

+ {#if error} +
{error}
+ {/if} +
+ + + +
+
+ + diff --git a/frontend/src/routes/old/+page.svelte b/frontend/src/routes/old/+page.svelte deleted file mode 100644 index a650f81..0000000 --- a/frontend/src/routes/old/+page.svelte +++ /dev/null @@ -1,375 +0,0 @@ - - -
- - - -
-
-

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.svelte b/frontend/src/routes/post-editor/+page.svelte index 21e0f23..49bd374 100644 --- a/frontend/src/routes/post-editor/+page.svelte +++ b/frontend/src/routes/post-editor/+page.svelte @@ -1,5 +1,7 @@ +

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} +
+ + 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;