diff --git a/.gitignore b/.gitignore index 177b7ba..7a0eec2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ dist/ .env .env.local backend/.env +backend/.env.* +!backend/.env.example # Editor .DS_Store @@ -15,6 +17,9 @@ backend/.env *.swp # Python +backend/.venv/ +venv/ +.venv/ __pycache__/ *.pyc *.pyo @@ -26,3 +31,4 @@ backend/uploads/ # Misc *.log +.neon diff --git a/DEPLOY.md b/DEPLOY.md index a2c45ec..7f3024a 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,69 +1,106 @@ # Deployment Guide -## Step 1 — Create GitHub Repository +| Part | Host | Notes | +|---|---|---| +| Frontend (Vite + React) | Vercel | `www.moderdiry.com` | +| Backend API (FastAPI) | Render, free web service | `render.yaml`, root directory `backend` | +| Database | Neon Postgres | project `summer-voice-22976145`, branch `production`, region `aws-eu-west-2` | -1. Go to https://github.com/new -2. Create a repo named `portfolio` (private or public) -3. Run these commands in the project folder: +Uploaded files (profile photo, resume, screenshots) are stored in Postgres (`uploaded_files` table), +because Render's free disk is wiped on every restart. They are still served at `/api/uploads/{filename}`. + +`railway.toml` and `backend/railway.toml` are unused leftovers from the old Railway setup. + +## 1. Database: Neon + +The project is already linked (`.neon`, `neon.ts`). To pull its connection strings into `.env.local` (git-ignored): + +```bash +neon login # once, opens a browser +neon env pull # writes DATABASE_URL (pooled) and DATABASE_URL_UNPOOLED (direct) +``` + +The backend uses `DATABASE_URL_UNPOOLED` when it is set (one long-running process with its own +connection pool that also creates tables at startup); otherwise `DATABASE_URL`. Neon's `sslmode` +is converted to asyncpg's `ssl` option automatically (`backend/app/core/config.py`). + +Tables are created at startup. The seed (`backend/app/services/seed.py`) fills empty tables and creates +the admin user only if none exists **and** `ADMIN_PASSWORD` is set. + +## 2. Backend: Render + +Either create a Blueprint from `render.yaml` (Render → New → Blueprint → this repo), or create the +service by hand: + +| Field | Value | +|---|---| +| Service type | Web Service | +| Repository | `Derdiry/portfolio` | +| Branch | `main` | +| Root directory | `backend` | +| Runtime | Python 3 | +| Region | Frankfurt (closest to the Neon project in London) | +| Build command | `pip install -r requirements.txt` | +| Start command | `uvicorn main:app --host 0.0.0.0 --port $PORT` | +| Instance type | Free | +| Health check path | `/health` | + +Environment variables: + +| Name | Value | +|---|---| +| `PYTHON_VERSION` | `3.10.12` | +| `DATABASE_URL_UNPOOLED` | Neon direct connection string (secret) | +| `JWT_SECRET` | long random string (secret), see below | +| `CORS_ORIGINS` | `https://www.moderdiry.com,https://moderdiry.com` | + +Generate `JWT_SECRET` locally and paste it straight into Render (don't save it in a file): ```bash -git init -git add . -git commit -m "Initial portfolio commit" -git branch -M main -git remote add origin https://github.com/Derdiry/portfolio.git -git push -u origin main +python3 -c "import secrets; print(secrets.token_urlsafe(64))" ``` -## Step 2 — Deploy Frontend to Vercel - -1. Go to https://vercel.com and sign up with GitHub -2. Click "Add New Project" → import your `portfolio` repo -3. **Framework Preset:** Vite -4. **Root Directory:** `.` (the project root) -5. **Build Command:** `npm run build` -6. **Output Directory:** `dist` -7. **Environment Variables — add:** - - `VITE_API_URL` = `https://your-backend.railway.app` (fill in after Step 3) -8. Click Deploy - -Your site will be live at `mohamedalderdiry.vercel.app` (or similar). - -## Step 3 — Deploy Backend to Railway - -1. Go to https://railway.app and sign up with GitHub -2. Click "New Project" → "Deploy from GitHub repo" → select `portfolio` -3. **Root Directory:** `backend` -4. Railway will detect the Dockerfile automatically -5. Add a **PostgreSQL** service: click "New" → "Database" → "PostgreSQL" -6. In the backend service, add these **Environment Variables:** - - `DATABASE_URL` = (click the PostgreSQL service → "Connect" → copy the URL, change `postgresql://` to `postgresql+asyncpg://`) - - `DATABASE_URL_SYNC` = (same URL but keep `postgresql://`) - - `JWT_SECRET` = (generate a random 32-char string) - - `ADMIN_USERNAME` = `admin` - - `ADMIN_PASSWORD` = (your secure password) - - `CORS_ORIGINS` = `https://mohamedalderdiry.vercel.app` - - `CONTACT_RECIPIENT` = `mod.derdiry@gmail.com` -7. Deploy — Railway will build the Docker image and run the server - -## Step 4 — Connect Frontend to Backend - -1. In Vercel dashboard → your project → Settings → Environment Variables -2. Update `VITE_API_URL` to your Railway backend URL (e.g. `https://portfolio-backend.railway.app`) -3. Redeploy: Vercel dashboard → Deployments → "Redeploy" - -## Step 5 — Custom Domain (optional, do later) - -1. In Vercel dashboard → your project → Settings → Domains -2. Click "Buy Domain" to purchase `mohamedalderdiry.com` (~$10/yr) -3. Vercel handles DNS automatically +`ADMIN_USERNAME` / `ADMIN_PASSWORD` are not needed while the admin user exists in the database. +SMTP variables are not set: Render's free tier blocks outbound SMTP ports (25, 465, 587), so contact +form messages are saved in the `contact_messages` table without an email notification. + +Free-tier behaviour: the service sleeps after 15 minutes without traffic and takes about a minute to +wake. Free instance hours (750 per month) are shared by all free services in the Render workspace. + +## 3. Frontend: Vercel + +- Framework preset: Vite. Build command: `npm run build`. Output directory: `dist`. +- Environment variable `VITE_API_URL` = the Render service URL (e.g. `https://portfolio-api.onrender.com`). +- `vercel.json` proxies `/uploads/:file` to `/api/uploads/:file`; keep it pointed at the same backend. +- Redeploy after changing `VITE_API_URL` (it is read at build time). + +## 4. Admin password + +Passwords are stored as bcrypt hashes. To reset the admin password, run from your machine with the +Neon connection string in `.env.local` (the script prompts for the new password and never prints it): + +```bash +cd backend +pip install -r requirements.txt +python -m app.services.reset_admin_password +``` ## Local development +`.env.local` points the backend at whichever Neon branch is checked out, so work on a dev branch, +not `production` (new branches expire after 7 days, per `neon.ts`): + ```bash -# Start backend + database -docker compose up -d +neon checkout dev --create # copies production; pulls its env into .env.local +cd backend && pip install -r requirements.txt +uvicorn main:app --reload # http://localhost:8000 +npm run dev # in the project root, http://localhost:5173 +neon checkout production # switch back when done +``` -# Start frontend +Fully local alternative (Postgres in Docker; uses `backend/.env`, see `backend/.env.example`): + +```bash +docker compose up -d npm run dev ``` diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..1c184ef --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,21 @@ +# Secrets and local env files +.env +.env.* +!.env.example + +# Local virtualenvs +.venv +venv + +# Python caches and test artifacts +**/__pycache__ +**/*.pyc +**/*.pyo +**/.pytest_cache +**/.mypy_cache +**/.ruff_cache +.coverage +htmlcov + +# Local uploads (files are stored in Postgres) +uploads/ diff --git a/backend/app/api/admin.py b/backend/app/api/admin.py index 4c9d80f..ed5f847 100644 --- a/backend/app/api/admin.py +++ b/backend/app/api/admin.py @@ -1,5 +1,4 @@ import io -import os import uuid from collections import defaultdict from datetime import datetime, timedelta, timezone @@ -12,7 +11,7 @@ from app.core.database import get_db from app.core.security import get_current_user -from app.models.models import Analytics, Education, Experience, Profile, Project, Skill +from app.models.models import Analytics, Education, Experience, Profile, Project, Skill, UploadedFile from app.schemas.schemas import ( AnalyticsStats, EducationCreate, @@ -70,6 +69,27 @@ async def update_profile( ) +# ── Stored files ───────────────────────────────────────────────────────────── + +def _jpeg_bytes(image: Image.Image, quality: int) -> bytes: + buf = io.BytesIO() + image.save(buf, "JPEG", quality=quality) + return buf.getvalue() + + +async def _store_file(db: AsyncSession, filename: str, content_type: str, data: bytes) -> str: + """Insert or replace a file in Postgres (caller commits); returns its public URL.""" + await db.merge(UploadedFile(filename=filename, content_type=content_type, data=data)) + return f"/uploads/{filename}" + + +async def _delete_file(db: AsyncSession, url: str | None) -> None: + if url and url.startswith("/uploads/"): + stored = await db.get(UploadedFile, url.removeprefix("/uploads/")) + if stored: + await db.delete(stored) + + # ── Photo Upload ───────────────────────────────────────────────────────────── @router.post("/profile/photo", response_model=PhotoUploadResponse) @@ -90,19 +110,15 @@ async def upload_profile_photo( # Resize to 400x400 image = image.resize((400, 400), Image.LANCZOS) - # Save to uploads/ - filename = f"{uuid.uuid4().hex}.jpg" - os.makedirs("uploads", exist_ok=True) - image.save(os.path.join("uploads", filename), "JPEG", quality=90) + photo_url = await _store_file(db, f"{uuid.uuid4().hex}.jpg", "image/jpeg", _jpeg_bytes(image, 90)) - photo_url = f"/uploads/{filename}" - - # Update profile + # Update profile, dropping the photo it replaces result = await db.execute(select(Profile).where(Profile.id == 1)) profile = result.scalar_one_or_none() if profile: + await _delete_file(db, profile.photo_url) profile.photo_url = photo_url - await db.commit() + await db.commit() return PhotoUploadResponse(photo_url=photo_url) @@ -116,11 +132,9 @@ async def upload_resume( ): if not file.content_type or file.content_type != "application/pdf": raise HTTPException(status_code=400, detail="File must be a PDF") - content = await file.read() - os.makedirs("uploads", exist_ok=True) - with open("uploads/resume.pdf", "wb") as f: - f.write(content) - return PhotoUploadResponse(photo_url="/uploads/resume.pdf") + resume_url = await _store_file(db, "resume.pdf", "application/pdf", await file.read()) + await db.commit() + return PhotoUploadResponse(photo_url=resume_url) # ── Projects ───────────────────────────────────────────────────────────────── @@ -184,12 +198,12 @@ async def upload_project_screenshot( image = Image.open(io.BytesIO(content)).convert("RGB") image.thumbnail((1280, 960), Image.LANCZOS) - filename = f"screenshot_{project_id}_{uuid.uuid4().hex[:8]}.jpg" - os.makedirs("uploads", exist_ok=True) - image.save(os.path.join("uploads", filename), "JPEG", quality=85) + url = await _store_file( + db, f"screenshot_{project_id}_{uuid.uuid4().hex[:8]}.jpg", "image/jpeg", _jpeg_bytes(image, 85) + ) screenshots = list(project.screenshots or []) - screenshots.append(f"/uploads/{filename}") + screenshots.append(url) project.screenshots = screenshots flag_modified(project, "screenshots") await db.commit() @@ -212,12 +226,9 @@ async def delete_project_screenshot( screenshots = [s for s in (project.screenshots or []) if s != url] project.screenshots = screenshots flag_modified(project, "screenshots") + await _delete_file(db, url) await db.commit() - filepath = os.path.join("uploads", filename) - if os.path.exists(filepath): - os.remove(filepath) - await db.refresh(project) return project diff --git a/backend/app/api/public.py b/backend/app/api/public.py index 347e026..153ee9c 100644 --- a/backend/app/api/public.py +++ b/backend/app/api/public.py @@ -1,14 +1,13 @@ -import os from collections import defaultdict from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Request, status -from fastapi.responses import FileResponse +from fastapi.responses import Response from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db -from app.models.models import Analytics, ContactMessage, Education, Experience, Profile, Project, Skill +from app.models.models import Analytics, ContactMessage, Education, Experience, Profile, Project, Skill, UploadedFile from app.schemas.schemas import ( AnalyticsStats, ContactRequest, @@ -122,11 +121,13 @@ async def contact(body: ContactRequest, db: AsyncSession = Depends(get_db)): # ── Uploads (served via API so CORSMiddleware adds headers) ────────────────── @router.get("/uploads/{filename}") -async def serve_upload(filename: str): - path = os.path.join("uploads", filename) - if not os.path.exists(path): +async def serve_upload(filename: str, db: AsyncSession = Depends(get_db)): + stored = await db.get(UploadedFile, filename) + if not stored: raise HTTPException(status_code=404, detail="File not found") - return FileResponse(path) + # Photos and screenshots get unique names; resume.pdf is replaced in place. + cache = "no-cache" if filename == "resume.pdf" else "public, max-age=86400" + return Response(content=stored.data, media_type=stored.content_type, headers={"Cache-Control": cache}) # ── Analytics ──────────────────────────────────────────────────────────────── diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 82fded1..048105a 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,26 +1,48 @@ +from pathlib import Path +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + from pydantic_settings import BaseSettings, SettingsConfigDict +BACKEND_DIR = Path(__file__).resolve().parents[2] +# Later files win: the repo-root .env.local (written by `neon deploy`) overrides backend/.env. +ENV_FILES = (BACKEND_DIR / ".env", BACKEND_DIR.parent / ".env.local") + + +def to_asyncpg(url: str) -> tuple[str, dict]: + """Convert a libpq-style URL (e.g. Neon's) into a SQLAlchemy asyncpg URL plus connect_args. + + asyncpg rejects libpq query params: sslmode becomes asyncpg's `ssl` argument, + and channel_binding (unsupported by asyncpg) is dropped; TLS is still enforced via ssl. + """ + parts = urlsplit(url) + query = dict(parse_qsl(parts.query)) + sslmode = query.pop("sslmode", None) + query.pop("channel_binding", None) + scheme = "postgresql+asyncpg" if parts.scheme in ("postgres", "postgresql", "postgresql+asyncpg") else parts.scheme + connect_args = {"ssl": sslmode} if sslmode and sslmode != "disable" else {} + return urlunsplit((scheme, parts.netloc, parts.path, urlencode(query), parts.fragment)), connect_args + class Settings(BaseSettings): - model_config = SettingsConfigDict(env_file=".env", extra="ignore") + model_config = SettingsConfigDict(env_file=ENV_FILES, extra="ignore") database_url: str = "postgresql+asyncpg://portfolio:portfolio@localhost:5432/portfolio" + # Neon's direct (non-pooled) endpoint. Preferred when set: this is one long-running + # process with its own SQLAlchemy pool that also runs DDL at startup. + database_url_unpooled: str = "" database_url_sync: str = "postgresql://portfolio:portfolio@localhost:5432/portfolio" - def __init__(self, **data): - super().__init__(**data) - # Railway injects postgresql:// but asyncpg needs postgresql+asyncpg:// - if self.database_url.startswith("postgresql://"): - self.database_url = self.database_url.replace("postgresql://", "postgresql+asyncpg://", 1) - if self.database_url.startswith("postgres://"): - self.database_url = self.database_url.replace("postgres://", "postgresql+asyncpg://", 1) + @property + def async_database(self) -> tuple[str, dict]: + return to_asyncpg(self.database_url_unpooled or self.database_url) jwt_secret: str = "dev-secret-change-in-production" jwt_algorithm: str = "HS256" jwt_expire_minutes: int = 1440 admin_username: str = "admin" - admin_password: str = "admin" + # Only used by the seed to create the first admin user; no insecure default. + admin_password: str = "" smtp_host: str = "smtp.gmail.com" smtp_port: int = 587 diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 0b67be3..708b94d 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -3,7 +3,8 @@ from app.core.config import settings -engine = create_async_engine(settings.database_url, echo=False, pool_pre_ping=True) +database_url, connect_args = settings.async_database +engine = create_async_engine(database_url, connect_args=connect_args, echo=False, pool_pre_ping=True) AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 435a4e2..5ed8450 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone from typing import Any -from sqlalchemy import Boolean, DateTime, Integer, String, Text +from sqlalchemy import Boolean, DateTime, Integer, LargeBinary, String, Text from sqlalchemy.dialects.postgresql import JSON from sqlalchemy.orm import Mapped, mapped_column @@ -122,3 +122,14 @@ class ContactMessage(Base): subject: Mapped[str] = mapped_column(String(300)) message: Mapped[str] = mapped_column(Text) sent_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + +class UploadedFile(Base): + """Uploaded files (profile photo, resume, screenshots) stored in Postgres so they + survive restarts on hosts with ephemeral disks. Served at /api/uploads/{filename}.""" + __tablename__ = "uploaded_files" + + filename: Mapped[str] = mapped_column(String(200), primary_key=True) + content_type: Mapped[str] = mapped_column(String(100)) + data: Mapped[bytes] = mapped_column(LargeBinary) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) diff --git a/backend/app/services/seed.py b/backend/app/services/seed.py index e83622b..22c6588 100644 --- a/backend/app/services/seed.py +++ b/backend/app/services/seed.py @@ -296,8 +296,11 @@ async def seed() -> None: # Admin user result = await db.execute(select(User).where(User.username == settings.admin_username)) if not result.scalar_one_or_none(): - db.add(User(username=settings.admin_username, password_hash=hash_password(settings.admin_password))) - print(f"Created admin user: {settings.admin_username}") + if settings.admin_password: + db.add(User(username=settings.admin_username, password_hash=hash_password(settings.admin_password))) + print(f"Created admin user: {settings.admin_username}") + else: + print("No admin user and ADMIN_PASSWORD is unset; skipping admin creation.") # Profile result = await db.execute(select(Profile).where(Profile.id == 1)) diff --git a/backend/main.py b/backend/main.py index ada0cfd..60ba8a6 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,4 +1,3 @@ -import os from contextlib import asynccontextmanager from fastapi import FastAPI @@ -48,8 +47,6 @@ async def lifespan(app: FastAPI): app.include_router(auth.router, prefix="/api/auth") app.include_router(admin.router, prefix="/api/admin") -os.makedirs("uploads", exist_ok=True) - @app.get("/health") async def health(): diff --git a/backend/railway.toml b/backend/railway.toml index e5dd998..87877a9 100644 --- a/backend/railway.toml +++ b/backend/railway.toml @@ -1,3 +1,5 @@ +# UNUSED: the backend moved to Render + Neon (see render.yaml and DEPLOY.md). +# Kept for reference only; Railway no longer hosts this project. [build] builder = "DOCKERFILE" dockerfilePath = "Dockerfile" diff --git a/neon.ts b/neon.ts new file mode 100644 index 0000000..22b8e20 --- /dev/null +++ b/neon.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "@neon/config/v1"; + +export default defineConfig({ + // Declare your Neon services here + auth: false, + // Branch policy: per-branch tuning + branch: (branch) => { + if (branch.isDefault) { + // Default branch: no overrides, uses project defaults + return {}; + } + if (!branch.exists) { + // New non-default branches: auto-expire + // Run `neon checkout ` to create a new branch with these settings + return { ttl: "7d" }; + } + // Existing branch: no changes + return {}; + }, +}); diff --git a/package-lock.json b/package-lock.json index 926aaf0..9ac1feb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "portfolio", "version": "1.0.0", "dependencies": { + "@neon/config": "^1.10.0", + "@neon/env": "^1.6.8", "framer-motion": "^11.2.10", "lucide-react": "^0.400.0", "qrcode.react": "^4.2.0", @@ -765,6 +767,293 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@neon/config": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@neon/config/-/config-1.10.0.tgz", + "integrity": "sha512-Uvh9F6tY6HsZeEKU9Bd8WZVClBVc26xPGiLm11Zypox2hMiql/iof5VUsbf/18YaAx3Fw8dk1w1RsYWNEx12mw==", + "license": "Apache-2.0", + "dependencies": { + "@neon/sdk": "7.7.0", + "jiti": "^2.7.0", + "zod": "^4.5.4" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@neon/config/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@neon/env": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@neon/env/-/env-1.6.8.tgz", + "integrity": "sha512-sQ1+EoI25zZpLjJCpj2PQnmGrPk5mx6nVNyAyVg5bvzVsTXs9vvj2YbVW8qR1YjXcgSZNoU/PFvhlWFBN+hrSg==", + "license": "Apache-2.0", + "dependencies": { + "@neon/config": "1.10.0", + "yargs": "^18.0.0", + "zod": "^4.5.4" + }, + "bin": { + "neon-env": "dist/cli.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@napi-rs/keyring": "1.3.0" + } + }, + "node_modules/@neon/sdk": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@neon/sdk/-/sdk-7.7.0.tgz", + "integrity": "sha512-ncGRkf+E58UEJ3U4Y1MOo6f/fyjOqzjqa3If25uYfDK6VDmRdm7ylrpsENsdZZ3vVnAO6sDnRqMHS0jIisC8XA==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1387,6 +1676,30 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/ansi-regex": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz", + "integrity": "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -1594,6 +1907,37 @@ "node": ">= 6" } }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -1805,6 +2149,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -1868,7 +2218,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2009,6 +2358,27 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2926,6 +3296,37 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3246,12 +3647,90 @@ } } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yargs": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.2.0.tgz", + "integrity": "sha512-9OpKOLeaoNFecEp7P6iYbzze/5CqWoH7N3SMs/6y1XF4nMvFMspEGZzJ6uFi9MmQwXhyzqYoSEKO3tvA3Z/o2w==", + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 1c44a70..ffbc893 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "@neon/config": "^1.10.0", + "@neon/env": "^1.6.8", "framer-motion": "^11.2.10", "lucide-react": "^0.400.0", "qrcode.react": "^4.2.0", diff --git a/railway.toml b/railway.toml index f81ba98..2e0a4b8 100644 --- a/railway.toml +++ b/railway.toml @@ -1,3 +1,5 @@ +# UNUSED: the backend moved to Render + Neon (see render.yaml and DEPLOY.md). +# Kept for reference only; Railway no longer hosts this project. [build] builder = "DOCKERFILE" dockerfilePath = "backend/Dockerfile" diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..563118c --- /dev/null +++ b/render.yaml @@ -0,0 +1,24 @@ +# Render Blueprint for the portfolio API (FastAPI). Database: Neon Postgres (aws-eu-west-2). +# Secrets use `sync: false`: Render prompts for them when the service is first created +# and never reads them from this file. See DEPLOY.md. +services: + - type: web + name: portfolio-api + runtime: python + plan: free + region: frankfurt # closest Render region to the Neon project (London) + branch: main + rootDir: backend + buildCommand: pip install -r requirements.txt + startCommand: uvicorn main:app --host 0.0.0.0 --port $PORT + healthCheckPath: /health + autoDeployTrigger: commit + envVars: + - key: PYTHON_VERSION + value: 3.10.12 # matches the local environment the backend is tested on + - key: DATABASE_URL_UNPOOLED # Neon direct connection string + sync: false + - key: JWT_SECRET + sync: false + - key: CORS_ORIGINS + value: https://www.moderdiry.com,https://moderdiry.com