Compare commits

..

No commits in common. "main" and "feature/gitea-cicd" have entirely different histories.

10 changed files with 102 additions and 128 deletions

View File

@ -12,51 +12,47 @@ jobs:
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
with:
token: ${{ secrets.REGISTRY_TOKEN }}
- name: Login to Gitea Container Registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ vars.REGISTRY_HOST }} -u ${{ secrets.REGISTRY_USER }} --password-stdin
uses: https://github.com/docker/login-action@v3
with:
registry: ${{ vars.REGISTRY_HOST }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push backend
run: |
docker build --pull \
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:main-latest \
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:${{ github.sha }} \
./backend
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:main-latest
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:${{ github.sha }}
uses: https://github.com/docker/build-push-action@v5
with:
context: ./backend
push: true
tags: |
${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:main-latest
${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-backend:${{ github.sha }}
- name: Build and push frontend
uses: https://github.com/docker/build-push-action@v5
with:
context: ./frontend
push: true
tags: |
${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:main-latest
${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:${{ github.sha }}
- name: Pull new images
run: |
docker build --pull \
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:main-latest \
-t ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:${{ github.sha }} \
./frontend
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:main-latest
docker push ${{ vars.REGISTRY_HOST }}/rohskiddo/umbra-frontend:${{ github.sha }}
cd /opt/umbra
docker compose pull backend frontend
- name: Deploy
run: |
# Spawn a short-lived container that mounts the host deploy path
# and runs compose commands against the host Docker daemon.
# DEPLOY_PATH is a Gitea variable — update it when moving hosts.
docker run --rm \
--network host \
--security-opt label:disable \
-v /var/run/docker.sock:/var/run/docker.sock \
-v ${{ vars.DEPLOY_PATH }}:/deploy \
-w /deploy \
docker:27-cli sh -c "
docker compose -p umbra --env-file stack.env pull backend frontend &&
docker compose -p umbra --env-file stack.env up -d db backend frontend
"
cd /opt/umbra
docker compose up -d
- name: Health check
run: |
echo "Waiting for services to start..."
sleep 30
curl -f http://localhost:${{ vars.DEPLOY_PORT }}/health || exit 1
sleep 10
curl -f http://localhost/health || exit 1
- name: Prune old images
if: success()

View File

@ -1,20 +0,0 @@
log:
level: info
runner:
capacity: 1
timeout: 3h
insecure: false
cache:
enabled: false
container:
network: host
privileged: false
options: "--security-opt label:disable"
valid_volumes:
- "**"
host:
workdir_parent: /tmp/act_runner

View File

@ -2,10 +2,7 @@ services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB=${POSTGRES_DB}
env_file: .env
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
@ -24,15 +21,7 @@ services:
backend:
image: git.sentinelforest.xyz/rohskiddo/umbra-backend:main-latest
restart: unless-stopped
environment:
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
- SECRET_KEY=${SECRET_KEY}
- ENVIRONMENT=${ENVIRONMENT:-production}
- UMBRA_URL=${UMBRA_URL:-https://umbra.ghost6.xyz}
- OPENWEATHERMAP_API_KEY=${OPENWEATHERMAP_API_KEY:-}
- WEBAUTHN_RP_ID=${WEBAUTHN_RP_ID:-umbra.ghost6.xyz}
- WEBAUTHN_RP_NAME=${WEBAUTHN_RP_NAME:-UMBRA}
- WEBAUTHN_ORIGIN=${WEBAUTHN_ORIGIN:-https://umbra.ghost6.xyz}
env_file: .env
depends_on:
db:
condition: service_healthy
@ -41,7 +30,7 @@ services:
- frontend_net
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
interval: 30s
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
@ -62,7 +51,7 @@ services:
networks:
- frontend_net
healthcheck:
test: ["CMD", "wget", "--spider", "--quiet", "http://127.0.0.1:8080/"]
test: ["CMD", "wget", "--spider", "--quiet", "http://localhost:8080/"]
interval: 15s
timeout: 5s
retries: 3
@ -72,8 +61,26 @@ services:
memory: 128M
cpus: "0.5"
act_runner:
image: gitea/act_runner:latest
restart: unless-stopped
volumes:
- act_runner_data:/data
- /var/run/docker.sock:/var/run/docker.sock
environment:
- GITEA_INSTANCE_URL=https://git.sentinelforest.xyz
- GITEA_RUNNER_REGISTRATION_TOKEN=${GITEA_RUNNER_REGISTRATION_TOKEN}
- GITEA_RUNNER_NAME=umbra-runner
- GITEA_RUNNER_LABELS=ubuntu-latest:docker://node:20-bookworm
deploy:
resources:
limits:
memory: 256M
cpus: "1.0"
volumes:
postgres_data:
act_runner_data:
networks:
backend_net:

View File

@ -12,8 +12,6 @@ limit_req_zone $binary_remote_addr zone=conn_search_limit:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=conn_request_limit:10m rate=3r/m;
# Event creation recurrence amplification means 1 POST = up to 90-365 child rows
limit_req_zone $binary_remote_addr zone=event_create_limit:10m rate=30r/m;
# Health endpoint lightweight but rate-limited for resilience
limit_req_zone $binary_remote_addr zone=health_limit:1m rate=30r/m;
# Use X-Forwarded-Proto from upstream proxy when present, fall back to $scheme for direct access
map $http_x_forwarded_proto $forwarded_proto {
@ -166,13 +164,6 @@ server {
include /etc/nginx/proxy-params.conf;
}
# Health endpoint proxied to backend for external uptime monitoring
location = /health {
limit_req zone=health_limit burst=5 nodelay;
limit_req_status 429;
include /etc/nginx/proxy-params.conf;
}
# API proxy (catch-all for non-rate-limited endpoints)
location /api {
proxy_set_header Upgrade $http_upgrade;
@ -193,7 +184,7 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}
@ -201,7 +192,7 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# PT-I03: Restrict unnecessary browser APIs
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=(), publickey-credentials-get=(self), publickey-credentials-create=(self)" always;

View File

@ -11,6 +11,6 @@ add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self'; frame-ancestors 'none'; upgrade-insecure-requests;" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data:; font-src 'self' https://fonts.gstatic.com; connect-src 'self';" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=(), publickey-credentials-get=(self), publickey-credentials-create=(self)" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;

View File

@ -591,32 +591,32 @@ export default function EventDetailPanel({
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-7 w-7"
onClick={() => setScopeStep(null)}
title="Cancel"
>
<X className="h-4 w-4" />
<X className="h-3.5 w-3.5" />
</Button>
) : (isEditing || isCreating) ? (
<>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-green-400 hover:text-green-300"
className="h-7 w-7 text-green-400 hover:text-green-300"
onClick={handleEditSave}
disabled={saveMutation.isPending}
title="Save"
>
<Save className="h-4 w-4" />
<Save className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-7 w-7"
onClick={handleEditCancel}
title="Cancel"
>
<X className="h-4 w-4" />
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
@ -628,12 +628,12 @@ export default function EventDetailPanel({
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-7 w-7"
onClick={handleEditStart}
disabled={isAcquiringLock || !!activeLockInfo}
title={activeLockInfo ? `Locked by ${activeLockInfo.locked_by_name || 'another user'}` : 'Edit event'}
>
{isAcquiringLock ? <Loader2 className="h-4 w-4 animate-spin" /> : <Pencil className="h-4 w-4" />}
{isAcquiringLock ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
</Button>
)}
{/* Leave button for invited events */}
@ -641,11 +641,11 @@ export default function EventDetailPanel({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => setShowLeaveDialog(true)}
title="Leave event"
>
<LogOut className="h-4 w-4" />
<LogOut className="h-3.5 w-3.5" />
</Button>
)}
{/* Delete button for own events */}
@ -664,12 +664,12 @@ export default function EventDetailPanel({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={handleDeleteStart}
disabled={deleteMutation.isPending}
title="Delete event"
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-3.5 w-3.5" />
</Button>
)
)}
@ -678,11 +678,11 @@ export default function EventDetailPanel({
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-7 w-7"
onClick={onClose}
title="Close panel"
>
<X className="h-4 w-4" />
<X className="h-3.5 w-3.5" />
</Button>
</>
)}

View File

@ -285,21 +285,21 @@ export default function TaskDetailPanel({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-green-400 hover:text-green-300"
className="h-7 w-7 text-green-400 hover:text-green-300"
onClick={handleEditSave}
disabled={updateTaskMutation.isPending}
title="Save changes"
>
<Save className="h-4 w-4" />
<Save className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-7 w-7"
onClick={handleEditCancel}
title="Cancel editing"
>
<X className="h-4 w-4" />
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
@ -307,30 +307,30 @@ export default function TaskDetailPanel({
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-7 w-7"
onClick={handleEditStart}
title="Edit task"
>
<Pencil className="h-4 w-4" />
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={() => onDelete(task.id)}
title="Delete task"
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-3.5 w-3.5" />
</Button>
{onClose && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-7 w-7"
onClick={onClose}
title="Close panel"
>
<X className="h-4 w-4" />
<X className="h-3.5 w-3.5" />
</Button>
)}
</>
@ -562,7 +562,7 @@ export default function TaskDetailPanel({
{/* Comments */}
<div className="space-y-3">
<div className="flex items-center gap-1.5">
<MessageSquare className="h-4 w-4 text-muted-foreground" />
<MessageSquare className="h-3.5 w-3.5 text-muted-foreground" />
<h4 className="text-[11px] text-muted-foreground uppercase tracking-wider">
Comments
{comments.length > 0 && (

View File

@ -236,21 +236,21 @@ export default function ReminderDetailPanel({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-green-400 hover:text-green-300"
className="h-7 w-7 text-green-400 hover:text-green-300"
onClick={handleEditSave}
disabled={saveMutation.isPending}
title="Save"
>
<Save className="h-4 w-4" />
<Save className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-7 w-7"
onClick={handleEditCancel}
title="Cancel"
>
<X className="h-4 w-4" />
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
@ -259,22 +259,22 @@ export default function ReminderDetailPanel({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 hover:bg-orange-500/10 hover:text-orange-400"
className="h-7 w-7 hover:bg-orange-500/10 hover:text-orange-400"
onClick={() => dismissMutation.mutate()}
disabled={dismissMutation.isPending}
title="Dismiss reminder"
>
<BellOff className="h-4 w-4" />
<BellOff className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-7 w-7"
onClick={handleEditStart}
title="Edit reminder"
>
<Pencil className="h-4 w-4" />
<Pencil className="h-3.5 w-3.5" />
</Button>
{confirmingDelete ? (
<Button
@ -290,22 +290,22 @@ export default function ReminderDetailPanel({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={handleDeleteClick}
disabled={deleteMutation.isPending}
title="Delete reminder"
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-7 w-7"
onClick={onClose}
title="Close panel"
>
<X className="h-4 w-4" />
<X className="h-3.5 w-3.5" />
</Button>
</>
)}

View File

@ -61,7 +61,7 @@ export function EntityDetailPanel<T>({
size="icon"
onClick={onToggleFavourite}
aria-label={isFavourite ? `Remove from ${favouriteLabel}s` : `Add to ${favouriteLabel}s`}
className={`h-8 w-8 ${isFavourite ? 'text-yellow-400' : 'text-muted-foreground'}`}
className={`h-7 w-7 ${isFavourite ? 'text-yellow-400' : 'text-muted-foreground'}`}
>
{isFavourite ? (
<Star className="h-4 w-4 fill-yellow-400" />
@ -75,7 +75,7 @@ export function EntityDetailPanel<T>({
size="icon"
onClick={onClose}
aria-label="Close panel"
className="h-8 w-8"
className="h-7 w-7"
>
<X className="h-4 w-4" />
</Button>

View File

@ -272,21 +272,21 @@ export default function TodoDetailPanel({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-green-400 hover:text-green-300"
className="h-7 w-7 text-green-400 hover:text-green-300"
onClick={handleEditSave}
disabled={saveMutation.isPending}
title="Save"
>
<Save className="h-4 w-4" />
<Save className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-7 w-7"
onClick={handleEditCancel}
title="Cancel"
>
<X className="h-4 w-4" />
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
@ -294,11 +294,11 @@ export default function TodoDetailPanel({
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-7 w-7"
onClick={handleEditStart}
title="Edit todo"
>
<Pencil className="h-4 w-4" />
<Pencil className="h-3.5 w-3.5" />
</Button>
{confirmingDelete ? (
<Button
@ -314,22 +314,22 @@ export default function TodoDetailPanel({
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={handleDeleteClick}
disabled={deleteMutation.isPending}
title="Delete todo"
>
<Trash2 className="h-4 w-4" />
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
className="h-7 w-7"
onClick={onClose}
title="Close panel"
>
<X className="h-4 w-4" />
<X className="h-3.5 w-3.5" />
</Button>
</>
)}