diff --git a/.env.example b/.env.example index c5606a7..2c3ef81 100644 --- a/.env.example +++ b/.env.example @@ -1,15 +1,15 @@ # ============================================================================= -# MAIN CONFIGURATION +# FRONTEND CONFIGURATION # ============================================================================= -BASE_URL=http://localhost:8080 # Public URL (backend + frontend use this) -#CORS_ORIGIN=http://localhost:3000 # Optional: Only set if frontend is on different domain +BASE_URL=http://localhost:3000 +API_URL=http://localhost:8080/api # ============================================================================= -# SERVER +# BACKEND CONFIGURATION # ============================================================================= -PORT=8080 DB_PATH=./data/formera.db JWT_SECRET=your-secure-secret-here # CHANGE IN PRODUCTION! +#CORS_ORIGIN=http://localhost:3000 # Optional: Only set if frontend is on different domain # ============================================================================= # STORAGE @@ -43,4 +43,4 @@ CLEANUP_DRY_RUN=false # ============================================================================= # SEO (optional) # ============================================================================= -NUXT_PUBLIC_INDEXABLE=true # Set to "false" to disallow search engine indexing +PUBLIC_INDEXABLE=true # Set to "false" to disallow search engine indexing diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 316893e..0000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,46 +0,0 @@ -name: Lint - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - lint-frontend: - name: Lint Frontend (Biome) - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Biome - uses: biomejs/setup-biome@v2 - with: - version: latest - - - name: Run Biome - run: biome ci ./frontend - - lint-backend: - name: Lint Backend (Go) - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Setup Go - uses: actions/setup-go@v5 - with: - go-version: '1.24' - - - name: Run golangci-lint - uses: golangci/golangci-lint-action@v6 - with: - version: latest - working-directory: backend - args: --timeout=3m diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cad7ed5..46dd726 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Build and Push Docker Image +name: Build and Push Docker Images on: push: @@ -27,7 +27,7 @@ jobs: run: go test -v -race ./... working-directory: backend - build-and-push: + build-backend: needs: test runs-on: ubuntu-latest permissions: @@ -51,21 +51,69 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata (tags, labels) + - name: Extract metadata (tags, labels) for Backend id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-backend tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=raw,value=latest,enable={{is_default_branch}} - - name: Build and push Docker image + - name: Build and push Backend Docker image uses: docker/build-push-action@v5 with: - context: . + context: ./backend + file: ./backend/Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + build-frontend: + needs: test + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Frontend + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-frontend + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Frontend Docker image + uses: docker/build-push-action@v5 + with: + context: ./frontend + file: ./frontend/Dockerfile push: true platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index e25eed1..0000000 --- a/Dockerfile +++ /dev/null @@ -1,60 +0,0 @@ -# Build Frontend (Nuxt) -FROM node:20-alpine AS frontend-builder - -RUN corepack enable && corepack prepare yarn@4.9.4 --activate - -WORKDIR /app - -COPY frontend/package.json frontend/yarn.lock frontend/.yarnrc.yml ./ -RUN yarn install --immutable || yarn install - -COPY frontend/ . - -RUN yarn build - -# Build Backend -FROM golang:1.24-alpine AS backend-builder - -RUN apk add --no-cache gcc musl-dev - -WORKDIR /app - -COPY backend/go.mod backend/go.sum ./ -RUN go mod download - -COPY backend/ . - -RUN CGO_ENABLED=1 GOOS=linux go build -a -ldflags '-linkmode external -extldflags "-static"' -o server ./cmd/server - -# Final Image -FROM node:20-alpine - -RUN apk add --no-cache ca-certificates tzdata nginx - -WORKDIR /app - -# Copy backend binary -COPY --from=backend-builder /app/server . - -# Copy frontend build (Nuxt SSR output) -COPY --from=frontend-builder /app/.output ./.output - -# Copy nginx config -COPY docker/nginx.conf /etc/nginx/http.d/default.conf - -# Create data directory -RUN mkdir -p /app/data - -# Copy startup script -COPY docker/start.sh /app/start.sh -RUN chmod +x /app/start.sh - -# Environment variables (internal ports, not configurable) -ENV PORT=8080 -ENV NITRO_PORT=3000 - -EXPOSE 80 - -VOLUME ["/app/data"] - -CMD ["/app/start.sh"] diff --git a/README.md b/README.md index 8ec11a2..b0d464b 100644 --- a/README.md +++ b/README.md @@ -26,33 +26,32 @@ Self-hosted form builder. Privacy-friendly alternative to Google Forms. ## Quick Start -### Docker - -```bash -docker run -d \ - -p 8080:80 \ - -v formera-data:/app/data \ - -e BASE_URL=https://forms.example.com \ - -e JWT_SECRET=your-secure-secret-here \ - ghcr.io/formeraapp/formera:latest -``` - -### Docker Compose +### Docker Compose (Recommended) ```yaml services: - formera: - image: ghcr.io/formeraapp/formera:latest - container_name: formera + backend: + image: ghcr.io/formeraapp/formera-backend:latest + container_name: formera-backend restart: unless-stopped - environment: - - BASE_URL=https://forms.example.com - - JWT_SECRET=your-secure-secret-here - # - CORS_ORIGIN=https://other-domain.com # Optional: only if frontend is on different domain + ports: + - "8080:8080" volumes: - formera-data:/app/data + environment: + - JWT_SECRET=your-secure-secret-here # CHANGE IN PRODUCTION! + + frontend: + image: ghcr.io/formeraapp/formera-frontend:latest + container_name: formera-frontend + restart: unless-stopped ports: - - "8080:80" + - "3000:3000" + environment: + - NUXT_PUBLIC_BASE_URL=http://localhost:3000 + - NUXT_PUBLIC_API_URL=http://localhost:8080/api + depends_on: + - backend volumes: formera-data: @@ -62,13 +61,33 @@ volumes: docker compose up -d ``` -Access at `http://localhost`. Setup wizard appears on first start. +Access at `http://localhost:3000`. Setup wizard appears on first start. + +### Docker (Separate Containers) + +**Backend:** +```bash +docker run -d \ + -p 8080:8080 \ + -v formera-data:/app/data \ + -e JWT_SECRET=your-secure-secret-here \ + ghcr.io/formeraapp/formera-backend:latest +``` + +**Frontend:** +```bash +docker run -d \ + -p 3000:3000 \ + -e NUXT_PUBLIC_BASE_URL=http://localhost:3000 \ + -e NUXT_PUBLIC_API_URL=http://localhost:8080/api \ + ghcr.io/formeraapp/formera-frontend:latest +``` ### Development ```bash # Backend -cd backend && go run ./cmd/server +cd backend && go run ./cmd/server serve # Frontend cd frontend && yarn install && yarn dev @@ -76,20 +95,21 @@ cd frontend && yarn install && yarn dev ## Configuration -### Main +### Frontend | Variable | Description | Default | |----------|-------------|---------| -| `BASE_URL` | Public URL of the application | `http://localhost:8080` | -| `CORS_ORIGIN` | Allowed origin (optional, defaults to BASE_URL) | - | -| `JWT_SECRET` | JWT signing key (change in production!) | - | +| `NUXT_PUBLIC_BASE_URL` | Public URL of the frontend | `http://localhost:3000` | +| `NUXT_PUBLIC_API_URL` | Backend API URL | `http://localhost:8080/api` | -### Server +### Backend | Variable | Description | Default | |----------|-------------|---------| | `PORT` | Backend port | `8080` | | `DB_PATH` | SQLite database path | `./data/formera.db` | +| `JWT_SECRET` | JWT signing key (change in production!) | - | +| `CORS_ORIGIN` | Allowed origin (optional) | - | ### Storage @@ -131,7 +151,41 @@ cd frontend && yarn install && yarn dev | Variable | Description | Default | |----------|-------------|---------| -| `NUXT_PUBLIC_INDEXABLE` | Allow search engine indexing | `true` | +| `PUBLIC_INDEXABLE` | Allow search engine indexing | `true` | + +## Production Setup with Reverse Proxy + +Example with Traefik: + +```yaml +services: + backend: + image: ghcr.io/formeraapp/formera-backend:latest + restart: unless-stopped + volumes: + - formera-data:/app/data + environment: + - JWT_SECRET=${JWT_SECRET} + - CORS_ORIGIN=https://forms.example.com + labels: + - "traefik.enable=true" + - "traefik.http.routers.formera-api.rule=Host(`forms.example.com`) && PathPrefix(`/api`)" + - "traefik.http.services.formera-api.loadbalancer.server.port=8080" + + frontend: + image: ghcr.io/formeraapp/formera-frontend:latest + restart: unless-stopped + environment: + - NUXT_PUBLIC_BASE_URL=https://forms.example.com + - NUXT_PUBLIC_API_URL=https://forms.example.com/api + labels: + - "traefik.enable=true" + - "traefik.http.routers.formera.rule=Host(`forms.example.com`)" + - "traefik.http.services.formera.loadbalancer.server.port=3000" + +volumes: + formera-data: +``` ## Testing diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..a9097bf --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,37 @@ +# Build Backend +FROM golang:1.24-alpine AS builder + +RUN apk add --no-cache gcc musl-dev + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=1 GOOS=linux go build -a -ldflags '-linkmode external -extldflags "-static"' -o server ./cmd/server + +# Final Image +FROM alpine:latest + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR /app + +COPY --from=builder /app/server . + +RUN mkdir -p /app/data + +# Create non-root user and group, set ownership +RUN addgroup -S app && adduser -S app -G app \ + && chown -R app:app /app /app/data + +ENV PORT=8080 + +EXPOSE 8080 + +VOLUME ["/app/data"] + +USER app +CMD ["./server", "serve"] diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c950c6c..3be996e 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -57,7 +57,6 @@ func main() { ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, })) - // Serve static files for local storage if cfg.Storage.GetStorageType() == "local" { r.Static("/uploads", cfg.Storage.LocalPath) @@ -180,9 +179,9 @@ func initStorage(cfg *config.Config) (storage.Storage, error) { return s3Store, nil default: - // Build full URL for local storage (BaseURL + LocalURL path) - baseURL := cfg.BaseURL + cfg.Storage.LocalURL - return storage.NewLocalStorage(cfg.Storage.LocalPath, baseURL) + // Build full URL for local storage (ApiURL + LocalURL path) + apiURL := cfg.ApiURL + cfg.Storage.LocalURL + return storage.NewLocalStorage(cfg.Storage.LocalPath, apiURL) } } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index df4bc19..ce18cbe 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -31,7 +31,8 @@ func init() { type Config struct { Port string - BaseURL string // Public URL of the backend (e.g., http://localhost:8080) + BaseURL string // Frontend URL (e.g., http://localhost:3000) + ApiURL string // Backend API URL (e.g., http://localhost:8080/api) DBPath string JWTSecret string CorsOrigin string @@ -72,8 +73,8 @@ type StorageConfig struct { S3PresignDuration time.Duration // Optional: presigned URL duration // Migration settings - MigrateOnStart bool // Auto-migrate local files to S3 when S3 is enabled - DeleteAfterMigrate bool // Delete local files after successful migration + MigrateOnStart bool // Auto-migrate local files to S3 when S3 is enabled + DeleteAfterMigrate bool // Delete local files after successful migration } // IsS3Configured returns true if S3 credentials are configured @@ -98,17 +99,18 @@ func Load() *Config { cleanupMinAge, _ := strconv.Atoi(getEnv("CLEANUP_MIN_AGE_DAYS", "7")) port := getEnv("PORT", "8080") - baseURL := getEnv("BASE_URL", "http://localhost:"+port) + baseURL := getEnv("BASE_URL", "http://localhost:3000") + apiURL := getEnv("API_URL", "http://localhost:"+port+"/api") // CORS_ORIGIN defaults to BASE_URL if not set (same-origin deployment) corsOrigin := getEnv("CORS_ORIGIN", "") if corsOrigin == "" { corsOrigin = baseURL } - return &Config{ Port: port, BaseURL: baseURL, + ApiURL: apiURL, DBPath: getEnv("DB_PATH", "./data/formera.db"), JWTSecret: getEnv("JWT_SECRET", "change-me-in-production-please"), CorsOrigin: corsOrigin, diff --git a/docker/nginx.conf b/docker/nginx.conf deleted file mode 100644 index 54bf29d..0000000 --- a/docker/nginx.conf +++ /dev/null @@ -1,38 +0,0 @@ -server { - listen 80; - server_name localhost; - - # Gzip compression - gzip on; - gzip_vary on; - gzip_min_length 1024; - gzip_proxied expired no-cache no-store private auth; - gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript; - - # API proxy to backend - location /api/ { - proxy_pass http://127.0.0.1:8080/api/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - - # Nuxt SSR proxy - location / { - proxy_pass http://127.0.0.1:3000; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } - - # Security headers - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; -} diff --git a/docker/start.sh b/docker/start.sh deleted file mode 100644 index 41f1a4c..0000000 --- a/docker/start.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh - -# Start nginx -nginx - -# Start backend -./server & - -# Start nuxt -node .output/server/index.mjs & - -# Wait for any process to exit -wait -n - -# Exit with status of process that exited first -exit $? diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..11c6a1a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,34 @@ +# Build Frontend (Nuxt) +FROM node:20-alpine AS builder + +RUN corepack enable && corepack prepare yarn@4.9.4 --activate + +WORKDIR /app + +COPY package.json yarn.lock .yarnrc.yml ./ +RUN yarn install --immutable || yarn install + +COPY . . + +RUN yarn build + +# Final Image +FROM node:20-alpine + +RUN apk add --no-cache ca-certificates tzdata + +WORKDIR /app + +COPY --from=builder /app/.output ./.output + +# Environment variables (override with NUXT_PUBLIC_BASE_URL and NUXT_PUBLIC_API_URL) +ENV NUXT_PUBLIC_BASE_URL=http://localhost:3000 +ENV NUXT_PUBLIC_API_URL=http://localhost:8080/api +ENV NITRO_PORT=3000 + +EXPOSE 3000 + +RUN addgroup -S app && adduser -S app -G app \ + && chown -R app:app /app +USER app +CMD ["node", ".output/server/index.mjs"] diff --git a/frontend/app/app.vue b/frontend/app/app.vue index 5b19ce4..355a7e3 100644 --- a/frontend/app/app.vue +++ b/frontend/app/app.vue @@ -1,14 +1,30 @@ + + \ No newline at end of file diff --git a/frontend/app/components/FormFields/FileUploadField.vue b/frontend/app/components/FormFields/FileUploadField.vue index b721fe7..937d26f 100644 --- a/frontend/app/components/FormFields/FileUploadField.vue +++ b/frontend/app/components/FormFields/FileUploadField.vue @@ -33,7 +33,7 @@ const emit = defineEmits<{ const { t } = useI18n(); const config = useRuntimeConfig(); -const apiUrl = config.public.apiUrl; +const apiUrl = config.public.apiUrl as string; const uploadedFiles = ref([]); const isUploading = ref(false); diff --git a/frontend/app/composables/useApi.ts b/frontend/app/composables/useApi.ts index 8d862b7..ae39fe7 100644 --- a/frontend/app/composables/useApi.ts +++ b/frontend/app/composables/useApi.ts @@ -6,14 +6,15 @@ export const getFileUrl = (pathOrUrl: string | undefined | null): string => { } const config = useRuntimeConfig(); - const apiUrl = config.public.apiUrl; + const apiUrl = config.public.apiUrl as string; + const cleanPath = pathOrUrl.startsWith("/") ? pathOrUrl.slice(1) : pathOrUrl; return `${apiUrl}/files/${cleanPath}`; }; export const useApi = () => { const config = useRuntimeConfig(); - const apiUrl = config.public.apiUrl; + const apiUrl = config.public.apiUrl as string; const getToken = () => { return localStorage.getItem("token"); diff --git a/frontend/app/layouts/auth.vue b/frontend/app/layouts/auth.vue deleted file mode 100644 index 28cc527..0000000 --- a/frontend/app/layouts/auth.vue +++ /dev/null @@ -1,55 +0,0 @@ - - - - - diff --git a/frontend/app/layouts/default.vue b/frontend/app/layouts/default.vue index 6c799dc..413a26c 100644 --- a/frontend/app/layouts/default.vue +++ b/frontend/app/layouts/default.vue @@ -7,8 +7,60 @@ const localePath = useLocalePath(); const isMobileMenuOpen = ref(false); -// Show loading while stores initialize -const isInitializing = computed(() => authStore.isLoading || setupStore.isLoading); +// Helper to extract locale prefix from path (e.g., '/de', '/fr') +function getLocalePrefix(path: string): string | null { + const match = path.match(/^\/([a-zA-Z-]{2,5})(?=\/|$)/); + return match ? `/${match[1]}` : null; +} + +// Determine layout type based on route +const layoutType = computed(() => { + const path = route.path; + const localePrefix = getLocalePrefix(path); + + // Build base paths for matching + const base = localePrefix ? localePrefix : ""; + + // Auth pages: login, register, setup, index (landing) + if ( + path === "/" + (localePrefix ? localePrefix.slice(1) : "") || // e.g., "/de" + path === base + "/login" || + path === base + "/register" || + path === base + "/setup" || + path === "/" || + path === "/login" || + path === "/register" || + path === "/setup" + ) { + return "auth"; + } + // Public form pages + if (path.startsWith(base + "/f/") || path.startsWith("/f/")) { + return "public"; + } + // Dashboard pages (default) + return "dashboard"; +}); + +const isAuthLayout = computed(() => layoutType.value === "auth"); +const isPublicLayout = computed(() => layoutType.value === "public"); +const isDashboardLayout = computed(() => layoutType.value === "dashboard"); + +// Auth layout background style +const backgroundStyle = computed(() => { + if (!isAuthLayout.value) return {}; + if (setupStore.loginBackgroundDisplayURL) { + return { + backgroundImage: `url(${setupStore.loginBackgroundDisplayURL})`, + backgroundSize: "cover", + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + }; + } + return { + background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)", + }; +}); const handleLogout = () => { authStore.logout(); @@ -33,7 +85,35 @@ watch( diff --git a/frontend/app/layouts/public.vue b/frontend/app/layouts/public.vue deleted file mode 100644 index 5484c79..0000000 --- a/frontend/app/layouts/public.vue +++ /dev/null @@ -1,64 +0,0 @@ - - - - - diff --git a/frontend/app/middleware/setup.global.ts b/frontend/app/middleware/00.setup.global.ts similarity index 100% rename from frontend/app/middleware/setup.global.ts rename to frontend/app/middleware/00.setup.global.ts diff --git a/frontend/app/middleware/auth.global.ts b/frontend/app/middleware/01.auth.global.ts similarity index 87% rename from frontend/app/middleware/auth.global.ts rename to frontend/app/middleware/01.auth.global.ts index 31992ca..194291d 100644 --- a/frontend/app/middleware/auth.global.ts +++ b/frontend/app/middleware/01.auth.global.ts @@ -12,7 +12,7 @@ export default defineNuxtRouteMiddleware((to) => { const isGuestRoute = guestRoutes.includes(to.path); if (isGuestRoute && authStore.user) { - return navigateTo("/forms", { replace: true }); + return navigateTo("/forms"); } // Protected routes @@ -20,6 +20,6 @@ export default defineNuxtRouteMiddleware((to) => { const isProtected = protectedRoutes.some((route) => to.path.startsWith(route)); if (isProtected && !authStore.user) { - return navigateTo("/login", { replace: true }); + return navigateTo("/login"); } }); diff --git a/frontend/app/pages/f/[id].vue b/frontend/app/pages/f/[id].vue index 81e2957..c7d8af0 100644 --- a/frontend/app/pages/f/[id].vue +++ b/frontend/app/pages/f/[id].vue @@ -1,8 +1,4 @@ diff --git a/frontend/app/pages/login.vue b/frontend/app/pages/login.vue index a148267..101f264 100644 --- a/frontend/app/pages/login.vue +++ b/frontend/app/pages/login.vue @@ -1,8 +1,4 @@