diff --git a/GITEA-SETUP.md b/GITEA-SETUP.md new file mode 100644 index 0000000..a57431a --- /dev/null +++ b/GITEA-SETUP.md @@ -0,0 +1,53 @@ +# Gitea + 1Password setup + +One-time steps to push to Gitea (http://10.0.10.205/) using your 1Password SSH key. + +## One-time setup + +1. **Create SSH config for Gitea** (from project root): + ```bash + ./scripts/setup-gitea-ssh.sh + ``` + This creates `~/.ssh/gitea-1password-only` so Git uses 1Password for `gitea@10.0.10.205`. + +2. **Enable 1Password SSH agent** + In 1Password: **Settings → Developer** → enable **Use the SSH agent**. + +3. **Optional: sign commits with your SSH key** + Add the following to your `~/.gitconfig` (and set `name` / `email` if not already set): + + ```ini + [user] + name = bert.hausmans + email = bert@hausmans.nl + signingkey = ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIw+E4aOsaDPBruF6PBjloZNaVS3jHVOTXTv9GN/LY5H + + [gpg] + format = ssh + + [gpg "ssh"] + program = "/Applications/1Password.app/Contents/MacOS/op-ssh-sign" + + [commit] + gpgsign = true + ``` + + Then every commit will be signed with your 1Password SSH key (1Password may prompt when signing). + +## Pushing + +- **From Cursor**: Use the usual Push action. Git uses the repo’s `core.sshCommand`, which points at 1Password. +- **From Terminal**: Run `./gitea-push.sh` or `git push` from the project root. + +**Terminal asking for a password instead of 1Password?** Re-run the setup so SSH uses the 1Password agent: `./scripts/setup-gitea-ssh.sh`, then try `./gitea-push.sh` again from Terminal.app. + +**1Password not popping up?** The approval dialog usually only appears when the request comes from **Terminal.app** or **iTerm**, not from Cursor’s integrated terminal. Run `./gitea-push.sh` from Terminal.app (or iTerm) so 1Password can show the prompt. + +**First push in a session:** 1Password may need to approve use of the SSH key once. If Cursor’s Push hangs or fails, run this in **Terminal.app** (so 1Password can show the approval dialog): + +```bash +export SSH_AUTH_SOCK="$HOME/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock" +ssh -T gitea@10.0.10.205 +``` + +Approve in 1Password when asked, then push again from Cursor or `./gitea-push.sh`. diff --git a/admin/.dockerignore b/admin/.dockerignore new file mode 100644 index 0000000..63d4085 --- /dev/null +++ b/admin/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitignore +.env +.env.* +!.env.example +node_modules +dist +.idea +.vscode +*.log +Dockerfile +.dockerignore +README.md diff --git a/admin/Dockerfile b/admin/Dockerfile new file mode 100644 index 0000000..29b8bb6 --- /dev/null +++ b/admin/Dockerfile @@ -0,0 +1,20 @@ +# Stage 1: build Vue admin SPA +FROM node:20-alpine AS build +WORKDIR /app + +ARG VITE_API_URL=http://10.0.10.189:3001 +ARG VITE_UPLOAD_APP_URL=http://10.0.10.189:3003 +ENV VITE_API_URL=$VITE_API_URL +ENV VITE_UPLOAD_APP_URL=$VITE_UPLOAD_APP_URL + +COPY package.json package-lock.json* ./ +RUN npm ci +COPY . . +RUN npm run build + +# Stage 2: serve with nginx +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx-default.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/admin/nginx-default.conf b/admin/nginx-default.conf new file mode 100644 index 0000000..6f01c39 --- /dev/null +++ b/admin/nginx-default.conf @@ -0,0 +1,9 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 0000000..d91c7f6 --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,20 @@ +.git +.gitignore +.env +.env.* +!.env.example +node_modules +vendor +storage/logs/* +storage/framework/cache/* +storage/framework/sessions/* +storage/framework/views/* +tests +.phpunit.result.cache +.phpunit.cache +.idea +.vscode +*.log +Dockerfile +.dockerignore +README.md diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..4590f12 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,42 @@ +# event-uploader API – Laravel. Same image used for api and queue (different command). +FROM php:8.3-cli-alpine + +# PHP extensions for Laravel + MySQL + Google etc. +RUN apk add --no-cache \ + git \ + unzip \ + libzip-dev \ + libpng-dev \ + libxml2-dev \ + oniguruma-dev \ + && docker-php-ext-install -j$(nproc) \ + pdo_mysql \ + gd \ + fileinfo \ + mbstring \ + xml \ + zip \ + pcntl \ + bcmath + +# Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer +ENV COMPOSER_ALLOW_SUPERUSER=1 + +WORKDIR /app + +# Dependencies first (better layer cache) +COPY composer.json composer.lock ./ +RUN composer install --no-dev --optimize-autoloader --no-interaction + +# Application +COPY . . + +# .env and APP_KEY are provided at runtime via compose + +# Writable dirs (runtime will mount or use defaults) +RUN mkdir -p storage/framework/cache storage/framework/sessions storage/framework/views storage/logs bootstrap/cache \ + && chmod -R 775 storage bootstrap/cache + +EXPOSE 8000 +CMD ["php", "artisan", "serve", "--host=0.0.0.0", "--port=8000"] diff --git a/api/app/Http/Controllers/Admin/GoogleDriveController.php b/api/app/Http/Controllers/Admin/GoogleDriveController.php index bba566d..2448fdf 100644 --- a/api/app/Http/Controllers/Admin/GoogleDriveController.php +++ b/api/app/Http/Controllers/Admin/GoogleDriveController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Models\User; use App\Services\GoogleDrive\GoogleDriveService; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; @@ -53,7 +54,9 @@ class GoogleDriveController extends Controller public function status(): JsonResponse { - $connection = Auth::user()?->googleDriveConnections()->first(); + /** @var User|null $user */ + $user = Auth::user(); + $connection = $user?->googleDriveConnections()?->first(); return response()->json([ 'connected' => (bool) $connection, @@ -63,7 +66,9 @@ class GoogleDriveController extends Controller public function disconnect(): JsonResponse { - Auth::user()?->googleDriveConnections()->delete(); + /** @var User|null $user */ + $user = Auth::user(); + $user?->googleDriveConnections()?->delete(); return response()->json(['message' => 'Disconnected']); } diff --git a/api/app/Http/Controllers/Public/EventUploadController.php b/api/app/Http/Controllers/Public/EventUploadController.php index 61c22e3..82c8432 100644 --- a/api/app/Http/Controllers/Public/EventUploadController.php +++ b/api/app/Http/Controllers/Public/EventUploadController.php @@ -10,6 +10,7 @@ use App\Models\Upload; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Str; @@ -52,7 +53,7 @@ class EventUploadController extends Controller public function upload(Request $request, string $slug): JsonResponse { - \Log::info('Upload request received', [ + Log::info('Upload request received', [ 'slug' => $slug, 'has_file' => $request->hasFile('file'), 'files' => array_keys($request->allFiles()), @@ -76,7 +77,7 @@ class EventUploadController extends Controller $file = $request->file('file'); - \Log::info('File details', [ + Log::info('File details', [ 'original_name' => $file->getClientOriginalName(), 'size' => $file->getSize(), 'mime' => $file->getMimeType(), @@ -112,7 +113,7 @@ class EventUploadController extends Controller $tempPath = $file->storeAs('uploads/temp', $storedName, ['disk' => 'local']); if ($tempPath === false || $tempPath === null) { - \Log::error('File storeAs returned false/null', [ + Log::error('File storeAs returned false/null', [ 'original_name' => $originalName, 'stored_name' => $storedName, 'temp_dir' => $tempDir, @@ -122,7 +123,7 @@ class EventUploadController extends Controller return response()->json(['message' => 'Failed to store file'], 500); } } catch (\Throwable $e) { - \Log::error('File storage exception', [ + Log::error('File storage exception', [ 'error' => $e->getMessage(), 'original_name' => $originalName, 'stored_name' => $storedName, @@ -133,7 +134,7 @@ class EventUploadController extends Controller // Local disk stores in app/private/, so construct full path accordingly $fullPath = storage_path('app/private/'.$tempPath); - \Log::info('File stored successfully', [ + Log::info('File stored successfully', [ 'temp_path' => $tempPath, 'full_path' => $fullPath, 'file_exists' => file_exists($fullPath), diff --git a/api/app/Services/GoogleDrive/GoogleDriveService.php b/api/app/Services/GoogleDrive/GoogleDriveService.php index 884d248..1c01704 100644 --- a/api/app/Services/GoogleDrive/GoogleDriveService.php +++ b/api/app/Services/GoogleDrive/GoogleDriveService.php @@ -203,6 +203,7 @@ class GoogleDriveService $file->setParents([$folderId]); $client->setDefer(true); + /** @var \Psr\Http\Message\RequestInterface $request When defer is true, create() returns the request instead of executing it */ $request = $service->files->create($file, [ 'mimeType' => $mimeType, 'uploadType' => 'resumable', diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..8719751 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,45 @@ +# Deploy event-uploader to Dockge + +Production stack: images from Gitea registry only. Use from Dockge on the home server (10.0.10.189:5001). + +## Build and push images + +Build and upload images to Gitea’s container registry (`10.0.10.205:3000`) from your dev machine so Dockge can pull them. + +1. **One-time:** Allow HTTP registry and log in: + - Docker Desktop (Mac): Settings → Docker Engine → add `"insecure-registries": ["10.0.10.205:3000"]`, Apply. + - Run: `docker login 10.0.10.205:3000` (username: `bert.hausmans`, password: Gitea password or a personal access token with package read/write). +2. **Each release:** From the project root: + - `./scripts/docker-build-push.sh 1.0.0` (or any version; omit to use `latest` or git describe). + - Or manually: set `VERSION=1.0.0`, `REGISTRY=10.0.10.205:3000`, `OWNER=bert.hausmans`, then `docker build -t $REGISTRY/$OWNER/event-uploader-api:$VERSION ./api` (and same for `admin`, `upload`), then `docker push` for each. + +After pushing, deploy on the server: set `TAG=1.0.0` in the stack `.env`, then in Dockge use **Pull** and **Redeploy**. + +## Ports (3000 range to avoid conflicts) + +| Service | Host port | Container | URL (example) | +|---------|-----------|-----------|---------------| +| API | 3001 | 8000 | http://10.0.10.189:3001 | +| Admin | 3002 | 80 | http://10.0.10.189:3002 | +| Upload | 3003 | 80 | http://10.0.10.189:3003 | +| MySQL | 3004 | 3306 | (internal; use 3004 only for direct DB access) | + +## One-time setup in Dockge + +1. Add stack: point Dockge at this repo’s `deploy/` folder (or paste `docker-compose.yml`). +2. Create `.env` in the stack directory (or use Dockge’s env) with at least: + - `TAG=latest` (or e.g. `1.0.0`) + - `DB_PASSWORD=...` + - `DB_DATABASE=event_uploader` + - `APP_KEY=...` (Laravel `php artisan key:generate`) + - `APP_URL=http://10.0.10.189:3001` (or your public URL) + - `SESSION_DOMAIN=10.0.10.189` (or your domain) + - `SANCTUM_STATEFUL_DOMAINS=10.0.10.189:3002,10.0.10.189:3003` + - Google OAuth if used: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` +3. Ensure Docker on the server has `10.0.10.205:3000` in `insecure-registries` and run `docker login 10.0.10.205:3000`. +4. First deploy: Pull, then Start (or `docker compose -f deploy/docker-compose.yml pull && docker compose -f deploy/docker-compose.yml up -d`). + +## Deploy new version + +- In Dockge: open the stack → **Pull** (to fetch new images from Gitea) → **Redeploy** (or Stop + Start). +- Or on the server: set `TAG=1.0.0` in `.env`, then `docker compose pull && docker compose up -d`. diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..2d7d2a3 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,90 @@ +# Production stack for Dockge. Uses images from Gitea registry only (no build). +# Ports in 3000 range to avoid conflicts with other containers on the host. +# Set TAG in .env (e.g. TAG=1.0.0 or TAG=latest). + +services: + api: + image: 10.0.10.205:3000/bert.hausmans/event-uploader-api:${TAG:-latest} + ports: + - "3001:8000" + environment: + - APP_KEY=${APP_KEY} + - APP_ENV=production + - APP_DEBUG=${APP_DEBUG:-false} + - APP_URL=${APP_URL} + - DB_CONNECTION=mysql + - DB_HOST=mysql + - DB_PORT=3306 + - DB_DATABASE=${DB_DATABASE:-event_uploader} + - DB_USERNAME=${DB_USERNAME:-root} + - DB_PASSWORD=${DB_PASSWORD} + - SESSION_DOMAIN=${SESSION_DOMAIN} + - SANCTUM_STATEFUL_DOMAINS=${SANCTUM_STATEFUL_DOMAINS} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} + - GOOGLE_REDIRECT_URI=${GOOGLE_REDIRECT_URI:-} + depends_on: + mysql: + condition: service_healthy + networks: + - event-uploader + + queue: + image: 10.0.10.205:3000/bert.hausmans/event-uploader-api:${TAG:-latest} + command: ["php", "artisan", "queue:work"] + environment: + - APP_KEY=${APP_KEY} + - APP_ENV=production + - DB_CONNECTION=mysql + - DB_HOST=mysql + - DB_PORT=3306 + - DB_DATABASE=${DB_DATABASE:-event_uploader} + - DB_USERNAME=${DB_USERNAME:-root} + - DB_PASSWORD=${DB_PASSWORD} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-} + depends_on: + mysql: + condition: service_healthy + api: + condition: service_started + networks: + - event-uploader + + admin: + image: 10.0.10.205:3000/bert.hausmans/event-uploader-admin:${TAG:-latest} + ports: + - "3002:80" + networks: + - event-uploader + + upload: + image: 10.0.10.205:3000/bert.hausmans/event-uploader-upload:${TAG:-latest} + ports: + - "3003:80" + networks: + - event-uploader + + mysql: + image: mysql:8.0 + ports: + - "3004:3306" + environment: + MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} + MYSQL_DATABASE: ${DB_DATABASE:-event_uploader} + volumes: + - event_uploader_mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${DB_PASSWORD}"] + interval: 5s + timeout: 5s + retries: 10 + networks: + - event-uploader + +volumes: + event_uploader_mysql_data: + +networks: + event-uploader: + driver: bridge diff --git a/gitea-push.sh b/gitea-push.sh new file mode 100644 index 0000000..4b8eca4 --- /dev/null +++ b/gitea-push.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Use this to push to Gitea with 1Password. +# Run from Terminal.app or iTerm (not Cursor's terminal) so 1Password can show its approval dialog. +# Step 1 tests the connection so 1Password can show its approval dialog. +# Step 2 runs git push in the same session (1Password may not prompt again). + +set -e +OP_SOCK="$HOME/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock" + +if [ ! -S "$OP_SOCK" ]; then + echo "1Password SSH agent socket not found at: $OP_SOCK" + echo "Enable it: 1Password → Settings → Developer → Use the SSH agent" + exit 1 +fi + +echo "Step 1: Test SSH (approve in 1Password when it pops up)..." +echo " If nothing pops up, run this script from Terminal.app or iTerm, not Cursor." +export SSH_AUTH_SOCK="$OP_SOCK" +ssh -F "$HOME/.ssh/gitea-1password-only" -T gitea@10.0.10.205 || true + +echo "" +echo "Step 2: Pushing to Gitea (same session = no 1Password prompt)..." +exec git push "$@" diff --git a/scripts/docker-build-push.sh b/scripts/docker-build-push.sh new file mode 100755 index 0000000..4c8a422 --- /dev/null +++ b/scripts/docker-build-push.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Build and push event-uploader Docker images to Gitea container registry. +# One-time: docker login 10.0.10.205:3000 (username: bert.hausmans, password: token or password). +# Usage: ./scripts/docker-build-push.sh [VERSION] +# VERSION defaults to "latest" (or git describe --tags --always if available). +# Run from project root. + +set -e +REGISTRY="${REGISTRY:-10.0.10.205:3000}" +OWNER="${OWNER:-bert.hausmans}" + +cd "$(dirname "$0")/.." +ROOT="$(pwd)" + +if [ -n "$1" ]; then + VERSION="$1" +else + VERSION=$(git describe --tags --always 2>/dev/null || echo "latest") +fi + +echo "Building and pushing images with tag: $VERSION" +echo "Registry: $REGISTRY, Owner: $OWNER" +echo "" + +docker build -t "$REGISTRY/$OWNER/event-uploader-api:$VERSION" "$ROOT/api" +docker push "$REGISTRY/$OWNER/event-uploader-api:$VERSION" + +docker build -t "$REGISTRY/$OWNER/event-uploader-admin:$VERSION" "$ROOT/admin" +docker push "$REGISTRY/$OWNER/event-uploader-admin:$VERSION" + +docker build -t "$REGISTRY/$OWNER/event-uploader-upload:$VERSION" "$ROOT/upload" +docker push "$REGISTRY/$OWNER/event-uploader-upload:$VERSION" + +echo "" +echo "Done. Images pushed:" +echo " $REGISTRY/$OWNER/event-uploader-api:$VERSION" +echo " $REGISTRY/$OWNER/event-uploader-admin:$VERSION" +echo " $REGISTRY/$OWNER/event-uploader-upload:$VERSION" +echo "On Dockge server: set TAG=$VERSION in stack .env, then Pull and Redeploy." diff --git a/scripts/setup-gitea-ssh.sh b/scripts/setup-gitea-ssh.sh new file mode 100644 index 0000000..bbdbe07 --- /dev/null +++ b/scripts/setup-gitea-ssh.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# One-time setup: creates ~/.ssh/gitea-1password-only so git push uses 1Password for Gitea. +# Run from project root: ./scripts/setup-gitea-ssh.sh + +set -e +SSH_DIR="$HOME/.ssh" +CONFIG_FILE="$SSH_DIR/gitea-1password-only" +OP_SOCK="$HOME/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock" + +mkdir -p "$SSH_DIR" + +cat > "$CONFIG_FILE" << 'EOF' +Host 10.0.10.205 + User gitea + HostName 10.0.10.205 + IdentityAgent "/Users/berthausmans/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock" +EOF + +echo "Created $CONFIG_FILE" +echo "" +echo "Next: enable \"Use the SSH agent\" in 1Password (Settings → Developer)." +echo "Then you can push from Cursor or run: ./gitea-push.sh" diff --git a/ssh-1password-diagnose.sh b/ssh-1password-diagnose.sh new file mode 100644 index 0000000..02d9a74 --- /dev/null +++ b/ssh-1password-diagnose.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Run in Terminal. Shows which SSH agent and key are used for Gitea. +# If you see "id_rsa" → SSH is NOT using 1Password (no prompt). +# If you see "SHA256:KY3A6J1..." → SSH is using 1Password. + +OP_AGENT="/Users/berthausmans/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock" + +echo "=== Which agent does SSH use for gitea@10.0.10.205? ===" +echo "" +echo "Running: ssh -v -T gitea@10.0.10.205 2>&1 | grep -E '(identity|agent|Offering|Authentications)'" +echo "" + +ssh -v -T gitea@10.0.10.205 2>&1 | grep -E '(identity file|IdentityAgent|get_agent_identities|Offering public key|Authentications that can continue)' || true + +echo "" +echo "---" +echo "If you see 'id_rsa' above → SSH is using the system agent, NOT 1Password." +echo "If you see 'SHA256:KY3A6J1r8Shvf...' (Offering public key) → 1Password is used." +echo "" +echo "This repo is set to use 1Password for git (core.sshCommand)." +echo "Test Gitea (bypass config, force 1Password):" +echo " SSH_AUTH_SOCK=\"$OP_AGENT\" ssh -F /dev/null -o IdentitiesOnly=yes -o User=gitea -o HostName=10.0.10.205 -T gitea@10.0.10.205" +echo "" +echo "Then: git push -u origin main (uses 1Password via repo config)" +echo "" diff --git a/upload/.dockerignore b/upload/.dockerignore new file mode 100644 index 0000000..63d4085 --- /dev/null +++ b/upload/.dockerignore @@ -0,0 +1,13 @@ +.git +.gitignore +.env +.env.* +!.env.example +node_modules +dist +.idea +.vscode +*.log +Dockerfile +.dockerignore +README.md diff --git a/upload/Dockerfile b/upload/Dockerfile new file mode 100644 index 0000000..e87a2cf --- /dev/null +++ b/upload/Dockerfile @@ -0,0 +1,18 @@ +# Stage 1: build Vue upload SPA +FROM node:20-alpine AS build +WORKDIR /app + +ARG VITE_API_URL=http://10.0.10.189:3001 +ENV VITE_API_URL=$VITE_API_URL + +COPY package.json package-lock.json* ./ +RUN npm ci +COPY . . +RUN npm run build + +# Stage 2: serve with nginx +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx-default.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/upload/nginx-default.conf b/upload/nginx-default.conf new file mode 100644 index 0000000..6f01c39 --- /dev/null +++ b/upload/nginx-default.conf @@ -0,0 +1,9 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + location / { + try_files $uri $uri/ /index.html; + } +}