diff --git a/README.md b/README.md index cab9f1a..0629968 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ npm run dev Open `http://localhost:5173`. Vite proxies `/api` requests to FastAPI on port 8000. +The interface loads Roboto Flex from Google Fonts for its compact UI copy, while editorial headings keep the résumé-inspired serif treatment. + ## Contact-form delivery The form uses `POST /api/contact` and sends email through SMTP. Copy `backend/.env.example` to `backend/.env`, fill in the SMTP values, and start the API with the environment file: @@ -52,6 +54,19 @@ uvicorn backend.main:app --reload --port 8000 --env-file backend\.env If SMTP is unavailable, the API returns a clear delivery error and the page keeps Alex's direct email, LinkedIn, and GitHub links visible. +## Journal publisher + +Open `http://localhost:5173/blog/manage` and sign in with the journal admin password. Publishing writes the new article directly to `backend/data/posts.json`; no database is involved. + +Set unique production values in `backend/.env`: + +```dotenv +JOURNAL_ADMIN_PASSWORD=replace-with-a-strong-password +JOURNAL_TOKEN_SECRET=replace-with-a-long-random-secret +``` + +The login endpoint returns a signed session that expires after four hours. The password remains server-side and the browser stores only the temporary token. + ## Content API - `GET /api/profile` @@ -59,6 +74,9 @@ If SMTP is unavailable, the API returns a clear delivery error and the page keep - `GET /api/skills` - `GET /api/posts` - `GET /api/posts/{slug}` +- `POST /api/auth/login` +- `GET /api/auth/session` +- `POST /api/posts` (authenticated) - `GET /api/resume` - `POST /api/contact` @@ -74,4 +92,3 @@ uvicorn backend.main:app --host 0.0.0.0 --port 8000 ``` When `frontend/dist` exists, FastAPI serves the built single-page application and supports direct links such as `/experience` and `/blog/{slug}`. - diff --git a/backend/.env.example b/backend/.env.example index 0c11eaf..5941222 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -13,3 +13,6 @@ SMTP_USE_SSL=false # Comma-separated origins allowed to call the API during development. FRONTEND_ORIGINS=http://localhost:5173 +# Lightweight journal publisher authentication. Use long, unique values in production. +JOURNAL_ADMIN_PASSWORD=replace-with-a-strong-password +JOURNAL_TOKEN_SECRET=replace-with-a-long-random-secret diff --git a/backend/data/profile.json b/backend/data/profile.json index fb97581..f082b25 100644 --- a/backend/data/profile.json +++ b/backend/data/profile.json @@ -28,6 +28,25 @@ "kind": "github" } ], + "interests": [ + { + "label": "Go to Spotify", + "note": "Often playing", + "href": "https://open.spotify.com/user/orbitrix", + "kind": "spotify" + }, + { + "label": "Open Instagram", + "note": "Life outside code", + "href": "https://www.instagram.com/alexanderherlan2/", + "kind": "instagram" + }, + { + "label": "Find me on Steam", + "note": "Occasionally gaming", + "href": "https://steamcommunity.com/profiles/76561197961799136", + "kind": "steam" + } + ], "focus": ["Product engineering", "Applied AI", "Cloud systems"] } - diff --git a/backend/main.py b/backend/main.py index 8b7e4e5..1c9c7d6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,16 +1,21 @@ from __future__ import annotations import json +import hashlib +import hmac import os import re import smtplib import ssl +import threading +import time +from datetime import date from email.message import EmailMessage from functools import lru_cache from pathlib import Path -from typing import Any +from typing import Any, Literal -from fastapi import FastAPI, HTTPException, Query +from fastapi import Depends, FastAPI, Header, HTTPException, Query, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from pydantic import BaseModel, EmailStr, Field @@ -22,6 +27,9 @@ PROJECT_DIR = BASE_DIR.parent DATA_DIR = BASE_DIR / "data" FRONTEND_DIST = PROJECT_DIR / "frontend" / "dist" RESUME_PATH = PROJECT_DIR / "Alexander Herlan Resume 2024.pdf" +POSTS_PATH = DATA_DIR / "posts.json" +POSTS_LOCK = threading.Lock() +ADMIN_TOKEN_TTL = 60 * 60 * 4 app = FastAPI( @@ -52,6 +60,25 @@ class ContactPayload(BaseModel): company: str = Field(default="", max_length=200) +class LoginPayload(BaseModel): + password: str = Field(min_length=1, max_length=200) + + +class ArticleSection(BaseModel): + heading: str = Field(min_length=3, max_length=160) + paragraphs: list[str] = Field(min_length=1, max_length=12) + + +class NewPostPayload(BaseModel): + title: str = Field(min_length=5, max_length=160) + excerpt: str = Field(min_length=20, max_length=360) + published_at: date + read_time: int = Field(ge=1, le=60) + tags: list[str] = Field(min_length=1, max_length=6) + accent: Literal["blue", "lavender", "peach", "yellow", "mint"] = "mint" + content: list[ArticleSection] = Field(min_length=1, max_length=8) + + @lru_cache(maxsize=8) def read_data(filename: str) -> Any: path = DATA_DIR / filename @@ -60,6 +87,64 @@ def read_data(filename: str) -> Any: return json.loads(path.read_text(encoding="utf-8")) +def journal_credentials() -> tuple[str, str]: + password = os.getenv("JOURNAL_ADMIN_PASSWORD", "") + token_secret = os.getenv("JOURNAL_TOKEN_SECRET", "") + if not password or not token_secret: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Journal publishing is not configured.", + ) + return password, token_secret + + +def issue_admin_token() -> tuple[str, int]: + _, token_secret = journal_credentials() + expires_at = int(time.time()) + ADMIN_TOKEN_TTL + payload = str(expires_at) + signature = hmac.new( + token_secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256 + ).hexdigest() + return f"{payload}.{signature}", expires_at + + +def require_admin(authorization: str | None = Header(default=None)) -> None: + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Admin sign-in required.", + ) + + token = authorization.removeprefix("Bearer ").strip() + try: + expires, supplied_signature = token.split(".", maxsplit=1) + expires_at = int(expires) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid admin session.", + ) + + _, token_secret = journal_credentials() + expected_signature = hmac.new( + token_secret.encode("utf-8"), expires.encode("utf-8"), hashlib.sha256 + ).hexdigest() + if expires_at <= int(time.time()) or not hmac.compare_digest( + supplied_signature, expected_signature + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Your admin session has expired. Please sign in again.", + ) + + +def post_slug(title: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", title.casefold()).strip("-") + if not slug: + raise HTTPException(status_code=422, detail="The title needs letters or numbers.") + return slug + + @app.get("/api/health") def health() -> dict[str, str]: return {"status": "ok"} @@ -80,6 +165,28 @@ def skills() -> dict[str, Any]: return read_data("skills.json") +@app.post("/api/auth/login") +def admin_login(payload: LoginPayload) -> dict[str, Any]: + password, _ = journal_credentials() + if not hmac.compare_digest(payload.password, password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="That password is not correct.", + ) + token, expires_at = issue_admin_token() + return { + "access_token": token, + "token_type": "bearer", + "expires_at": expires_at, + "expires_in": ADMIN_TOKEN_TTL, + } + + +@app.get("/api/auth/session") +def admin_session(_: None = Depends(require_admin)) -> dict[str, bool]: + return {"authenticated": True} + + @app.get("/api/posts") def posts( q: str | None = Query(default=None, max_length=100), @@ -105,6 +212,40 @@ def posts( return sorted(filtered, key=lambda item: item["published_at"], reverse=True) +@app.post("/api/posts", status_code=status.HTTP_201_CREATED) +def create_post( + payload: NewPostPayload, _: None = Depends(require_admin) +) -> dict[str, Any]: + slug = post_slug(payload.title) + clean_tags = list(dict.fromkeys(tag.strip() for tag in payload.tags if tag.strip())) + if not clean_tags: + raise HTTPException(status_code=422, detail="Add at least one topic tag.") + if any(len(tag) > 40 for tag in clean_tags): + raise HTTPException(status_code=422, detail="Topic tags must be 40 characters or fewer.") + + article = payload.model_dump(mode="json") + article["slug"] = slug + article["tags"] = clean_tags + + with POSTS_LOCK: + current_posts = json.loads(POSTS_PATH.read_text(encoding="utf-8")) + if any(post["slug"] == slug for post in current_posts): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A journal post with this title already exists.", + ) + current_posts.append(article) + temporary_path = POSTS_PATH.with_suffix(".json.tmp") + temporary_path.write_text( + json.dumps(current_posts, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + temporary_path.replace(POSTS_PATH) + read_data.cache_clear() + + return article + + @app.get("/api/posts/{slug}") def post_by_slug(slug: str) -> dict[str, Any]: if not re.fullmatch(r"[a-z0-9-]+", slug): @@ -193,4 +334,3 @@ if FRONTEND_DIST.is_dir(): if requested_file.is_file(): return FileResponse(requested_file) return FileResponse(FRONTEND_DIST / "index.html") - diff --git a/frontend/index.html b/frontend/index.html index ff0a08b..60bba13 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,9 @@ content="Alex Herlan is a senior software engineer building full-stack products, applied AI, and dependable cloud systems." /> + + +