From 36df8246bc28302e06e79b84d8c6cccf18aff56e Mon Sep 17 00:00:00 2001 From: "Alec S." Date: Thu, 4 Dec 2025 09:23:18 +0100 Subject: [PATCH 1/2] Add OpenAPI/Swagger documentation and backend improvements - Add Swagger/OpenAPI documentation with swaggo annotations for all endpoints - Set up GitLab CI/CD pipeline for API docs deployment to GitLab Pages - Add structured logging with zerolog (levels, pretty print, request logging) - Implement rate limiting middleware for auth and public endpoints - Add security headers middleware (CSP, X-Frame-Options, etc.) - Add XSS sanitization for form submissions - Implement server-side pagination for forms and submissions - Add trusted proxy and custom IP header configuration - Create Makefile with swagger, build, test, and lint targets - Add frontend pagination component and composable --- .env.example | 12 + .gitlab-ci.yml | 55 + api-docs/index.html | 58 + api-docs/swagger.json | 2169 +++++++++++++++++ backend/Makefile | 42 + backend/cmd/server/main.go | 170 +- backend/docs/docs.go | 2193 ++++++++++++++++++ backend/docs/swagger.json | 2169 +++++++++++++++++ backend/docs/swagger.yaml | 1434 ++++++++++++ backend/go.mod | 60 +- backend/go.sum | 132 ++ backend/internal/config/config.go | 40 +- backend/internal/handlers/auth.go | 35 + backend/internal/handlers/form.go | 149 +- backend/internal/handlers/form_test.go | 442 ++++ backend/internal/handlers/setup.go | 40 + backend/internal/handlers/submission.go | 114 +- backend/internal/handlers/submission_test.go | 342 +++ backend/internal/handlers/types.go | 39 + backend/internal/handlers/upload.go | 63 +- backend/internal/handlers/user.go | 48 +- backend/internal/handlers/user_test.go | 9 +- backend/internal/logger/logger.go | 136 ++ backend/internal/middleware/auth.go | 18 +- backend/internal/middleware/auth_test.go | 50 +- backend/internal/middleware/ratelimit.go | 199 ++ backend/internal/middleware/security.go | 35 + backend/internal/pagination/pagination.go | 78 + backend/internal/sanitizer/sanitizer.go | 62 + backend/internal/storage/local.go | 55 + backend/internal/storage/s3.go | 33 + backend/internal/storage/storage.go | 10 + frontend/app/components/UI/Pagination.vue | 232 ++ frontend/app/composables/useApi.ts | 24 +- frontend/app/composables/usePagination.ts | 96 + frontend/app/pages/forms/[id]/responses.vue | 87 +- frontend/app/pages/forms/index.vue | 5 +- frontend/app/pages/settings.vue | 27 +- frontend/app/store/forms.ts | 4 +- frontend/i18n/locales/de.json | 8 + frontend/i18n/locales/en.json | 8 + frontend/nuxt.config.ts | 2 +- frontend/shared/types/index.ts | 18 +- 43 files changed, 10855 insertions(+), 147 deletions(-) create mode 100644 .gitlab-ci.yml create mode 100644 api-docs/index.html create mode 100644 api-docs/swagger.json create mode 100644 backend/Makefile create mode 100644 backend/docs/docs.go create mode 100644 backend/docs/swagger.json create mode 100644 backend/docs/swagger.yaml create mode 100644 backend/internal/handlers/form_test.go create mode 100644 backend/internal/handlers/submission_test.go create mode 100644 backend/internal/handlers/types.go create mode 100644 backend/internal/logger/logger.go create mode 100644 backend/internal/middleware/ratelimit.go create mode 100644 backend/internal/middleware/security.go create mode 100644 backend/internal/pagination/pagination.go create mode 100644 backend/internal/sanitizer/sanitizer.go create mode 100644 frontend/app/components/UI/Pagination.vue create mode 100644 frontend/app/composables/usePagination.ts diff --git a/.env.example b/.env.example index 6fba3c7..aacd351 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,18 @@ 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 +# ============================================================================= +# PROXY / RATE LIMITING +# ============================================================================= +# Trusted proxies for accurate client IP detection (comma-separated IPs/CIDRs) +# Leave empty to trust all proxies (default, for development) +# Examples: "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16" for private networks +#TRUSTED_PROXIES= + +# Custom header for client IP (overrides default X-Forwarded-For / X-Real-IP) +# Common values: CF-Connecting-IP (Cloudflare), X-Real-IP (Nginx), True-Client-IP +#REAL_IP_HEADER= + # ============================================================================= # STORAGE # ============================================================================= diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..959602c --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,55 @@ +stages: + - build + - docs + - deploy + +variables: + GO_VERSION: "1.23" + +# Generate Swagger documentation +swagger: + stage: docs + image: golang:${GO_VERSION} + script: + - go install github.com/swaggo/swag/cmd/swag@latest + - cd backend + - swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency + - cp docs/swagger.json ../api-docs/ + artifacts: + paths: + - api-docs/ + expire_in: 1 week + only: + - main + - develop + - merge_requests + +# Deploy API documentation to GitLab Pages +pages: + stage: deploy + dependencies: + - swagger + script: + - mkdir -p public + - cp -r api-docs/* public/ + artifacts: + paths: + - public + only: + - main + environment: + name: documentation + url: https://$CI_PROJECT_NAMESPACE.gitlab.io/$CI_PROJECT_NAME + +# Optional: Build backend to verify code compiles +build:backend: + stage: build + image: golang:${GO_VERSION} + script: + - cd backend + - go mod download + - go build -v ./... + only: + - main + - develop + - merge_requests diff --git a/api-docs/index.html b/api-docs/index.html new file mode 100644 index 0000000..4d17e4c --- /dev/null +++ b/api-docs/index.html @@ -0,0 +1,58 @@ + + + + + + Formera API Documentation + + + + +
+ + + + + diff --git a/api-docs/swagger.json b/api-docs/swagger.json new file mode 100644 index 0000000..aa7d405 --- /dev/null +++ b/api-docs/swagger.json @@ -0,0 +1,2169 @@ +{ + "swagger": "2.0", + "info": { + "description": "REST API for Formera - a self-hosted form builder application", + "title": "Formera API", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "https://github.com/your-repo/formera" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + }, + "version": "1.0" + }, + "host": "localhost:8080", + "basePath": "/api", + "paths": { + "/auth/login": { + "post": { + "description": "Authenticate with email and password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Login user", + "parameters": [ + { + "description": "Login credentials", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.AuthResponse" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Invalid credentials", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/auth/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get the authenticated user's profile", + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Get current user", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.User" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/auth/register": { + "post": { + "description": "Create a new user account (if registration is enabled)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Register new user", + "parameters": [ + { + "description": "Registration data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/internal_handlers.AuthResponse" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "403": { + "description": "Registration disabled", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "409": { + "description": "Email already registered", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/files/{path}": { + "get": { + "description": "Serve a file by path (streams from storage)", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "Files" + ], + "summary": "Get file", + "parameters": [ + { + "type": "string", + "description": "File path", + "name": "path", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "File content", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get paginated list of user's forms", + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "List forms", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Items per page", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_pagination.Result" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new form", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Create form", + "parameters": [ + { + "description": "Form data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.CreateFormRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/check-slug": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Check if a slug is available for use", + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Check slug availability", + "parameters": [ + { + "type": "string", + "description": "Slug to check", + "name": "slug", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Exclude this form from check", + "name": "form_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.SlugCheckResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get a form by ID", + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Get form", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update a form by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Update form", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.UpdateFormRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "409": { + "description": "Slug already taken", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a form and all its submissions", + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Delete form", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.MessageResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/duplicate": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a copy of an existing form", + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Duplicate form", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/export/csv": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Download all submissions as a CSV file", + "produces": [ + "text/csv" + ], + "tags": [ + "Submissions" + ], + "summary": "Export submissions as CSV", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "CSV file", + "schema": { + "type": "file" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/export/json": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Download all submissions as a JSON file", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "Export submissions as JSON", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "JSON array of submissions", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/stats": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get submission statistics for a form", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "Get form statistics", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.FormStatsResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/submissions": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get paginated list of form submissions", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "List submissions", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 1, + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Items per page", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.SubmissionListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/submissions/by-date": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get submission counts grouped by date", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "Get submissions by date", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_handlers.SubmissionsByDateResponse" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/submissions/{submissionId}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get a specific submission by ID", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "Get submission", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Submission ID", + "name": "submissionId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Submission" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a specific submission", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "Delete submission", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Submission ID", + "name": "submissionId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.MessageResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/public/forms/{id}": { + "get": { + "description": "Get a published form by ID or slug (public access)", + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "Get public form", + "parameters": [ + { + "type": "string", + "description": "Form ID or slug", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/public/forms/{id}/submit": { + "post": { + "description": "Submit a response to a published form", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "Submit form", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Submission data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.SubmitRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/formera_internal_models.Submission" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "403": { + "description": "Form closed or max submissions reached", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/public/forms/{id}/verify-password": { + "post": { + "description": "Verify password for a password-protected form", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "Verify form password", + "parameters": [ + { + "type": "string", + "description": "Form ID or slug", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Password", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.VerifyPasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "Returns form if password is valid", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/public/upload": { + "post": { + "description": "Upload a file (for form submissions)", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Uploads" + ], + "summary": "Upload file", + "parameters": [ + { + "type": "file", + "description": "File to upload", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_storage.UploadResult" + } + }, + "400": { + "description": "Invalid file", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/settings": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get application settings (admin only)", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Get settings", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Settings" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "403": { + "description": "Admin access required", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update application settings (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Update settings", + "parameters": [ + { + "description": "Settings data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.UpdateSettingsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Settings" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "403": { + "description": "Admin access required", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/setup/complete": { + "post": { + "description": "Create the first admin user and complete setup", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Setup" + ], + "summary": "Complete initial setup", + "parameters": [ + { + "description": "Setup data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.SetupRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.AuthResponse" + } + }, + "400": { + "description": "Setup already completed or invalid data", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/setup/status": { + "get": { + "description": "Get application setup status and public settings", + "produces": [ + "application/json" + ], + "tags": [ + "Setup" + ], + "summary": "Get setup status", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.SetupStatusResponse" + } + } + } + } + }, + "/uploads/image": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Upload an image file (for form design backgrounds)", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Uploads" + ], + "summary": "Upload image", + "parameters": [ + { + "type": "file", + "description": "Image file", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_storage.UploadResult" + } + }, + "400": { + "description": "Invalid file", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/uploads/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete an uploaded file", + "produces": [ + "application/json" + ], + "tags": [ + "Uploads" + ], + "summary": "Delete file", + "parameters": [ + { + "type": "string", + "description": "File ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "formera_internal_models.FieldType": { + "type": "string", + "enum": [ + "text", + "textarea", + "number", + "email", + "phone", + "date", + "time", + "url", + "richtext", + "select", + "radio", + "checkbox", + "dropdown", + "file", + "rating", + "scale", + "signature", + "section", + "pagebreak", + "divider", + "heading", + "paragraph", + "image" + ], + "x-enum-varnames": [ + "FieldTypeText", + "FieldTypeTextarea", + "FieldTypeNumber", + "FieldTypeEmail", + "FieldTypePhone", + "FieldTypeDate", + "FieldTypeTime", + "FieldTypeURL", + "FieldTypeRichtext", + "FieldTypeSelect", + "FieldTypeRadio", + "FieldTypeCheckbox", + "FieldTypeDropdown", + "FieldTypeFile", + "FieldTypeRating", + "FieldTypeScale", + "FieldTypeSignature", + "FieldTypeSection", + "FieldTypePagebreak", + "FieldTypeDivider", + "FieldTypeHeading", + "FieldTypeParagraph", + "FieldTypeImage" + ] + }, + "formera_internal_models.FooterLink": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "formera_internal_models.Form": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FormField" + } + }, + "id": { + "type": "string" + }, + "password_protected": { + "description": "Password protection", + "type": "boolean" + }, + "settings": { + "$ref": "#/definitions/formera_internal_models.FormSettings" + }, + "slug": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/formera_internal_models.FormStatus" + }, + "submissions": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.Submission" + } + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "formera_internal_models.FormDesign": { + "type": "object", + "properties": { + "backgroundColor": { + "type": "string" + }, + "backgroundImage": { + "type": "string" + }, + "backgroundPosition": { + "type": "string" + }, + "backgroundSize": { + "type": "string" + }, + "borderRadius": { + "type": "string" + }, + "buttonStyle": { + "type": "string" + }, + "fontFamily": { + "type": "string" + }, + "formBackgroundColor": { + "type": "string" + }, + "headerStyle": { + "type": "string" + }, + "maxWidth": { + "type": "string" + }, + "primaryColor": { + "type": "string" + }, + "textColor": { + "type": "string" + } + } + }, + "formera_internal_models.FormField": { + "type": "object", + "properties": { + "allowedTypes": { + "description": "File Upload", + "type": "array", + "items": { + "type": "string" + } + }, + "collapsed": { + "type": "boolean" + }, + "collapsible": { + "type": "boolean" + }, + "content": { + "description": "Layout-specific", + "type": "string" + }, + "description": { + "description": "Description/Help text", + "type": "string" + }, + "headingLevel": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "imageAlt": { + "type": "string" + }, + "imageUrl": { + "type": "string" + }, + "label": { + "type": "string" + }, + "maxFileSize": { + "type": "integer" + }, + "maxLabel": { + "type": "string" + }, + "maxValue": { + "type": "integer" + }, + "minLabel": { + "type": "string" + }, + "minValue": { + "description": "Rating/Scale", + "type": "integer" + }, + "multiple": { + "type": "boolean" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "order": { + "type": "integer" + }, + "placeholder": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "richTextContent": { + "description": "Rich Text", + "type": "string" + }, + "sectionDescription": { + "type": "string" + }, + "sectionTitle": { + "description": "Section-specific", + "type": "string" + }, + "type": { + "$ref": "#/definitions/formera_internal_models.FieldType" + }, + "validation": { + "type": "object", + "additionalProperties": true + } + } + }, + "formera_internal_models.FormSettings": { + "type": "object", + "properties": { + "allow_multiple": { + "type": "boolean" + }, + "design": { + "$ref": "#/definitions/formera_internal_models.FormDesign" + }, + "end_date": { + "type": "string" + }, + "max_submissions": { + "type": "integer" + }, + "notification_email": { + "type": "string" + }, + "notify_on_submission": { + "type": "boolean" + }, + "require_login": { + "type": "boolean" + }, + "start_date": { + "type": "string" + }, + "submit_button_text": { + "type": "string" + }, + "success_message": { + "type": "string" + } + } + }, + "formera_internal_models.FormStatus": { + "type": "string", + "enum": [ + "draft", + "published", + "closed" + ], + "x-enum-varnames": [ + "FormStatusDraft", + "FormStatusPublished", + "FormStatusClosed" + ] + }, + "formera_internal_models.Settings": { + "type": "object", + "properties": { + "allow_registration": { + "type": "boolean" + }, + "app_name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "favicon_url": { + "type": "string" + }, + "footer_links": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FooterLink" + } + }, + "id": { + "type": "integer" + }, + "language": { + "description": "Language and Theme", + "type": "string" + }, + "login_background_url": { + "type": "string" + }, + "logo_show_text": { + "type": "boolean" + }, + "logo_url": { + "type": "string" + }, + "primary_color": { + "description": "Customization", + "type": "string" + }, + "setup_completed": { + "type": "boolean" + }, + "theme": { + "description": "\"light\", \"dark\", or \"system\"", + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "formera_internal_models.Submission": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/formera_internal_models.SubmissionData" + }, + "form_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "metadata": { + "$ref": "#/definitions/formera_internal_models.SubmissionMetadata" + } + } + }, + "formera_internal_models.SubmissionData": { + "type": "object", + "additionalProperties": true + }, + "formera_internal_models.SubmissionMetadata": { + "type": "object", + "properties": { + "ip": { + "type": "string" + }, + "referrer": { + "type": "string" + }, + "tracking": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "user_agent": { + "type": "string" + }, + "utm_campaign": { + "type": "string" + }, + "utm_content": { + "type": "string" + }, + "utm_medium": { + "type": "string" + }, + "utm_source": { + "description": "UTM/Tracking parameters", + "type": "string" + }, + "utm_term": { + "type": "string" + } + } + }, + "formera_internal_models.User": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "forms": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/formera_internal_models.UserRole" + }, + "updated_at": { + "type": "string" + } + } + }, + "formera_internal_models.UserRole": { + "type": "string", + "enum": [ + "admin", + "user" + ], + "x-enum-varnames": [ + "RoleAdmin", + "RoleUser" + ] + }, + "formera_internal_pagination.Result": { + "type": "object", + "properties": { + "data": {}, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_items": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + }, + "formera_internal_storage.UploadResult": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "path": { + "description": "Relative path (e.g., \"images/2025/12/abc123.png\")", + "type": "string" + }, + "size": { + "type": "integer" + }, + "url": { + "description": "Full URL for immediate use", + "type": "string" + } + } + }, + "internal_handlers.AuthResponse": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/formera_internal_models.User" + } + } + }, + "internal_handlers.CreateFormRequest": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FormField" + } + }, + "settings": { + "$ref": "#/definitions/formera_internal_models.FormSettings" + }, + "title": { + "type": "string" + } + } + }, + "internal_handlers.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Invalid request" + } + } + }, + "internal_handlers.FormStatsResponse": { + "type": "object", + "properties": { + "field_stats": { + "type": "object", + "additionalProperties": true + }, + "total_submissions": { + "type": "integer", + "example": 150 + } + } + }, + "internal_handlers.LoginRequest": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "internal_handlers.MessageResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Operation successful" + } + } + }, + "internal_handlers.RegisterRequest": { + "type": "object", + "required": [ + "email", + "name", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string", + "minLength": 8 + } + } + }, + "internal_handlers.SetupRequest": { + "type": "object", + "required": [ + "email", + "name", + "password" + ], + "properties": { + "allow_registration": { + "type": "boolean" + }, + "app_name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string", + "minLength": 8 + } + } + }, + "internal_handlers.SetupStatusResponse": { + "type": "object", + "properties": { + "allow_registration": { + "type": "boolean" + }, + "app_name": { + "type": "string" + }, + "favicon_url": { + "type": "string" + }, + "footer_links": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FooterLink" + } + }, + "language": { + "type": "string" + }, + "login_background_url": { + "type": "string" + }, + "logo_show_text": { + "type": "boolean" + }, + "logo_url": { + "type": "string" + }, + "primary_color": { + "type": "string" + }, + "setup_required": { + "type": "boolean" + }, + "theme": { + "type": "string" + } + } + }, + "internal_handlers.SlugCheckResponse": { + "type": "object", + "properties": { + "available": { + "type": "boolean", + "example": true + }, + "reason": { + "type": "string", + "example": "taken" + }, + "slug": { + "type": "string", + "example": "contact-form" + } + } + }, + "internal_handlers.SubmissionListResponse": { + "type": "object", + "properties": { + "form": {}, + "submissions": {} + } + }, + "internal_handlers.SubmissionsByDateResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 25 + }, + "date": { + "type": "string", + "example": "2025-01-15" + } + } + }, + "internal_handlers.SubmitRequest": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/definitions/formera_internal_models.SubmissionData" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "internal_handlers.UpdateFormRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FormField" + } + }, + "password": { + "description": "Raw password, will be hashed", + "type": "string" + }, + "password_protected": { + "type": "boolean" + }, + "settings": { + "$ref": "#/definitions/formera_internal_models.FormSettings" + }, + "slug": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/formera_internal_models.FormStatus" + }, + "title": { + "type": "string" + } + } + }, + "internal_handlers.UpdateSettingsRequest": { + "type": "object", + "properties": { + "allow_registration": { + "type": "boolean" + }, + "app_name": { + "type": "string" + }, + "favicon_url": { + "type": "string" + }, + "footer_links": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FooterLink" + } + }, + "language": { + "type": "string" + }, + "login_background_url": { + "type": "string" + }, + "logo_show_text": { + "type": "boolean" + }, + "logo_url": { + "type": "string" + }, + "primary_color": { + "type": "string" + }, + "theme": { + "type": "string" + } + } + }, + "internal_handlers.VerifyPasswordRequest": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "password": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Type \"Bearer\" followed by a space and JWT token", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..2a9a8b9 --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,42 @@ +.PHONY: build run test swagger docs clean + +# Build the application +build: + go build -o bin/server ./cmd/server + +# Run the application +run: + go run ./cmd/server + +# Run tests +test: + go test -v ./... + +# Generate Swagger documentation +swagger: + @which swag > /dev/null 2>&1 || go install github.com/swaggo/swag/cmd/swag@latest + $(shell go env GOPATH)/bin/swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency + @echo "Swagger docs generated in docs/" + +# Update API docs for GitLab Pages +docs: swagger + cp docs/swagger.json ../api-docs/ + @echo "API docs updated in ../api-docs/" + +# Clean build artifacts +clean: + rm -rf bin/ + rm -rf docs/ + +# Install development dependencies +deps: + go mod download + go install github.com/swaggo/swag/cmd/swag@latest + +# Format code +fmt: + go fmt ./... + +# Lint code (requires golangci-lint) +lint: + golangci-lint run diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 261bb15..8e18914 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -1,7 +1,8 @@ package main import ( - "log" + "context" + "net/http" "os" "os/signal" "syscall" @@ -10,44 +11,90 @@ import ( "formera/internal/config" "formera/internal/database" "formera/internal/handlers" + "formera/internal/logger" "formera/internal/middleware" "formera/internal/storage" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + + // Swagger docs + _ "formera/docs" + + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" ) +// @title Formera API +// @version 1.0 +// @description REST API for Formera - a self-hosted form builder application +// @termsOfService http://swagger.io/terms/ + +// @contact.name API Support +// @contact.url https://github.com/your-repo/formera + +// @license.name MIT +// @license.url https://opensource.org/licenses/MIT + +// @host localhost:8080 +// @BasePath /api + +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +// @description Type "Bearer" followed by a space and JWT token + func main() { cfg := config.Load() + // Initialize logger + logger.Initialize(logger.Config{ + Level: cfg.LogLevel, + Pretty: cfg.LogPretty, + }) + // Initialize database if err := database.Initialize(cfg.DBPath); err != nil { - log.Fatalf("Failed to initialize database: %v", err) + logger.Fatal().Err(err).Msg("Failed to initialize database") } // Initialize storage store, err := initStorage(cfg) if err != nil { - log.Fatalf("Failed to initialize storage: %v", err) + logger.Fatal().Err(err).Msg("Failed to initialize storage") } - log.Printf("Storage initialized: %s", store.Type()) + logger.Info().Str("type", string(store.Type())).Msg("Storage initialized") // Start cleanup scheduler cleanupScheduler := startCleanupScheduler(cfg, store) defer cleanupScheduler.Stop() - // Handle graceful shutdown - go func() { - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - <-sigCh - log.Println("Shutting down...") - cleanupScheduler.Stop() - os.Exit(0) - }() + // Setup Gin router with custom middleware + gin.SetMode(gin.ReleaseMode) + r := gin.New() + + // Configure trusted proxies for accurate client IP detection + // nil = trust all proxies (default), empty slice = trust no proxies + if cfg.TrustedProxies == nil { + r.SetTrustedProxies(nil) // Trust all (dev mode) + } else if len(cfg.TrustedProxies) == 0 { + r.SetTrustedProxies([]string{}) // Trust none + } else { + if err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil { + logger.Fatal().Err(err).Msg("Invalid trusted proxies configuration") + } + logger.Info().Strs("proxies", cfg.TrustedProxies).Msg("Trusted proxies configured") + } - // Setup Gin router - r := gin.Default() + // Configure custom IP header if specified (e.g., CF-Connecting-IP for Cloudflare) + if cfg.RealIPHeader != "" { + r.RemoteIPHeaders = []string{cfg.RealIPHeader} + logger.Info().Str("header", cfg.RealIPHeader).Msg("Using custom IP header") + } + + r.Use(logger.GinLogger()) + r.Use(logger.GinRecovery()) + r.Use(middleware.SecurityHeaders()) // CORS configuration r.Use(cors.New(cors.Config{ @@ -57,9 +104,15 @@ func main() { ExposeHeaders: []string{"Content-Length"}, AllowCredentials: true, })) - // Serve static files for local storage + // Serve uploaded files - works for both local and S3 storage + // For local storage: serves files directly from disk + // For S3 storage: redirects to presigned URLs if cfg.Storage.GetStorageType() == "local" { r.Static("/uploads", cfg.Storage.LocalPath) + } else { + // For S3, use the upload handler to generate presigned URLs + uploadHandlerForFiles := handlers.NewUploadHandler(store) + r.GET("/uploads/*path", uploadHandlerForFiles.GetFile) } // Initialize handlers @@ -70,21 +123,24 @@ func main() { uploadHandler := handlers.NewUploadHandler(store) userHandler := handlers.NewUserHandler() - // Public routes + // Public routes with global rate limit (100 req/min per IP) api := r.Group("/api") + api.Use(middleware.APIRateLimiter()) { // Setup routes (public) api.GET("/setup/status", setupHandler.GetStatus) api.POST("/setup/complete", setupHandler.CompleteSetup) - // Auth routes - api.POST("/auth/register", authHandler.Register) - api.POST("/auth/login", authHandler.Login) + // Auth routes with stricter rate limit (10 req/min per IP) + api.POST("/auth/register", middleware.AuthRateLimiter(), authHandler.Register) + api.POST("/auth/login", middleware.AuthRateLimiter(), authHandler.Login) // Public form access (supports both ID and slug) api.GET("/public/forms/:id", formHandler.GetPublic) api.POST("/public/forms/:id/verify-password", formHandler.VerifyPassword) - api.POST("/public/forms/:id/submit", submissionHandler.Submit) + + // Form submission with moderate rate limit (30 req/min per IP) + api.POST("/public/forms/:id/submit", middleware.SubmissionRateLimiter(), submissionHandler.Submit) // Public file upload (for form submissions with file fields) api.POST("/public/upload", uploadHandler.UploadFile) @@ -141,15 +197,65 @@ func main() { admin.DELETE("/users/:id", userHandler.Delete) } - // Health check + // Swagger documentation endpoint + r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + + // Health check endpoints r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"status": "ok"}) }) - log.Printf("Server starting on port %s", cfg.Port) - if err := r.Run(":" + cfg.Port); err != nil { - log.Fatalf("Failed to start server: %v", err) + r.GET("/health/ready", func(c *gin.Context) { + // Check database connection + sqlDB, err := database.DB.DB() + if err != nil { + c.JSON(503, gin.H{"status": "error", "error": "database connection failed"}) + return + } + if err := sqlDB.Ping(); err != nil { + c.JSON(503, gin.H{"status": "error", "error": "database ping failed"}) + return + } + c.JSON(200, gin.H{"status": "ready", "database": "ok"}) + }) + + // Create HTTP server + srv := &http.Server{ + Addr: ":" + cfg.Port, + Handler: r, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Start server in goroutine + go func() { + logger.Info().Str("port", cfg.Port).Msg("Server starting") + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Fatal().Err(err).Msg("Failed to start server") + } + }() + + // Graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + logger.Info().Msg("Shutting down server...") + + // Give outstanding requests 30 seconds to complete + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Stop cleanup scheduler + cleanupScheduler.Stop() + + // Shutdown HTTP server + if err := srv.Shutdown(ctx); err != nil { + logger.Error().Err(err).Msg("Server forced to shutdown") } + + logger.Info().Msg("Server exited") } // initStorage initializes the appropriate storage backend based on configuration @@ -194,23 +300,25 @@ func migrateLocalToS3(cfg *config.Config, s3Store *storage.S3Storage) { return // No local files to migrate } - log.Println("Checking for local files to migrate to S3...") + logger.Info().Msg("Checking for local files to migrate to S3...") result, err := storage.MigrateLocalToS3(localPath, s3Store, cfg.Storage.DeleteAfterMigrate) if err != nil { - log.Printf("Migration error: %v", err) + logger.Error().Err(err).Msg("Migration error") return } if result.MigratedFiles > 0 { - log.Printf("Migration complete: %d files migrated (%.2f MB)", - result.MigratedFiles, float64(result.MigratedBytes)/(1024*1024)) + logger.Info(). + Int("files", result.MigratedFiles). + Float64("size_mb", float64(result.MigratedBytes)/(1024*1024)). + Msg("Migration complete") } if len(result.Errors) > 0 { - log.Printf("Migration had %d errors:", len(result.Errors)) + logger.Warn().Int("count", len(result.Errors)).Msg("Migration had errors") for _, e := range result.Errors { - log.Printf(" - %s", e) + logger.Warn().Str("error", e).Msg("Migration error detail") } } } diff --git a/backend/docs/docs.go b/backend/docs/docs.go new file mode 100644 index 0000000..c6aa55a --- /dev/null +++ b/backend/docs/docs.go @@ -0,0 +1,2193 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "https://github.com/your-repo/formera" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/auth/login": { + "post": { + "description": "Authenticate with email and password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Login user", + "parameters": [ + { + "description": "Login credentials", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.AuthResponse" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Invalid credentials", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/auth/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get the authenticated user's profile", + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Get current user", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.User" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/auth/register": { + "post": { + "description": "Create a new user account (if registration is enabled)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Register new user", + "parameters": [ + { + "description": "Registration data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/internal_handlers.AuthResponse" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "403": { + "description": "Registration disabled", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "409": { + "description": "Email already registered", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/files/{path}": { + "get": { + "description": "Serve a file by path (streams from storage)", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "Files" + ], + "summary": "Get file", + "parameters": [ + { + "type": "string", + "description": "File path", + "name": "path", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "File content", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get paginated list of user's forms", + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "List forms", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Items per page", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_pagination.Result" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new form", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Create form", + "parameters": [ + { + "description": "Form data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.CreateFormRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/check-slug": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Check if a slug is available for use", + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Check slug availability", + "parameters": [ + { + "type": "string", + "description": "Slug to check", + "name": "slug", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Exclude this form from check", + "name": "form_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.SlugCheckResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get a form by ID", + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Get form", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update a form by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Update form", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.UpdateFormRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "409": { + "description": "Slug already taken", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a form and all its submissions", + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Delete form", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.MessageResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/duplicate": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a copy of an existing form", + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Duplicate form", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/export/csv": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Download all submissions as a CSV file", + "produces": [ + "text/csv" + ], + "tags": [ + "Submissions" + ], + "summary": "Export submissions as CSV", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "CSV file", + "schema": { + "type": "file" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/export/json": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Download all submissions as a JSON file", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "Export submissions as JSON", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "JSON array of submissions", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/stats": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get submission statistics for a form", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "Get form statistics", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.FormStatsResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/submissions": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get paginated list of form submissions", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "List submissions", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 1, + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Items per page", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.SubmissionListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/submissions/by-date": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get submission counts grouped by date", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "Get submissions by date", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_handlers.SubmissionsByDateResponse" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/submissions/{submissionId}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get a specific submission by ID", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "Get submission", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Submission ID", + "name": "submissionId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Submission" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a specific submission", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "Delete submission", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Submission ID", + "name": "submissionId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.MessageResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/public/forms/{id}": { + "get": { + "description": "Get a published form by ID or slug (public access)", + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "Get public form", + "parameters": [ + { + "type": "string", + "description": "Form ID or slug", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/public/forms/{id}/submit": { + "post": { + "description": "Submit a response to a published form", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "Submit form", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Submission data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.SubmitRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/formera_internal_models.Submission" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "403": { + "description": "Form closed or max submissions reached", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/public/forms/{id}/verify-password": { + "post": { + "description": "Verify password for a password-protected form", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "Verify form password", + "parameters": [ + { + "type": "string", + "description": "Form ID or slug", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Password", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.VerifyPasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "Returns form if password is valid", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/public/upload": { + "post": { + "description": "Upload a file (for form submissions)", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Uploads" + ], + "summary": "Upload file", + "parameters": [ + { + "type": "file", + "description": "File to upload", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_storage.UploadResult" + } + }, + "400": { + "description": "Invalid file", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/settings": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get application settings (admin only)", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Get settings", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Settings" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "403": { + "description": "Admin access required", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update application settings (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Update settings", + "parameters": [ + { + "description": "Settings data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.UpdateSettingsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Settings" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "403": { + "description": "Admin access required", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/setup/complete": { + "post": { + "description": "Create the first admin user and complete setup", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Setup" + ], + "summary": "Complete initial setup", + "parameters": [ + { + "description": "Setup data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.SetupRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.AuthResponse" + } + }, + "400": { + "description": "Setup already completed or invalid data", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/setup/status": { + "get": { + "description": "Get application setup status and public settings", + "produces": [ + "application/json" + ], + "tags": [ + "Setup" + ], + "summary": "Get setup status", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.SetupStatusResponse" + } + } + } + } + }, + "/uploads/image": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Upload an image file (for form design backgrounds)", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Uploads" + ], + "summary": "Upload image", + "parameters": [ + { + "type": "file", + "description": "Image file", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_storage.UploadResult" + } + }, + "400": { + "description": "Invalid file", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/uploads/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete an uploaded file", + "produces": [ + "application/json" + ], + "tags": [ + "Uploads" + ], + "summary": "Delete file", + "parameters": [ + { + "type": "string", + "description": "File ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "formera_internal_models.FieldType": { + "type": "string", + "enum": [ + "text", + "textarea", + "number", + "email", + "phone", + "date", + "time", + "url", + "richtext", + "select", + "radio", + "checkbox", + "dropdown", + "file", + "rating", + "scale", + "signature", + "section", + "pagebreak", + "divider", + "heading", + "paragraph", + "image" + ], + "x-enum-varnames": [ + "FieldTypeText", + "FieldTypeTextarea", + "FieldTypeNumber", + "FieldTypeEmail", + "FieldTypePhone", + "FieldTypeDate", + "FieldTypeTime", + "FieldTypeURL", + "FieldTypeRichtext", + "FieldTypeSelect", + "FieldTypeRadio", + "FieldTypeCheckbox", + "FieldTypeDropdown", + "FieldTypeFile", + "FieldTypeRating", + "FieldTypeScale", + "FieldTypeSignature", + "FieldTypeSection", + "FieldTypePagebreak", + "FieldTypeDivider", + "FieldTypeHeading", + "FieldTypeParagraph", + "FieldTypeImage" + ] + }, + "formera_internal_models.FooterLink": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "formera_internal_models.Form": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FormField" + } + }, + "id": { + "type": "string" + }, + "password_protected": { + "description": "Password protection", + "type": "boolean" + }, + "settings": { + "$ref": "#/definitions/formera_internal_models.FormSettings" + }, + "slug": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/formera_internal_models.FormStatus" + }, + "submissions": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.Submission" + } + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "formera_internal_models.FormDesign": { + "type": "object", + "properties": { + "backgroundColor": { + "type": "string" + }, + "backgroundImage": { + "type": "string" + }, + "backgroundPosition": { + "type": "string" + }, + "backgroundSize": { + "type": "string" + }, + "borderRadius": { + "type": "string" + }, + "buttonStyle": { + "type": "string" + }, + "fontFamily": { + "type": "string" + }, + "formBackgroundColor": { + "type": "string" + }, + "headerStyle": { + "type": "string" + }, + "maxWidth": { + "type": "string" + }, + "primaryColor": { + "type": "string" + }, + "textColor": { + "type": "string" + } + } + }, + "formera_internal_models.FormField": { + "type": "object", + "properties": { + "allowedTypes": { + "description": "File Upload", + "type": "array", + "items": { + "type": "string" + } + }, + "collapsed": { + "type": "boolean" + }, + "collapsible": { + "type": "boolean" + }, + "content": { + "description": "Layout-specific", + "type": "string" + }, + "description": { + "description": "Description/Help text", + "type": "string" + }, + "headingLevel": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "imageAlt": { + "type": "string" + }, + "imageUrl": { + "type": "string" + }, + "label": { + "type": "string" + }, + "maxFileSize": { + "type": "integer" + }, + "maxLabel": { + "type": "string" + }, + "maxValue": { + "type": "integer" + }, + "minLabel": { + "type": "string" + }, + "minValue": { + "description": "Rating/Scale", + "type": "integer" + }, + "multiple": { + "type": "boolean" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "order": { + "type": "integer" + }, + "placeholder": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "richTextContent": { + "description": "Rich Text", + "type": "string" + }, + "sectionDescription": { + "type": "string" + }, + "sectionTitle": { + "description": "Section-specific", + "type": "string" + }, + "type": { + "$ref": "#/definitions/formera_internal_models.FieldType" + }, + "validation": { + "type": "object", + "additionalProperties": true + } + } + }, + "formera_internal_models.FormSettings": { + "type": "object", + "properties": { + "allow_multiple": { + "type": "boolean" + }, + "design": { + "$ref": "#/definitions/formera_internal_models.FormDesign" + }, + "end_date": { + "type": "string" + }, + "max_submissions": { + "type": "integer" + }, + "notification_email": { + "type": "string" + }, + "notify_on_submission": { + "type": "boolean" + }, + "require_login": { + "type": "boolean" + }, + "start_date": { + "type": "string" + }, + "submit_button_text": { + "type": "string" + }, + "success_message": { + "type": "string" + } + } + }, + "formera_internal_models.FormStatus": { + "type": "string", + "enum": [ + "draft", + "published", + "closed" + ], + "x-enum-varnames": [ + "FormStatusDraft", + "FormStatusPublished", + "FormStatusClosed" + ] + }, + "formera_internal_models.Settings": { + "type": "object", + "properties": { + "allow_registration": { + "type": "boolean" + }, + "app_name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "favicon_url": { + "type": "string" + }, + "footer_links": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FooterLink" + } + }, + "id": { + "type": "integer" + }, + "language": { + "description": "Language and Theme", + "type": "string" + }, + "login_background_url": { + "type": "string" + }, + "logo_show_text": { + "type": "boolean" + }, + "logo_url": { + "type": "string" + }, + "primary_color": { + "description": "Customization", + "type": "string" + }, + "setup_completed": { + "type": "boolean" + }, + "theme": { + "description": "\"light\", \"dark\", or \"system\"", + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "formera_internal_models.Submission": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/formera_internal_models.SubmissionData" + }, + "form_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "metadata": { + "$ref": "#/definitions/formera_internal_models.SubmissionMetadata" + } + } + }, + "formera_internal_models.SubmissionData": { + "type": "object", + "additionalProperties": true + }, + "formera_internal_models.SubmissionMetadata": { + "type": "object", + "properties": { + "ip": { + "type": "string" + }, + "referrer": { + "type": "string" + }, + "tracking": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "user_agent": { + "type": "string" + }, + "utm_campaign": { + "type": "string" + }, + "utm_content": { + "type": "string" + }, + "utm_medium": { + "type": "string" + }, + "utm_source": { + "description": "UTM/Tracking parameters", + "type": "string" + }, + "utm_term": { + "type": "string" + } + } + }, + "formera_internal_models.User": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "forms": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/formera_internal_models.UserRole" + }, + "updated_at": { + "type": "string" + } + } + }, + "formera_internal_models.UserRole": { + "type": "string", + "enum": [ + "admin", + "user" + ], + "x-enum-varnames": [ + "RoleAdmin", + "RoleUser" + ] + }, + "formera_internal_pagination.Result": { + "type": "object", + "properties": { + "data": {}, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_items": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + }, + "formera_internal_storage.UploadResult": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "path": { + "description": "Relative path (e.g., \"images/2025/12/abc123.png\")", + "type": "string" + }, + "size": { + "type": "integer" + }, + "url": { + "description": "Full URL for immediate use", + "type": "string" + } + } + }, + "internal_handlers.AuthResponse": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/formera_internal_models.User" + } + } + }, + "internal_handlers.CreateFormRequest": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FormField" + } + }, + "settings": { + "$ref": "#/definitions/formera_internal_models.FormSettings" + }, + "title": { + "type": "string" + } + } + }, + "internal_handlers.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Invalid request" + } + } + }, + "internal_handlers.FormStatsResponse": { + "type": "object", + "properties": { + "field_stats": { + "type": "object", + "additionalProperties": true + }, + "total_submissions": { + "type": "integer", + "example": 150 + } + } + }, + "internal_handlers.LoginRequest": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "internal_handlers.MessageResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Operation successful" + } + } + }, + "internal_handlers.RegisterRequest": { + "type": "object", + "required": [ + "email", + "name", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string", + "minLength": 8 + } + } + }, + "internal_handlers.SetupRequest": { + "type": "object", + "required": [ + "email", + "name", + "password" + ], + "properties": { + "allow_registration": { + "type": "boolean" + }, + "app_name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string", + "minLength": 8 + } + } + }, + "internal_handlers.SetupStatusResponse": { + "type": "object", + "properties": { + "allow_registration": { + "type": "boolean" + }, + "app_name": { + "type": "string" + }, + "favicon_url": { + "type": "string" + }, + "footer_links": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FooterLink" + } + }, + "language": { + "type": "string" + }, + "login_background_url": { + "type": "string" + }, + "logo_show_text": { + "type": "boolean" + }, + "logo_url": { + "type": "string" + }, + "primary_color": { + "type": "string" + }, + "setup_required": { + "type": "boolean" + }, + "theme": { + "type": "string" + } + } + }, + "internal_handlers.SlugCheckResponse": { + "type": "object", + "properties": { + "available": { + "type": "boolean", + "example": true + }, + "reason": { + "type": "string", + "example": "taken" + }, + "slug": { + "type": "string", + "example": "contact-form" + } + } + }, + "internal_handlers.SubmissionListResponse": { + "type": "object", + "properties": { + "form": {}, + "submissions": {} + } + }, + "internal_handlers.SubmissionsByDateResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 25 + }, + "date": { + "type": "string", + "example": "2025-01-15" + } + } + }, + "internal_handlers.SubmitRequest": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/definitions/formera_internal_models.SubmissionData" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "internal_handlers.UpdateFormRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FormField" + } + }, + "password": { + "description": "Raw password, will be hashed", + "type": "string" + }, + "password_protected": { + "type": "boolean" + }, + "settings": { + "$ref": "#/definitions/formera_internal_models.FormSettings" + }, + "slug": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/formera_internal_models.FormStatus" + }, + "title": { + "type": "string" + } + } + }, + "internal_handlers.UpdateSettingsRequest": { + "type": "object", + "properties": { + "allow_registration": { + "type": "boolean" + }, + "app_name": { + "type": "string" + }, + "favicon_url": { + "type": "string" + }, + "footer_links": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FooterLink" + } + }, + "language": { + "type": "string" + }, + "login_background_url": { + "type": "string" + }, + "logo_show_text": { + "type": "boolean" + }, + "logo_url": { + "type": "string" + }, + "primary_color": { + "type": "string" + }, + "theme": { + "type": "string" + } + } + }, + "internal_handlers.VerifyPasswordRequest": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "password": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Type \"Bearer\" followed by a space and JWT token", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "1.0", + Host: "localhost:8080", + BasePath: "/api", + Schemes: []string{}, + Title: "Formera API", + Description: "REST API for Formera - a self-hosted form builder application", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json new file mode 100644 index 0000000..aa7d405 --- /dev/null +++ b/backend/docs/swagger.json @@ -0,0 +1,2169 @@ +{ + "swagger": "2.0", + "info": { + "description": "REST API for Formera - a self-hosted form builder application", + "title": "Formera API", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "https://github.com/your-repo/formera" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + }, + "version": "1.0" + }, + "host": "localhost:8080", + "basePath": "/api", + "paths": { + "/auth/login": { + "post": { + "description": "Authenticate with email and password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Login user", + "parameters": [ + { + "description": "Login credentials", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.AuthResponse" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Invalid credentials", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/auth/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get the authenticated user's profile", + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Get current user", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.User" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "User not found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/auth/register": { + "post": { + "description": "Create a new user account (if registration is enabled)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Auth" + ], + "summary": "Register new user", + "parameters": [ + { + "description": "Registration data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/internal_handlers.AuthResponse" + } + }, + "400": { + "description": "Invalid request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "403": { + "description": "Registration disabled", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "409": { + "description": "Email already registered", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/files/{path}": { + "get": { + "description": "Serve a file by path (streams from storage)", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "Files" + ], + "summary": "Get file", + "parameters": [ + { + "type": "string", + "description": "File path", + "name": "path", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "File content", + "schema": { + "type": "file" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get paginated list of user's forms", + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "List forms", + "parameters": [ + { + "type": "integer", + "default": 1, + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Items per page", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_pagination.Result" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a new form", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Create form", + "parameters": [ + { + "description": "Form data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.CreateFormRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/check-slug": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Check if a slug is available for use", + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Check slug availability", + "parameters": [ + { + "type": "string", + "description": "Slug to check", + "name": "slug", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Exclude this form from check", + "name": "form_id", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.SlugCheckResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get a form by ID", + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Get form", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update a form by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Update form", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Update data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.UpdateFormRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "409": { + "description": "Slug already taken", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a form and all its submissions", + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Delete form", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.MessageResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/duplicate": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Create a copy of an existing form", + "produces": [ + "application/json" + ], + "tags": [ + "Forms" + ], + "summary": "Duplicate form", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/export/csv": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Download all submissions as a CSV file", + "produces": [ + "text/csv" + ], + "tags": [ + "Submissions" + ], + "summary": "Export submissions as CSV", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "CSV file", + "schema": { + "type": "file" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/export/json": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Download all submissions as a JSON file", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "Export submissions as JSON", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "JSON array of submissions", + "schema": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/stats": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get submission statistics for a form", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "Get form statistics", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.FormStatsResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/submissions": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get paginated list of form submissions", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "List submissions", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "integer", + "default": 1, + "description": "Page number", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "default": 20, + "description": "Items per page", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.SubmissionListResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/submissions/by-date": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get submission counts grouped by date", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "Get submissions by date", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/internal_handlers.SubmissionsByDateResponse" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/forms/{id}/submissions/{submissionId}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get a specific submission by ID", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "Get submission", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Submission ID", + "name": "submissionId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Submission" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete a specific submission", + "produces": [ + "application/json" + ], + "tags": [ + "Submissions" + ], + "summary": "Delete submission", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Submission ID", + "name": "submissionId", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.MessageResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/public/forms/{id}": { + "get": { + "description": "Get a published form by ID or slug (public access)", + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "Get public form", + "parameters": [ + { + "type": "string", + "description": "Form ID or slug", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/public/forms/{id}/submit": { + "post": { + "description": "Submit a response to a published form", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "Submit form", + "parameters": [ + { + "type": "string", + "description": "Form ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Submission data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.SubmitRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/formera_internal_models.Submission" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "403": { + "description": "Form closed or max submissions reached", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/public/forms/{id}/verify-password": { + "post": { + "description": "Verify password for a password-protected form", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Public" + ], + "summary": "Verify form password", + "parameters": [ + { + "type": "string", + "description": "Form ID or slug", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Password", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.VerifyPasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "Returns form if password is valid", + "schema": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/public/upload": { + "post": { + "description": "Upload a file (for form submissions)", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Uploads" + ], + "summary": "Upload file", + "parameters": [ + { + "type": "file", + "description": "File to upload", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_storage.UploadResult" + } + }, + "400": { + "description": "Invalid file", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/settings": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Get application settings (admin only)", + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Get settings", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Settings" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "403": { + "description": "Admin access required", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Update application settings (admin only)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Settings" + ], + "summary": "Update settings", + "parameters": [ + { + "description": "Settings data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.UpdateSettingsRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_models.Settings" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "403": { + "description": "Admin access required", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/setup/complete": { + "post": { + "description": "Create the first admin user and complete setup", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Setup" + ], + "summary": "Complete initial setup", + "parameters": [ + { + "description": "Setup data", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_handlers.SetupRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.AuthResponse" + } + }, + "400": { + "description": "Setup already completed or invalid data", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/setup/status": { + "get": { + "description": "Get application setup status and public settings", + "produces": [ + "application/json" + ], + "tags": [ + "Setup" + ], + "summary": "Get setup status", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.SetupStatusResponse" + } + } + } + } + }, + "/uploads/image": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Upload an image file (for form design backgrounds)", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Uploads" + ], + "summary": "Upload image", + "parameters": [ + { + "type": "file", + "description": "Image file", + "name": "file", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/formera_internal_storage.UploadResult" + } + }, + "400": { + "description": "Invalid file", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "429": { + "description": "Rate limit exceeded", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + }, + "/uploads/{id}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Delete an uploaded file", + "produces": [ + "application/json" + ], + "tags": [ + "Uploads" + ], + "summary": "Delete file", + "parameters": [ + { + "type": "string", + "description": "File ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_handlers.MessageResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/internal_handlers.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "formera_internal_models.FieldType": { + "type": "string", + "enum": [ + "text", + "textarea", + "number", + "email", + "phone", + "date", + "time", + "url", + "richtext", + "select", + "radio", + "checkbox", + "dropdown", + "file", + "rating", + "scale", + "signature", + "section", + "pagebreak", + "divider", + "heading", + "paragraph", + "image" + ], + "x-enum-varnames": [ + "FieldTypeText", + "FieldTypeTextarea", + "FieldTypeNumber", + "FieldTypeEmail", + "FieldTypePhone", + "FieldTypeDate", + "FieldTypeTime", + "FieldTypeURL", + "FieldTypeRichtext", + "FieldTypeSelect", + "FieldTypeRadio", + "FieldTypeCheckbox", + "FieldTypeDropdown", + "FieldTypeFile", + "FieldTypeRating", + "FieldTypeScale", + "FieldTypeSignature", + "FieldTypeSection", + "FieldTypePagebreak", + "FieldTypeDivider", + "FieldTypeHeading", + "FieldTypeParagraph", + "FieldTypeImage" + ] + }, + "formera_internal_models.FooterLink": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "formera_internal_models.Form": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FormField" + } + }, + "id": { + "type": "string" + }, + "password_protected": { + "description": "Password protection", + "type": "boolean" + }, + "settings": { + "$ref": "#/definitions/formera_internal_models.FormSettings" + }, + "slug": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/formera_internal_models.FormStatus" + }, + "submissions": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.Submission" + } + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "string" + } + } + }, + "formera_internal_models.FormDesign": { + "type": "object", + "properties": { + "backgroundColor": { + "type": "string" + }, + "backgroundImage": { + "type": "string" + }, + "backgroundPosition": { + "type": "string" + }, + "backgroundSize": { + "type": "string" + }, + "borderRadius": { + "type": "string" + }, + "buttonStyle": { + "type": "string" + }, + "fontFamily": { + "type": "string" + }, + "formBackgroundColor": { + "type": "string" + }, + "headerStyle": { + "type": "string" + }, + "maxWidth": { + "type": "string" + }, + "primaryColor": { + "type": "string" + }, + "textColor": { + "type": "string" + } + } + }, + "formera_internal_models.FormField": { + "type": "object", + "properties": { + "allowedTypes": { + "description": "File Upload", + "type": "array", + "items": { + "type": "string" + } + }, + "collapsed": { + "type": "boolean" + }, + "collapsible": { + "type": "boolean" + }, + "content": { + "description": "Layout-specific", + "type": "string" + }, + "description": { + "description": "Description/Help text", + "type": "string" + }, + "headingLevel": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "imageAlt": { + "type": "string" + }, + "imageUrl": { + "type": "string" + }, + "label": { + "type": "string" + }, + "maxFileSize": { + "type": "integer" + }, + "maxLabel": { + "type": "string" + }, + "maxValue": { + "type": "integer" + }, + "minLabel": { + "type": "string" + }, + "minValue": { + "description": "Rating/Scale", + "type": "integer" + }, + "multiple": { + "type": "boolean" + }, + "options": { + "type": "array", + "items": { + "type": "string" + } + }, + "order": { + "type": "integer" + }, + "placeholder": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "richTextContent": { + "description": "Rich Text", + "type": "string" + }, + "sectionDescription": { + "type": "string" + }, + "sectionTitle": { + "description": "Section-specific", + "type": "string" + }, + "type": { + "$ref": "#/definitions/formera_internal_models.FieldType" + }, + "validation": { + "type": "object", + "additionalProperties": true + } + } + }, + "formera_internal_models.FormSettings": { + "type": "object", + "properties": { + "allow_multiple": { + "type": "boolean" + }, + "design": { + "$ref": "#/definitions/formera_internal_models.FormDesign" + }, + "end_date": { + "type": "string" + }, + "max_submissions": { + "type": "integer" + }, + "notification_email": { + "type": "string" + }, + "notify_on_submission": { + "type": "boolean" + }, + "require_login": { + "type": "boolean" + }, + "start_date": { + "type": "string" + }, + "submit_button_text": { + "type": "string" + }, + "success_message": { + "type": "string" + } + } + }, + "formera_internal_models.FormStatus": { + "type": "string", + "enum": [ + "draft", + "published", + "closed" + ], + "x-enum-varnames": [ + "FormStatusDraft", + "FormStatusPublished", + "FormStatusClosed" + ] + }, + "formera_internal_models.Settings": { + "type": "object", + "properties": { + "allow_registration": { + "type": "boolean" + }, + "app_name": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "favicon_url": { + "type": "string" + }, + "footer_links": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FooterLink" + } + }, + "id": { + "type": "integer" + }, + "language": { + "description": "Language and Theme", + "type": "string" + }, + "login_background_url": { + "type": "string" + }, + "logo_show_text": { + "type": "boolean" + }, + "logo_url": { + "type": "string" + }, + "primary_color": { + "description": "Customization", + "type": "string" + }, + "setup_completed": { + "type": "boolean" + }, + "theme": { + "description": "\"light\", \"dark\", or \"system\"", + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "formera_internal_models.Submission": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "data": { + "$ref": "#/definitions/formera_internal_models.SubmissionData" + }, + "form_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "metadata": { + "$ref": "#/definitions/formera_internal_models.SubmissionMetadata" + } + } + }, + "formera_internal_models.SubmissionData": { + "type": "object", + "additionalProperties": true + }, + "formera_internal_models.SubmissionMetadata": { + "type": "object", + "properties": { + "ip": { + "type": "string" + }, + "referrer": { + "type": "string" + }, + "tracking": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "user_agent": { + "type": "string" + }, + "utm_campaign": { + "type": "string" + }, + "utm_content": { + "type": "string" + }, + "utm_medium": { + "type": "string" + }, + "utm_source": { + "description": "UTM/Tracking parameters", + "type": "string" + }, + "utm_term": { + "type": "string" + } + } + }, + "formera_internal_models.User": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "forms": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.Form" + } + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/formera_internal_models.UserRole" + }, + "updated_at": { + "type": "string" + } + } + }, + "formera_internal_models.UserRole": { + "type": "string", + "enum": [ + "admin", + "user" + ], + "x-enum-varnames": [ + "RoleAdmin", + "RoleUser" + ] + }, + "formera_internal_pagination.Result": { + "type": "object", + "properties": { + "data": {}, + "page": { + "type": "integer" + }, + "page_size": { + "type": "integer" + }, + "total_items": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + }, + "formera_internal_storage.UploadResult": { + "type": "object", + "properties": { + "filename": { + "type": "string" + }, + "id": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "path": { + "description": "Relative path (e.g., \"images/2025/12/abc123.png\")", + "type": "string" + }, + "size": { + "type": "integer" + }, + "url": { + "description": "Full URL for immediate use", + "type": "string" + } + } + }, + "internal_handlers.AuthResponse": { + "type": "object", + "properties": { + "token": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/formera_internal_models.User" + } + } + }, + "internal_handlers.CreateFormRequest": { + "type": "object", + "required": [ + "title" + ], + "properties": { + "description": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FormField" + } + }, + "settings": { + "$ref": "#/definitions/formera_internal_models.FormSettings" + }, + "title": { + "type": "string" + } + } + }, + "internal_handlers.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "Invalid request" + } + } + }, + "internal_handlers.FormStatsResponse": { + "type": "object", + "properties": { + "field_stats": { + "type": "object", + "additionalProperties": true + }, + "total_submissions": { + "type": "integer", + "example": 150 + } + } + }, + "internal_handlers.LoginRequest": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "internal_handlers.MessageResponse": { + "type": "object", + "properties": { + "message": { + "type": "string", + "example": "Operation successful" + } + } + }, + "internal_handlers.RegisterRequest": { + "type": "object", + "required": [ + "email", + "name", + "password" + ], + "properties": { + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string", + "minLength": 8 + } + } + }, + "internal_handlers.SetupRequest": { + "type": "object", + "required": [ + "email", + "name", + "password" + ], + "properties": { + "allow_registration": { + "type": "boolean" + }, + "app_name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + }, + "password": { + "type": "string", + "minLength": 8 + } + } + }, + "internal_handlers.SetupStatusResponse": { + "type": "object", + "properties": { + "allow_registration": { + "type": "boolean" + }, + "app_name": { + "type": "string" + }, + "favicon_url": { + "type": "string" + }, + "footer_links": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FooterLink" + } + }, + "language": { + "type": "string" + }, + "login_background_url": { + "type": "string" + }, + "logo_show_text": { + "type": "boolean" + }, + "logo_url": { + "type": "string" + }, + "primary_color": { + "type": "string" + }, + "setup_required": { + "type": "boolean" + }, + "theme": { + "type": "string" + } + } + }, + "internal_handlers.SlugCheckResponse": { + "type": "object", + "properties": { + "available": { + "type": "boolean", + "example": true + }, + "reason": { + "type": "string", + "example": "taken" + }, + "slug": { + "type": "string", + "example": "contact-form" + } + } + }, + "internal_handlers.SubmissionListResponse": { + "type": "object", + "properties": { + "form": {}, + "submissions": {} + } + }, + "internal_handlers.SubmissionsByDateResponse": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "example": 25 + }, + "date": { + "type": "string", + "example": "2025-01-15" + } + } + }, + "internal_handlers.SubmitRequest": { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "$ref": "#/definitions/formera_internal_models.SubmissionData" + }, + "metadata": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "internal_handlers.UpdateFormRequest": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FormField" + } + }, + "password": { + "description": "Raw password, will be hashed", + "type": "string" + }, + "password_protected": { + "type": "boolean" + }, + "settings": { + "$ref": "#/definitions/formera_internal_models.FormSettings" + }, + "slug": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/formera_internal_models.FormStatus" + }, + "title": { + "type": "string" + } + } + }, + "internal_handlers.UpdateSettingsRequest": { + "type": "object", + "properties": { + "allow_registration": { + "type": "boolean" + }, + "app_name": { + "type": "string" + }, + "favicon_url": { + "type": "string" + }, + "footer_links": { + "type": "array", + "items": { + "$ref": "#/definitions/formera_internal_models.FooterLink" + } + }, + "language": { + "type": "string" + }, + "login_background_url": { + "type": "string" + }, + "logo_show_text": { + "type": "boolean" + }, + "logo_url": { + "type": "string" + }, + "primary_color": { + "type": "string" + }, + "theme": { + "type": "string" + } + } + }, + "internal_handlers.VerifyPasswordRequest": { + "type": "object", + "required": [ + "password" + ], + "properties": { + "password": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "BearerAuth": { + "description": "Type \"Bearer\" followed by a space and JWT token", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml new file mode 100644 index 0000000..a74e150 --- /dev/null +++ b/backend/docs/swagger.yaml @@ -0,0 +1,1434 @@ +basePath: /api +definitions: + formera_internal_models.FieldType: + enum: + - text + - textarea + - number + - email + - phone + - date + - time + - url + - richtext + - select + - radio + - checkbox + - dropdown + - file + - rating + - scale + - signature + - section + - pagebreak + - divider + - heading + - paragraph + - image + type: string + x-enum-varnames: + - FieldTypeText + - FieldTypeTextarea + - FieldTypeNumber + - FieldTypeEmail + - FieldTypePhone + - FieldTypeDate + - FieldTypeTime + - FieldTypeURL + - FieldTypeRichtext + - FieldTypeSelect + - FieldTypeRadio + - FieldTypeCheckbox + - FieldTypeDropdown + - FieldTypeFile + - FieldTypeRating + - FieldTypeScale + - FieldTypeSignature + - FieldTypeSection + - FieldTypePagebreak + - FieldTypeDivider + - FieldTypeHeading + - FieldTypeParagraph + - FieldTypeImage + formera_internal_models.FooterLink: + properties: + label: + type: string + url: + type: string + type: object + formera_internal_models.Form: + properties: + created_at: + type: string + description: + type: string + fields: + items: + $ref: '#/definitions/formera_internal_models.FormField' + type: array + id: + type: string + password_protected: + description: Password protection + type: boolean + settings: + $ref: '#/definitions/formera_internal_models.FormSettings' + slug: + type: string + status: + $ref: '#/definitions/formera_internal_models.FormStatus' + submissions: + items: + $ref: '#/definitions/formera_internal_models.Submission' + type: array + title: + type: string + updated_at: + type: string + user_id: + type: string + type: object + formera_internal_models.FormDesign: + properties: + backgroundColor: + type: string + backgroundImage: + type: string + backgroundPosition: + type: string + backgroundSize: + type: string + borderRadius: + type: string + buttonStyle: + type: string + fontFamily: + type: string + formBackgroundColor: + type: string + headerStyle: + type: string + maxWidth: + type: string + primaryColor: + type: string + textColor: + type: string + type: object + formera_internal_models.FormField: + properties: + allowedTypes: + description: File Upload + items: + type: string + type: array + collapsed: + type: boolean + collapsible: + type: boolean + content: + description: Layout-specific + type: string + description: + description: Description/Help text + type: string + headingLevel: + type: integer + id: + type: string + imageAlt: + type: string + imageUrl: + type: string + label: + type: string + maxFileSize: + type: integer + maxLabel: + type: string + maxValue: + type: integer + minLabel: + type: string + minValue: + description: Rating/Scale + type: integer + multiple: + type: boolean + options: + items: + type: string + type: array + order: + type: integer + placeholder: + type: string + required: + type: boolean + richTextContent: + description: Rich Text + type: string + sectionDescription: + type: string + sectionTitle: + description: Section-specific + type: string + type: + $ref: '#/definitions/formera_internal_models.FieldType' + validation: + additionalProperties: true + type: object + type: object + formera_internal_models.FormSettings: + properties: + allow_multiple: + type: boolean + design: + $ref: '#/definitions/formera_internal_models.FormDesign' + end_date: + type: string + max_submissions: + type: integer + notification_email: + type: string + notify_on_submission: + type: boolean + require_login: + type: boolean + start_date: + type: string + submit_button_text: + type: string + success_message: + type: string + type: object + formera_internal_models.FormStatus: + enum: + - draft + - published + - closed + type: string + x-enum-varnames: + - FormStatusDraft + - FormStatusPublished + - FormStatusClosed + formera_internal_models.Settings: + properties: + allow_registration: + type: boolean + app_name: + type: string + created_at: + type: string + favicon_url: + type: string + footer_links: + items: + $ref: '#/definitions/formera_internal_models.FooterLink' + type: array + id: + type: integer + language: + description: Language and Theme + type: string + login_background_url: + type: string + logo_show_text: + type: boolean + logo_url: + type: string + primary_color: + description: Customization + type: string + setup_completed: + type: boolean + theme: + description: '"light", "dark", or "system"' + type: string + updated_at: + type: string + type: object + formera_internal_models.Submission: + properties: + created_at: + type: string + data: + $ref: '#/definitions/formera_internal_models.SubmissionData' + form_id: + type: string + id: + type: string + metadata: + $ref: '#/definitions/formera_internal_models.SubmissionMetadata' + type: object + formera_internal_models.SubmissionData: + additionalProperties: true + type: object + formera_internal_models.SubmissionMetadata: + properties: + ip: + type: string + referrer: + type: string + tracking: + additionalProperties: + type: string + type: object + user_agent: + type: string + utm_campaign: + type: string + utm_content: + type: string + utm_medium: + type: string + utm_source: + description: UTM/Tracking parameters + type: string + utm_term: + type: string + type: object + formera_internal_models.User: + properties: + created_at: + type: string + email: + type: string + forms: + items: + $ref: '#/definitions/formera_internal_models.Form' + type: array + id: + type: string + name: + type: string + role: + $ref: '#/definitions/formera_internal_models.UserRole' + updated_at: + type: string + type: object + formera_internal_models.UserRole: + enum: + - admin + - user + type: string + x-enum-varnames: + - RoleAdmin + - RoleUser + formera_internal_pagination.Result: + properties: + data: {} + page: + type: integer + page_size: + type: integer + total_items: + type: integer + total_pages: + type: integer + type: object + formera_internal_storage.UploadResult: + properties: + filename: + type: string + id: + type: string + mimeType: + type: string + path: + description: Relative path (e.g., "images/2025/12/abc123.png") + type: string + size: + type: integer + url: + description: Full URL for immediate use + type: string + type: object + internal_handlers.AuthResponse: + properties: + token: + type: string + user: + $ref: '#/definitions/formera_internal_models.User' + type: object + internal_handlers.CreateFormRequest: + properties: + description: + type: string + fields: + items: + $ref: '#/definitions/formera_internal_models.FormField' + type: array + settings: + $ref: '#/definitions/formera_internal_models.FormSettings' + title: + type: string + required: + - title + type: object + internal_handlers.ErrorResponse: + properties: + error: + example: Invalid request + type: string + type: object + internal_handlers.FormStatsResponse: + properties: + field_stats: + additionalProperties: true + type: object + total_submissions: + example: 150 + type: integer + type: object + internal_handlers.LoginRequest: + properties: + email: + type: string + password: + type: string + required: + - email + - password + type: object + internal_handlers.MessageResponse: + properties: + message: + example: Operation successful + type: string + type: object + internal_handlers.RegisterRequest: + properties: + email: + type: string + name: + type: string + password: + minLength: 8 + type: string + required: + - email + - name + - password + type: object + internal_handlers.SetupRequest: + properties: + allow_registration: + type: boolean + app_name: + type: string + email: + type: string + name: + type: string + password: + minLength: 8 + type: string + required: + - email + - name + - password + type: object + internal_handlers.SetupStatusResponse: + properties: + allow_registration: + type: boolean + app_name: + type: string + favicon_url: + type: string + footer_links: + items: + $ref: '#/definitions/formera_internal_models.FooterLink' + type: array + language: + type: string + login_background_url: + type: string + logo_show_text: + type: boolean + logo_url: + type: string + primary_color: + type: string + setup_required: + type: boolean + theme: + type: string + type: object + internal_handlers.SlugCheckResponse: + properties: + available: + example: true + type: boolean + reason: + example: taken + type: string + slug: + example: contact-form + type: string + type: object + internal_handlers.SubmissionListResponse: + properties: + form: {} + submissions: {} + type: object + internal_handlers.SubmissionsByDateResponse: + properties: + count: + example: 25 + type: integer + date: + example: "2025-01-15" + type: string + type: object + internal_handlers.SubmitRequest: + properties: + data: + $ref: '#/definitions/formera_internal_models.SubmissionData' + metadata: + additionalProperties: + type: string + type: object + required: + - data + type: object + internal_handlers.UpdateFormRequest: + properties: + description: + type: string + fields: + items: + $ref: '#/definitions/formera_internal_models.FormField' + type: array + password: + description: Raw password, will be hashed + type: string + password_protected: + type: boolean + settings: + $ref: '#/definitions/formera_internal_models.FormSettings' + slug: + type: string + status: + $ref: '#/definitions/formera_internal_models.FormStatus' + title: + type: string + type: object + internal_handlers.UpdateSettingsRequest: + properties: + allow_registration: + type: boolean + app_name: + type: string + favicon_url: + type: string + footer_links: + items: + $ref: '#/definitions/formera_internal_models.FooterLink' + type: array + language: + type: string + login_background_url: + type: string + logo_show_text: + type: boolean + logo_url: + type: string + primary_color: + type: string + theme: + type: string + type: object + internal_handlers.VerifyPasswordRequest: + properties: + password: + type: string + required: + - password + type: object +host: localhost:8080 +info: + contact: + name: API Support + url: https://github.com/your-repo/formera + description: REST API for Formera - a self-hosted form builder application + license: + name: MIT + url: https://opensource.org/licenses/MIT + termsOfService: http://swagger.io/terms/ + title: Formera API + version: "1.0" +paths: + /auth/login: + post: + consumes: + - application/json + description: Authenticate with email and password + parameters: + - description: Login credentials + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_handlers.LoginRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_handlers.AuthResponse' + "400": + description: Invalid request + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "401": + description: Invalid credentials + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "429": + description: Rate limit exceeded + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + summary: Login user + tags: + - Auth + /auth/me: + get: + description: Get the authenticated user's profile + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/formera_internal_models.User' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "404": + description: User not found + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Get current user + tags: + - Auth + /auth/register: + post: + consumes: + - application/json + description: Create a new user account (if registration is enabled) + parameters: + - description: Registration data + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_handlers.RegisterRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/internal_handlers.AuthResponse' + "400": + description: Invalid request + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "403": + description: Registration disabled + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "409": + description: Email already registered + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + summary: Register new user + tags: + - Auth + /files/{path}: + get: + description: Serve a file by path (streams from storage) + parameters: + - description: File path + in: path + name: path + required: true + type: string + produces: + - application/octet-stream + responses: + "200": + description: File content + schema: + type: file + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + summary: Get file + tags: + - Files + /forms: + get: + description: Get paginated list of user's forms + parameters: + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/formera_internal_pagination.Result' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: List forms + tags: + - Forms + post: + consumes: + - application/json + description: Create a new form + parameters: + - description: Form data + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_handlers.CreateFormRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/formera_internal_models.Form' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Create form + tags: + - Forms + /forms/{id}: + delete: + description: Delete a form and all its submissions + parameters: + - description: Form ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_handlers.MessageResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete form + tags: + - Forms + get: + description: Get a form by ID + parameters: + - description: Form ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/formera_internal_models.Form' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Get form + tags: + - Forms + put: + consumes: + - application/json + description: Update a form by ID + parameters: + - description: Form ID + in: path + name: id + required: true + type: string + - description: Update data + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_handlers.UpdateFormRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/formera_internal_models.Form' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "409": + description: Slug already taken + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Update form + tags: + - Forms + /forms/{id}/duplicate: + post: + description: Create a copy of an existing form + parameters: + - description: Form ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/formera_internal_models.Form' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Duplicate form + tags: + - Forms + /forms/{id}/export/csv: + get: + description: Download all submissions as a CSV file + parameters: + - description: Form ID + in: path + name: id + required: true + type: string + produces: + - text/csv + responses: + "200": + description: CSV file + schema: + type: file + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Export submissions as CSV + tags: + - Submissions + /forms/{id}/export/json: + get: + description: Download all submissions as a JSON file + parameters: + - description: Form ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: JSON array of submissions + schema: + items: + additionalProperties: true + type: object + type: array + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Export submissions as JSON + tags: + - Submissions + /forms/{id}/stats: + get: + description: Get submission statistics for a form + parameters: + - description: Form ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_handlers.FormStatsResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Get form statistics + tags: + - Submissions + /forms/{id}/submissions: + get: + description: Get paginated list of form submissions + parameters: + - description: Form ID + in: path + name: id + required: true + type: string + - default: 1 + description: Page number + in: query + name: page + type: integer + - default: 20 + description: Items per page + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_handlers.SubmissionListResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: List submissions + tags: + - Submissions + /forms/{id}/submissions/{submissionId}: + delete: + description: Delete a specific submission + parameters: + - description: Form ID + in: path + name: id + required: true + type: string + - description: Submission ID + in: path + name: submissionId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_handlers.MessageResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete submission + tags: + - Submissions + get: + description: Get a specific submission by ID + parameters: + - description: Form ID + in: path + name: id + required: true + type: string + - description: Submission ID + in: path + name: submissionId + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/formera_internal_models.Submission' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Get submission + tags: + - Submissions + /forms/{id}/submissions/by-date: + get: + description: Get submission counts grouped by date + parameters: + - description: Form ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/internal_handlers.SubmissionsByDateResponse' + type: array + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Get submissions by date + tags: + - Submissions + /forms/check-slug: + get: + description: Check if a slug is available for use + parameters: + - description: Slug to check + in: query + name: slug + required: true + type: string + - description: Exclude this form from check + in: query + name: form_id + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_handlers.SlugCheckResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Check slug availability + tags: + - Forms + /public/forms/{id}: + get: + description: Get a published form by ID or slug (public access) + parameters: + - description: Form ID or slug + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/formera_internal_models.Form' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + summary: Get public form + tags: + - Public + /public/forms/{id}/submit: + post: + consumes: + - application/json + description: Submit a response to a published form + parameters: + - description: Form ID + in: path + name: id + required: true + type: string + - description: Submission data + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_handlers.SubmitRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/formera_internal_models.Submission' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "403": + description: Form closed or max submissions reached + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "429": + description: Rate limit exceeded + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + summary: Submit form + tags: + - Public + /public/forms/{id}/verify-password: + post: + consumes: + - application/json + description: Verify password for a password-protected form + parameters: + - description: Form ID or slug + in: path + name: id + required: true + type: string + - description: Password + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_handlers.VerifyPasswordRequest' + produces: + - application/json + responses: + "200": + description: Returns form if password is valid + schema: + $ref: '#/definitions/formera_internal_models.Form' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + summary: Verify form password + tags: + - Public + /public/upload: + post: + consumes: + - multipart/form-data + description: Upload a file (for form submissions) + parameters: + - description: File to upload + in: formData + name: file + required: true + type: file + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/formera_internal_storage.UploadResult' + "400": + description: Invalid file + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "429": + description: Rate limit exceeded + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + summary: Upload file + tags: + - Uploads + /settings: + get: + description: Get application settings (admin only) + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/formera_internal_models.Settings' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "403": + description: Admin access required + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Get settings + tags: + - Settings + put: + consumes: + - application/json + description: Update application settings (admin only) + parameters: + - description: Settings data + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_handlers.UpdateSettingsRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/formera_internal_models.Settings' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "403": + description: Admin access required + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Update settings + tags: + - Settings + /setup/complete: + post: + consumes: + - application/json + description: Create the first admin user and complete setup + parameters: + - description: Setup data + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_handlers.SetupRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_handlers.AuthResponse' + "400": + description: Setup already completed or invalid data + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + summary: Complete initial setup + tags: + - Setup + /setup/status: + get: + description: Get application setup status and public settings + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_handlers.SetupStatusResponse' + summary: Get setup status + tags: + - Setup + /uploads/{id}: + delete: + description: Delete an uploaded file + parameters: + - description: File ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_handlers.MessageResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete file + tags: + - Uploads + /uploads/image: + post: + consumes: + - multipart/form-data + description: Upload an image file (for form design backgrounds) + parameters: + - description: Image file + in: formData + name: file + required: true + type: file + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/formera_internal_storage.UploadResult' + "400": + description: Invalid file + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + "429": + description: Rate limit exceeded + schema: + $ref: '#/definitions/internal_handlers.ErrorResponse' + security: + - BearerAuth: [] + summary: Upload image + tags: + - Uploads +securityDefinitions: + BearerAuth: + description: Type "Bearer" followed by a space and JWT token + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/backend/go.mod b/backend/go.mod index 770b781..cfecb8c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -18,6 +18,9 @@ require ( ) require ( + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.2.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.21 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.25 // indirect @@ -32,37 +35,68 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.6 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 // indirect github.com/aws/smithy-go v1.22.1 // indirect - github.com/bytedance/sonic v1.14.0 // indirect - github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/aymerick/douceur v0.2.0 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.14.2 // indirect + github.com/bytedance/sonic/loader v0.4.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-openapi/jsonpointer v0.22.3 // indirect + github.com/go-openapi/jsonreference v0.21.3 // indirect + github.com/go-openapi/spec v0.22.1 // indirect + github.com/go-openapi/swag v0.25.4 // indirect + github.com/go-openapi/swag/conv v0.25.4 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.4 // indirect + github.com/go-openapi/swag/loading v0.25.4 // indirect + github.com/go-openapi/swag/stringutils v0.25.4 // indirect + github.com/go-openapi/swag/typeutils v0.25.4 // indirect + github.com/go-openapi/swag/yamlutils v0.25.4 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/go-playground/validator/v10 v10.28.0 // indirect github.com/goccy/go-json v0.10.5 // indirect - github.com/goccy/go-yaml v1.18.0 // indirect + github.com/goccy/go-yaml v1.19.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/quic-go/qpack v0.5.1 // indirect - github.com/quic-go/quic-go v0.54.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.57.1 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect + github.com/swaggo/files v1.0.1 // indirect + github.com/swaggo/gin-swagger v1.6.1 // indirect + github.com/swaggo/swag v1.16.6 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.3.0 // indirect - go.uber.org/mock v0.5.0 // indirect - golang.org/x/arch v0.20.0 // indirect - golang.org/x/mod v0.29.0 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + github.com/urfave/cli/v2 v2.27.7 // indirect + github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect + go.uber.org/mock v0.6.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.23.0 // indirect + golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect - golang.org/x/tools v0.38.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect + golang.org/x/tools v0.39.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index cd0a0af..186fec9 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,9 @@ +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.2.1 h1:QsZ4TjvwiMpat6gBCBxEQI0rcS9ehtkKtSpiUnd9N28= +github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/aws/aws-sdk-go-v2 v1.32.6 h1:7BokKRgRPuGmKkFMhEg/jSul+tB9VvXhcViILtfG8b4= github.com/aws/aws-sdk-go-v2 v1.32.6/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.7 h1:lL7IfaFzngfx0ZwUGOZdsFFnQ5uLvR0hWqqhyE7Q9M8= @@ -34,23 +40,58 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.33.2 h1:s4074ZO1Hk8qv65GqNXqDjmkf4HS github.com/aws/aws-sdk-go-v2/service/sts v1.33.2/go.mod h1:mVggCnIWoM09jP71Wh+ea7+5gAp53q+49wDFs1SW5z8= github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE= +github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o= +github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik= +github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-openapi/jsonpointer v0.22.3 h1:dKMwfV4fmt6Ah90zloTbUKWMD+0he+12XYAsPotrkn8= +github.com/go-openapi/jsonpointer v0.22.3/go.mod h1:0lBbqeRsQ5lIanv3LHZBrmRGHLHcQoOXQnf88fHlGWo= +github.com/go-openapi/jsonreference v0.21.3 h1:96Dn+MRPa0nYAR8DR1E03SblB5FJvh7W6krPI0Z7qMc= +github.com/go-openapi/jsonreference v0.21.3/go.mod h1:RqkUP0MrLf37HqxZxrIAtTWW4ZJIK1VzduhXYBEeGc4= +github.com/go-openapi/spec v0.22.1 h1:beZMa5AVQzRspNjvhe5aG1/XyBSMeX1eEOs7dMoXh/k= +github.com/go-openapi/spec v0.22.1/go.mod h1:c7aeIQT175dVowfp7FeCvXXnjN/MrpaONStibD2WtDA= +github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= +github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= +github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= +github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= +github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= +github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= +github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= +github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= +github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= +github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= +github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= +github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= +github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -59,10 +100,15 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688= +github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= +github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -70,22 +116,34 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -93,47 +151,119 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10= +github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU= +github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4= +github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg= +github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= +golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -141,3 +271,5 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index ecebd58..690f5b7 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -4,6 +4,7 @@ import ( "log" "os" "strconv" + "strings" "time" "github.com/joho/godotenv" @@ -37,6 +38,14 @@ type Config struct { JWTSecret string CorsOrigin string + // Logging configuration + LogLevel string // debug, info, warn, error + LogPretty bool // Human-readable output (for development) + + // Proxy configuration + TrustedProxies []string // List of trusted proxy IPs/CIDRs (empty = trust all) + RealIPHeader string // Custom header for client IP (e.g., "CF-Connecting-IP", "X-Real-IP") + // Storage configuration Storage StorageConfig @@ -108,12 +117,16 @@ func Load() *Config { 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, + Port: port, + BaseURL: baseURL, + ApiURL: apiURL, + DBPath: getEnv("DB_PATH", "./data/formera.db"), + JWTSecret: getEnv("JWT_SECRET", "change-me-in-production-please"), + CorsOrigin: corsOrigin, + LogLevel: getEnv("LOG_LEVEL", "info"), + LogPretty: getEnv("LOG_PRETTY", "true") == "true", + TrustedProxies: parseTrustedProxies(getEnv("TRUSTED_PROXIES", "")), + RealIPHeader: getEnv("REAL_IP_HEADER", ""), Storage: StorageConfig{ Type: getEnv("STORAGE_TYPE", ""), // auto-detect if empty @@ -151,3 +164,18 @@ func getEnv(key, fallback string) string { } return fallback } + +// parseTrustedProxies parses a comma-separated list of trusted proxy IPs/CIDRs +func parseTrustedProxies(value string) []string { + if value == "" { + return nil // nil = trust all proxies (default for backwards compatibility) + } + var proxies []string + for _, p := range strings.Split(value, ",") { + p = strings.TrimSpace(p) + if p != "" { + proxies = append(proxies, p) + } + } + return proxies +} diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go index 73d3c2f..2e5f8d0 100644 --- a/backend/internal/handlers/auth.go +++ b/backend/internal/handlers/auth.go @@ -36,6 +36,18 @@ type AuthResponse struct { User *models.User `json:"user"` } +// Register godoc +// @Summary Register new user +// @Description Create a new user account (if registration is enabled) +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body RegisterRequest true "Registration data" +// @Success 201 {object} AuthResponse +// @Failure 400 {object} ErrorResponse "Invalid request" +// @Failure 403 {object} ErrorResponse "Registration disabled" +// @Failure 409 {object} ErrorResponse "Email already registered" +// @Router /auth/register [post] func (h *AuthHandler) Register(c *gin.Context) { var settings models.Settings database.DB.First(&settings) @@ -84,6 +96,18 @@ func (h *AuthHandler) Register(c *gin.Context) { }) } +// Login godoc +// @Summary Login user +// @Description Authenticate with email and password +// @Tags Auth +// @Accept json +// @Produce json +// @Param request body LoginRequest true "Login credentials" +// @Success 200 {object} AuthResponse +// @Failure 400 {object} ErrorResponse "Invalid request" +// @Failure 401 {object} ErrorResponse "Invalid credentials" +// @Failure 429 {object} ErrorResponse "Rate limit exceeded" +// @Router /auth/login [post] func (h *AuthHandler) Login(c *gin.Context) { var req LoginRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -114,6 +138,16 @@ func (h *AuthHandler) Login(c *gin.Context) { }) } +// Me godoc +// @Summary Get current user +// @Description Get the authenticated user's profile +// @Tags Auth +// @Produce json +// @Success 200 {object} models.User +// @Failure 401 {object} ErrorResponse "Unauthorized" +// @Failure 404 {object} ErrorResponse "User not found" +// @Security BearerAuth +// @Router /auth/me [get] func (h *AuthHandler) Me(c *gin.Context) { userID := c.GetString("user_id") @@ -130,6 +164,7 @@ func (h *AuthHandler) generateToken(user *models.User) (string, error) { claims := &middleware.Claims{ UserID: user.ID, Email: user.Email, + Role: string(user.Role), RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * 7 * time.Hour)), // 7 days IssuedAt: jwt.NewNumericDate(time.Now()), diff --git a/backend/internal/handlers/form.go b/backend/internal/handlers/form.go index d96a542..347d39f 100644 --- a/backend/internal/handlers/form.go +++ b/backend/internal/handlers/form.go @@ -7,6 +7,8 @@ import ( "formera/internal/database" "formera/internal/models" + "formera/internal/pagination" + "formera/internal/sanitizer" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" @@ -60,6 +62,18 @@ func normalizeSlug(slug string) string { return slug } +// Create godoc +// @Summary Create form +// @Description Create a new form +// @Tags Forms +// @Accept json +// @Produce json +// @Param request body CreateFormRequest true "Form data" +// @Success 201 {object} models.Form +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Security BearerAuth +// @Router /forms [post] func (h *FormHandler) Create(c *gin.Context) { userID := c.GetString("user_id") @@ -71,8 +85,8 @@ func (h *FormHandler) Create(c *gin.Context) { form := &models.Form{ UserID: userID, - Title: req.Title, - Description: req.Description, + Title: sanitizer.StripHTML(req.Title), + Description: sanitizer.SanitizeHTML(req.Description), Fields: req.Fields, Settings: req.Settings, Status: models.FormStatusDraft, @@ -86,18 +100,47 @@ func (h *FormHandler) Create(c *gin.Context) { c.JSON(http.StatusCreated, form) } +// List godoc +// @Summary List forms +// @Description Get paginated list of user's forms +// @Tags Forms +// @Produce json +// @Param page query int false "Page number" default(1) +// @Param page_size query int false "Items per page" default(20) +// @Success 200 {object} pagination.Result +// @Failure 401 {object} ErrorResponse +// @Security BearerAuth +// @Router /forms [get] func (h *FormHandler) List(c *gin.Context) { userID := c.GetString("user_id") + params := pagination.GetParams(c) + + var totalItems int64 + database.DB.Model(&models.Form{}).Where("user_id = ?", userID).Count(&totalItems) var forms []models.Form - if result := database.DB.Where("user_id = ?", userID).Order("created_at DESC").Find(&forms); result.Error != nil { + if result := database.DB.Where("user_id = ?", userID). + Order("created_at DESC"). + Scopes(pagination.Paginate(params)). + Find(&forms); result.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch forms"}) return } - c.JSON(http.StatusOK, forms) + c.JSON(http.StatusOK, pagination.CreateResult(forms, params, totalItems)) } +// Get godoc +// @Summary Get form +// @Description Get a form by ID +// @Tags Forms +// @Produce json +// @Param id path string true "Form ID" +// @Success 200 {object} models.Form +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Security BearerAuth +// @Router /forms/{id} [get] func (h *FormHandler) Get(c *gin.Context) { userID := c.GetString("user_id") formID := c.Param("id") @@ -111,6 +154,15 @@ func (h *FormHandler) Get(c *gin.Context) { c.JSON(http.StatusOK, form) } +// GetPublic godoc +// @Summary Get public form +// @Description Get a published form by ID or slug (public access) +// @Tags Public +// @Produce json +// @Param id path string true "Form ID or slug" +// @Success 200 {object} models.Form +// @Failure 404 {object} ErrorResponse +// @Router /public/forms/{id} [get] func (h *FormHandler) GetPublic(c *gin.Context) { identifier := c.Param("id") @@ -136,6 +188,18 @@ func (h *FormHandler) GetPublic(c *gin.Context) { c.JSON(http.StatusOK, form) } +// VerifyPassword godoc +// @Summary Verify form password +// @Description Verify password for a password-protected form +// @Tags Public +// @Accept json +// @Produce json +// @Param id path string true "Form ID or slug" +// @Param request body VerifyPasswordRequest true "Password" +// @Success 200 {object} models.Form "Returns form if password is valid" +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Router /public/forms/{id}/verify-password [post] func (h *FormHandler) VerifyPassword(c *gin.Context) { identifier := c.Param("id") @@ -168,6 +232,18 @@ func (h *FormHandler) VerifyPassword(c *gin.Context) { }) } +// CheckSlugAvailability godoc +// @Summary Check slug availability +// @Description Check if a slug is available for use +// @Tags Forms +// @Produce json +// @Param slug query string true "Slug to check" +// @Param form_id query string false "Exclude this form from check" +// @Success 200 {object} SlugCheckResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Security BearerAuth +// @Router /forms/check-slug [get] func (h *FormHandler) CheckSlugAvailability(c *gin.Context) { userID := c.GetString("user_id") slug := c.Query("slug") @@ -210,6 +286,21 @@ func (h *FormHandler) CheckSlugAvailability(c *gin.Context) { }) } +// Update godoc +// @Summary Update form +// @Description Update a form by ID +// @Tags Forms +// @Accept json +// @Produce json +// @Param id path string true "Form ID" +// @Param request body UpdateFormRequest true "Update data" +// @Success 200 {object} models.Form +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Failure 409 {object} ErrorResponse "Slug already taken" +// @Security BearerAuth +// @Router /forms/{id} [put] func (h *FormHandler) Update(c *gin.Context) { userID := c.GetString("user_id") formID := c.Param("id") @@ -227,10 +318,10 @@ func (h *FormHandler) Update(c *gin.Context) { } if req.Title != "" { - form.Title = req.Title + form.Title = sanitizer.StripHTML(req.Title) } if req.Description != "" { - form.Description = req.Description + form.Description = sanitizer.SanitizeHTML(req.Description) } if req.Fields != nil { form.Fields = req.Fields @@ -285,6 +376,17 @@ func (h *FormHandler) Update(c *gin.Context) { c.JSON(http.StatusOK, form) } +// Delete godoc +// @Summary Delete form +// @Description Delete a form and all its submissions +// @Tags Forms +// @Produce json +// @Param id path string true "Form ID" +// @Success 200 {object} MessageResponse +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Security BearerAuth +// @Router /forms/{id} [delete] func (h *FormHandler) Delete(c *gin.Context) { userID := c.GetString("user_id") formID := c.Param("id") @@ -295,16 +397,47 @@ func (h *FormHandler) Delete(c *gin.Context) { return } - database.DB.Where("form_id = ?", formID).Delete(&models.Submission{}) + // Use transaction to ensure atomicity + tx := database.DB.Begin() + if tx.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start transaction"}) + return + } - if result := database.DB.Delete(&form); result.Error != nil { + // Delete all submissions first + if result := tx.Where("form_id = ?", formID).Delete(&models.Submission{}); result.Error != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete submissions"}) + return + } + + // Then delete the form + if result := tx.Delete(&form); result.Error != nil { + tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete form"}) return } + // Commit the transaction + if err := tx.Commit().Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Form deleted successfully"}) } +// Duplicate godoc +// @Summary Duplicate form +// @Description Create a copy of an existing form +// @Tags Forms +// @Produce json +// @Param id path string true "Form ID" +// @Success 201 {object} models.Form +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Security BearerAuth +// @Router /forms/{id}/duplicate [post] func (h *FormHandler) Duplicate(c *gin.Context) { userID := c.GetString("user_id") formID := c.Param("id") diff --git a/backend/internal/handlers/form_test.go b/backend/internal/handlers/form_test.go new file mode 100644 index 0000000..4686579 --- /dev/null +++ b/backend/internal/handlers/form_test.go @@ -0,0 +1,442 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "formera/internal/models" + "formera/internal/pagination" + "formera/internal/testutil" + + "github.com/gin-gonic/gin" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +func TestFormHandler_Create(t *testing.T) { + testutil.SetupTestDB(t) + + handler := NewFormHandler() + router := gin.New() + router.POST("/forms", func(c *gin.Context) { + c.Set("user_id", "test-user-id") + handler.Create(c) + }) + + body := CreateFormRequest{ + Title: "Test Form", + Description: "A test form description", + Fields: models.FormFields{}, + Settings: models.FormSettings{}, + } + jsonBody, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/forms", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected status %d, got %d: %s", http.StatusCreated, w.Code, w.Body.String()) + } + + var response models.Form + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if response.Title != "Test Form" { + t.Errorf("expected title 'Test Form', got %s", response.Title) + } + if response.Status != models.FormStatusDraft { + t.Errorf("expected status 'draft', got %s", response.Status) + } +} + +func TestFormHandler_Create_SanitizesXSS(t *testing.T) { + testutil.SetupTestDB(t) + + handler := NewFormHandler() + router := gin.New() + router.POST("/forms", func(c *gin.Context) { + c.Set("user_id", "test-user-id") + handler.Create(c) + }) + + body := CreateFormRequest{ + Title: "Test Form", + Description: "

Valid HTML

", + Fields: models.FormFields{}, + Settings: models.FormSettings{}, + } + jsonBody, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/forms", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected status %d, got %d: %s", http.StatusCreated, w.Code, w.Body.String()) + } + + var response models.Form + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + // Title should have script tags stripped + if response.Title == "Test Form" { + t.Error("XSS script tag was not stripped from title") + } + // Description should have script stripped but valid HTML kept + if response.Description == "

Valid HTML

" { + t.Error("XSS script tag was not stripped from description") + } +} + +func TestFormHandler_List(t *testing.T) { + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "test@example.com", "password123", models.RoleUser) + + // Create test forms + form1 := &models.Form{UserID: user.ID, Title: "Form 1", Status: models.FormStatusDraft} + form2 := &models.Form{UserID: user.ID, Title: "Form 2", Status: models.FormStatusPublished} + db.Create(form1) + db.Create(form2) + + handler := NewFormHandler() + router := gin.New() + router.GET("/forms", func(c *gin.Context) { + c.Set("user_id", user.ID) + handler.List(c) + }) + + req := httptest.NewRequest(http.MethodGet, "/forms", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } + + var response pagination.Result + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if response.TotalItems != 2 { + t.Errorf("expected 2 forms, got %d", response.TotalItems) + } +} + +func TestFormHandler_List_Pagination(t *testing.T) { + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "test@example.com", "password123", models.RoleUser) + + // Create 25 test forms + for i := 0; i < 25; i++ { + form := &models.Form{UserID: user.ID, Title: "Form", Status: models.FormStatusDraft} + db.Create(form) + } + + handler := NewFormHandler() + router := gin.New() + router.GET("/forms", func(c *gin.Context) { + c.Set("user_id", user.ID) + handler.List(c) + }) + + // Test first page + req := httptest.NewRequest(http.MethodGet, "/forms?page=1&page_size=10", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var response pagination.Result + json.Unmarshal(w.Body.Bytes(), &response) + + if response.TotalItems != 25 { + t.Errorf("expected total 25 forms, got %d", response.TotalItems) + } + if response.TotalPages != 3 { + t.Errorf("expected 3 pages, got %d", response.TotalPages) + } + if response.Page != 1 { + t.Errorf("expected page 1, got %d", response.Page) + } +} + +func TestFormHandler_Get(t *testing.T) { + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "test@example.com", "password123", models.RoleUser) + + form := &models.Form{UserID: user.ID, Title: "Test Form", Status: models.FormStatusDraft} + db.Create(form) + + handler := NewFormHandler() + router := gin.New() + router.GET("/forms/:id", func(c *gin.Context) { + c.Set("user_id", user.ID) + handler.Get(c) + }) + + req := httptest.NewRequest(http.MethodGet, "/forms/"+form.ID, nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } + + var response models.Form + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if response.Title != "Test Form" { + t.Errorf("expected title 'Test Form', got %s", response.Title) + } +} + +func TestFormHandler_Get_NotFound(t *testing.T) { + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "test@example.com", "password123", models.RoleUser) + + handler := NewFormHandler() + router := gin.New() + router.GET("/forms/:id", func(c *gin.Context) { + c.Set("user_id", user.ID) + handler.Get(c) + }) + + req := httptest.NewRequest(http.MethodGet, "/forms/non-existent-id", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code) + } +} + +func TestFormHandler_Get_WrongUser(t *testing.T) { + db := testutil.SetupTestDB(t) + owner := testutil.CreateTestUser(t, db, "owner@example.com", "password123", models.RoleUser) + otherUser := testutil.CreateTestUser(t, db, "other@example.com", "password123", models.RoleUser) + + form := &models.Form{UserID: owner.ID, Title: "Test Form", Status: models.FormStatusDraft} + db.Create(form) + + handler := NewFormHandler() + router := gin.New() + router.GET("/forms/:id", func(c *gin.Context) { + c.Set("user_id", otherUser.ID) // Different user trying to access + handler.Get(c) + }) + + req := httptest.NewRequest(http.MethodGet, "/forms/"+form.ID, nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code) + } +} + +func TestFormHandler_Update(t *testing.T) { + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "test@example.com", "password123", models.RoleUser) + + form := &models.Form{UserID: user.ID, Title: "Original Title", Status: models.FormStatusDraft} + db.Create(form) + + handler := NewFormHandler() + router := gin.New() + router.PUT("/forms/:id", func(c *gin.Context) { + c.Set("user_id", user.ID) + handler.Update(c) + }) + + body := UpdateFormRequest{ + Title: "Updated Title", + Status: models.FormStatusPublished, + } + jsonBody, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPut, "/forms/"+form.ID, bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String()) + } + + var response models.Form + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if response.Title != "Updated Title" { + t.Errorf("expected title 'Updated Title', got %s", response.Title) + } + if response.Status != models.FormStatusPublished { + t.Errorf("expected status 'published', got %s", response.Status) + } +} + +func TestFormHandler_Delete(t *testing.T) { + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "test@example.com", "password123", models.RoleUser) + + form := &models.Form{UserID: user.ID, Title: "Test Form", Status: models.FormStatusDraft} + db.Create(form) + + // Create some submissions for this form + submission := &models.Submission{FormID: form.ID, Data: map[string]interface{}{"field1": "value1"}} + db.Create(submission) + + handler := NewFormHandler() + router := gin.New() + router.DELETE("/forms/:id", func(c *gin.Context) { + c.Set("user_id", user.ID) + handler.Delete(c) + }) + + req := httptest.NewRequest(http.MethodDelete, "/forms/"+form.ID, nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String()) + } + + // Verify form is deleted + var deletedForm models.Form + result := db.First(&deletedForm, "id = ?", form.ID) + if result.Error == nil { + t.Error("form should have been deleted") + } + + // Verify submissions are deleted (transaction test) + var deletedSubmission models.Submission + result = db.First(&deletedSubmission, "form_id = ?", form.ID) + if result.Error == nil { + t.Error("submissions should have been deleted with form") + } +} + +func TestFormHandler_Duplicate(t *testing.T) { + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "test@example.com", "password123", models.RoleUser) + + form := &models.Form{ + UserID: user.ID, + Title: "Original Form", + Description: "Original description", + Status: models.FormStatusPublished, + } + db.Create(form) + + handler := NewFormHandler() + router := gin.New() + router.POST("/forms/:id/duplicate", func(c *gin.Context) { + c.Set("user_id", user.ID) + handler.Duplicate(c) + }) + + req := httptest.NewRequest(http.MethodPost, "/forms/"+form.ID+"/duplicate", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected status %d, got %d: %s", http.StatusCreated, w.Code, w.Body.String()) + } + + var response models.Form + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if response.Title != "Original Form (Kopie)" { + t.Errorf("expected title 'Original Form (Kopie)', got %s", response.Title) + } + if response.Status != models.FormStatusDraft { + t.Errorf("expected status 'draft', got %s", response.Status) + } + if response.ID == form.ID { + t.Error("duplicated form should have a new ID") + } +} + +func TestFormHandler_GetPublic(t *testing.T) { + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "test@example.com", "password123", models.RoleUser) + + form := &models.Form{ + UserID: user.ID, + Title: "Public Form", + Slug: "public-form", + Status: models.FormStatusPublished, + } + db.Create(form) + + handler := NewFormHandler() + router := gin.New() + router.GET("/public/forms/:id", handler.GetPublic) + + // Test by ID + req := httptest.NewRequest(http.MethodGet, "/public/forms/"+form.ID, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } + + // Test by slug + req = httptest.NewRequest(http.MethodGet, "/public/forms/public-form", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d for slug lookup, got %d", http.StatusOK, w.Code) + } +} + +func TestFormHandler_GetPublic_Draft(t *testing.T) { + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "test@example.com", "password123", models.RoleUser) + + form := &models.Form{ + UserID: user.ID, + Title: "Draft Form", + Status: models.FormStatusDraft, // Not published + } + db.Create(form) + + handler := NewFormHandler() + router := gin.New() + router.GET("/public/forms/:id", handler.GetPublic) + + req := httptest.NewRequest(http.MethodGet, "/public/forms/"+form.ID, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status %d for draft form, got %d", http.StatusNotFound, w.Code) + } +} diff --git a/backend/internal/handlers/setup.go b/backend/internal/handlers/setup.go index b8d6956..d6d0b7e 100644 --- a/backend/internal/handlers/setup.go +++ b/backend/internal/handlers/setup.go @@ -39,6 +39,13 @@ type SetupRequest struct { AllowRegistration bool `json:"allow_registration"` } +// GetStatus godoc +// @Summary Get setup status +// @Description Get application setup status and public settings +// @Tags Setup +// @Produce json +// @Success 200 {object} SetupStatusResponse +// @Router /setup/status [get] func (h *SetupHandler) GetStatus(c *gin.Context) { var settings models.Settings database.DB.First(&settings) @@ -63,6 +70,16 @@ func (h *SetupHandler) GetStatus(c *gin.Context) { }) } +// CompleteSetup godoc +// @Summary Complete initial setup +// @Description Create the first admin user and complete setup +// @Tags Setup +// @Accept json +// @Produce json +// @Param request body SetupRequest true "Setup data" +// @Success 200 {object} AuthResponse +// @Failure 400 {object} ErrorResponse "Setup already completed or invalid data" +// @Router /setup/complete [post] func (h *SetupHandler) CompleteSetup(c *gin.Context) { var userCount int64 database.DB.Model(&models.User{}).Count(&userCount) @@ -118,6 +135,16 @@ func (h *SetupHandler) CompleteSetup(c *gin.Context) { }) } +// GetSettings godoc +// @Summary Get settings +// @Description Get application settings (admin only) +// @Tags Settings +// @Produce json +// @Success 200 {object} models.Settings +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse "Admin access required" +// @Security BearerAuth +// @Router /settings [get] func (h *SetupHandler) GetSettings(c *gin.Context) { var settings models.Settings database.DB.First(&settings) @@ -138,6 +165,19 @@ type UpdateSettingsRequest struct { Theme string `json:"theme"` } +// UpdateSettings godoc +// @Summary Update settings +// @Description Update application settings (admin only) +// @Tags Settings +// @Accept json +// @Produce json +// @Param request body UpdateSettingsRequest true "Settings data" +// @Success 200 {object} models.Settings +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse "Admin access required" +// @Security BearerAuth +// @Router /settings [put] func (h *SetupHandler) UpdateSettings(c *gin.Context) { var req UpdateSettingsRequest if err := c.ShouldBindJSON(&req); err != nil { diff --git a/backend/internal/handlers/submission.go b/backend/internal/handlers/submission.go index 54b03c4..81fdf32 100644 --- a/backend/internal/handlers/submission.go +++ b/backend/internal/handlers/submission.go @@ -9,6 +9,8 @@ import ( "formera/internal/database" "formera/internal/models" + "formera/internal/pagination" + "formera/internal/sanitizer" "github.com/gin-gonic/gin" ) @@ -24,6 +26,20 @@ type SubmitRequest struct { Metadata map[string]string `json:"metadata,omitempty"` } +// Submit godoc +// @Summary Submit form +// @Description Submit a response to a published form +// @Tags Public +// @Accept json +// @Produce json +// @Param id path string true "Form ID" +// @Param request body SubmitRequest true "Submission data" +// @Success 201 {object} models.Submission +// @Failure 400 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse "Form closed or max submissions reached" +// @Failure 404 {object} ErrorResponse +// @Failure 429 {object} ErrorResponse "Rate limit exceeded" +// @Router /public/forms/{id}/submit [post] func (h *SubmissionHandler) Submit(c *gin.Context) { formID := c.Param("id") @@ -116,9 +132,12 @@ func (h *SubmissionHandler) Submit(c *gin.Context) { } } + // Sanitize submission data to prevent XSS + sanitizedData := sanitizer.SanitizeSubmissionData(req.Data) + submission := &models.Submission{ FormID: formID, - Data: req.Data, + Data: sanitizedData, Metadata: metadata, } @@ -133,9 +152,23 @@ func (h *SubmissionHandler) Submit(c *gin.Context) { }) } +// List godoc +// @Summary List submissions +// @Description Get paginated list of form submissions +// @Tags Submissions +// @Produce json +// @Param id path string true "Form ID" +// @Param page query int false "Page number" default(1) +// @Param page_size query int false "Items per page" default(20) +// @Success 200 {object} SubmissionListResponse +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Security BearerAuth +// @Router /forms/{id}/submissions [get] func (h *SubmissionHandler) List(c *gin.Context) { userID := c.GetString("user_id") formID := c.Param("id") + params := pagination.GetParams(c) var form models.Form if result := database.DB.Where("id = ? AND user_id = ?", formID, userID).First(&form); result.Error != nil { @@ -143,19 +176,36 @@ func (h *SubmissionHandler) List(c *gin.Context) { return } + var totalItems int64 + database.DB.Model(&models.Submission{}).Where("form_id = ?", formID).Count(&totalItems) + var submissions []models.Submission - if result := database.DB.Where("form_id = ?", formID).Order("created_at DESC").Find(&submissions); result.Error != nil { + if result := database.DB.Where("form_id = ?", formID). + Order("created_at DESC"). + Scopes(pagination.Paginate(params)). + Find(&submissions); result.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch submissions"}) return } c.JSON(http.StatusOK, gin.H{ "form": form, - "submissions": submissions, - "count": len(submissions), + "submissions": pagination.CreateResult(submissions, params, totalItems), }) } +// Get godoc +// @Summary Get submission +// @Description Get a specific submission by ID +// @Tags Submissions +// @Produce json +// @Param id path string true "Form ID" +// @Param submissionId path string true "Submission ID" +// @Success 200 {object} models.Submission +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Security BearerAuth +// @Router /forms/{id}/submissions/{submissionId} [get] func (h *SubmissionHandler) Get(c *gin.Context) { userID := c.GetString("user_id") formID := c.Param("id") @@ -176,6 +226,18 @@ func (h *SubmissionHandler) Get(c *gin.Context) { c.JSON(http.StatusOK, submission) } +// Delete godoc +// @Summary Delete submission +// @Description Delete a specific submission +// @Tags Submissions +// @Produce json +// @Param id path string true "Form ID" +// @Param submissionId path string true "Submission ID" +// @Success 200 {object} MessageResponse +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Security BearerAuth +// @Router /forms/{id}/submissions/{submissionId} [delete] func (h *SubmissionHandler) Delete(c *gin.Context) { userID := c.GetString("user_id") formID := c.Param("id") @@ -195,6 +257,17 @@ func (h *SubmissionHandler) Delete(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Submission deleted successfully"}) } +// Stats godoc +// @Summary Get form statistics +// @Description Get submission statistics for a form +// @Tags Submissions +// @Produce json +// @Param id path string true "Form ID" +// @Success 200 {object} FormStatsResponse +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Security BearerAuth +// @Router /forms/{id}/stats [get] func (h *SubmissionHandler) Stats(c *gin.Context) { userID := c.GetString("user_id") formID := c.Param("id") @@ -236,6 +309,17 @@ func (h *SubmissionHandler) Stats(c *gin.Context) { }) } +// ExportCSV godoc +// @Summary Export submissions as CSV +// @Description Download all submissions as a CSV file +// @Tags Submissions +// @Produce text/csv +// @Param id path string true "Form ID" +// @Success 200 {file} file "CSV file" +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Security BearerAuth +// @Router /forms/{id}/export/csv [get] func (h *SubmissionHandler) ExportCSV(c *gin.Context) { userID := c.GetString("user_id") formID := c.Param("id") @@ -285,6 +369,17 @@ func (h *SubmissionHandler) ExportCSV(c *gin.Context) { } } +// ExportJSON godoc +// @Summary Export submissions as JSON +// @Description Download all submissions as a JSON file +// @Tags Submissions +// @Produce json +// @Param id path string true "Form ID" +// @Success 200 {array} map[string]interface{} "JSON array of submissions" +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Security BearerAuth +// @Router /forms/{id}/export/json [get] func (h *SubmissionHandler) ExportJSON(c *gin.Context) { userID := c.GetString("user_id") formID := c.Param("id") @@ -318,6 +413,17 @@ func (h *SubmissionHandler) ExportJSON(c *gin.Context) { c.JSON(http.StatusOK, exportData) } +// SubmissionsByDate godoc +// @Summary Get submissions by date +// @Description Get submission counts grouped by date +// @Tags Submissions +// @Produce json +// @Param id path string true "Form ID" +// @Success 200 {array} SubmissionsByDateResponse +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Security BearerAuth +// @Router /forms/{id}/submissions/by-date [get] func (h *SubmissionHandler) SubmissionsByDate(c *gin.Context) { userID := c.GetString("user_id") formID := c.Param("id") diff --git a/backend/internal/handlers/submission_test.go b/backend/internal/handlers/submission_test.go new file mode 100644 index 0000000..2951bd7 --- /dev/null +++ b/backend/internal/handlers/submission_test.go @@ -0,0 +1,342 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "formera/internal/models" + "formera/internal/testutil" + + "github.com/gin-gonic/gin" +) + +func TestSubmissionHandler_Submit(t *testing.T) { + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "test@example.com", "password123", models.RoleUser) + + form := &models.Form{ + UserID: user.ID, + Title: "Test Form", + Status: models.FormStatusPublished, + Fields: models.FormFields{ + {ID: "field1", Label: "Field 1", Type: "text", Required: true}, + }, + Settings: models.FormSettings{ + SuccessMessage: "Thank you!", + }, + } + db.Create(form) + + handler := NewSubmissionHandler() + router := gin.New() + router.POST("/public/forms/:id/submit", handler.Submit) + + body := SubmitRequest{ + Data: map[string]interface{}{ + "field1": "test value", + }, + } + jsonBody, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/public/forms/"+form.ID+"/submit", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected status %d, got %d: %s", http.StatusCreated, w.Code, w.Body.String()) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if response["message"] != "Thank you!" { + t.Errorf("expected success message 'Thank you!', got %v", response["message"]) + } +} + +func TestSubmissionHandler_Submit_RequiredFieldMissing(t *testing.T) { + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "test@example.com", "password123", models.RoleUser) + + form := &models.Form{ + UserID: user.ID, + Title: "Test Form", + Status: models.FormStatusPublished, + Fields: models.FormFields{ + {ID: "field1", Label: "Field 1", Type: "text", Required: true}, + }, + } + db.Create(form) + + handler := NewSubmissionHandler() + router := gin.New() + router.POST("/public/forms/:id/submit", handler.Submit) + + body := SubmitRequest{ + Data: map[string]interface{}{}, // Missing required field + } + jsonBody, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/public/forms/"+form.ID+"/submit", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, w.Code) + } +} + +func TestSubmissionHandler_Submit_FormNotPublished(t *testing.T) { + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "test@example.com", "password123", models.RoleUser) + + form := &models.Form{ + UserID: user.ID, + Title: "Test Form", + Status: models.FormStatusDraft, // Not published + } + db.Create(form) + + handler := NewSubmissionHandler() + router := gin.New() + router.POST("/public/forms/:id/submit", handler.Submit) + + body := SubmitRequest{ + Data: map[string]interface{}{"field1": "value"}, + } + jsonBody, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/public/forms/"+form.ID+"/submit", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code) + } +} + +func TestSubmissionHandler_Submit_MaxSubmissionsReached(t *testing.T) { + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "test@example.com", "password123", models.RoleUser) + + form := &models.Form{ + UserID: user.ID, + Title: "Test Form", + Status: models.FormStatusPublished, + Settings: models.FormSettings{ + MaxSubmissions: 1, + }, + } + db.Create(form) + + // Create existing submission + db.Create(&models.Submission{FormID: form.ID, Data: map[string]interface{}{}}) + + handler := NewSubmissionHandler() + router := gin.New() + router.POST("/public/forms/:id/submit", handler.Submit) + + body := SubmitRequest{ + Data: map[string]interface{}{"field1": "value"}, + } + jsonBody, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/public/forms/"+form.ID+"/submit", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("expected status %d, got %d", http.StatusForbidden, w.Code) + } +} + +func TestSubmissionHandler_Submit_SanitizesXSS(t *testing.T) { + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "test@example.com", "password123", models.RoleUser) + + form := &models.Form{ + UserID: user.ID, + Title: "Test Form", + Status: models.FormStatusPublished, + Fields: models.FormFields{ + {ID: "field1", Label: "Field 1", Type: "text", Required: true}, + }, + } + db.Create(form) + + handler := NewSubmissionHandler() + router := gin.New() + router.POST("/public/forms/:id/submit", handler.Submit) + + body := SubmitRequest{ + Data: map[string]interface{}{ + "field1": "Hello", + }, + } + jsonBody, _ := json.Marshal(body) + + req := httptest.NewRequest(http.MethodPost, "/public/forms/"+form.ID+"/submit", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("expected status %d, got %d: %s", http.StatusCreated, w.Code, w.Body.String()) + } + + // Check the stored submission + var submission models.Submission + db.First(&submission, "form_id = ?", form.ID) + + if val, ok := submission.Data["field1"].(string); ok { + if val == "Hello" { + t.Error("XSS script tag was not stripped from submission data") + } + } +} + +func TestSubmissionHandler_List(t *testing.T) { + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "test@example.com", "password123", models.RoleUser) + + form := &models.Form{UserID: user.ID, Title: "Test Form", Status: models.FormStatusPublished} + db.Create(form) + + // Create submissions + db.Create(&models.Submission{FormID: form.ID, Data: map[string]interface{}{"field1": "value1"}}) + db.Create(&models.Submission{FormID: form.ID, Data: map[string]interface{}{"field1": "value2"}}) + + handler := NewSubmissionHandler() + router := gin.New() + router.GET("/forms/:id/submissions", func(c *gin.Context) { + c.Set("user_id", user.ID) + handler.List(c) + }) + + req := httptest.NewRequest(http.MethodGet, "/forms/"+form.ID+"/submissions", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } +} + +func TestSubmissionHandler_List_WrongUser(t *testing.T) { + db := testutil.SetupTestDB(t) + owner := testutil.CreateTestUser(t, db, "owner@example.com", "password123", models.RoleUser) + otherUser := testutil.CreateTestUser(t, db, "other@example.com", "password123", models.RoleUser) + + form := &models.Form{UserID: owner.ID, Title: "Test Form", Status: models.FormStatusPublished} + db.Create(form) + + handler := NewSubmissionHandler() + router := gin.New() + router.GET("/forms/:id/submissions", func(c *gin.Context) { + c.Set("user_id", otherUser.ID) // Different user + handler.List(c) + }) + + req := httptest.NewRequest(http.MethodGet, "/forms/"+form.ID+"/submissions", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status %d, got %d", http.StatusNotFound, w.Code) + } +} + +func TestSubmissionHandler_Delete(t *testing.T) { + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "test@example.com", "password123", models.RoleUser) + + form := &models.Form{UserID: user.ID, Title: "Test Form", Status: models.FormStatusPublished} + db.Create(form) + + submission := &models.Submission{FormID: form.ID, Data: map[string]interface{}{"field1": "value1"}} + db.Create(submission) + + handler := NewSubmissionHandler() + router := gin.New() + router.DELETE("/forms/:id/submissions/:submissionId", func(c *gin.Context) { + c.Set("user_id", user.ID) + handler.Delete(c) + }) + + req := httptest.NewRequest(http.MethodDelete, "/forms/"+form.ID+"/submissions/"+submission.ID, nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String()) + } + + // Verify submission is deleted + var deletedSubmission models.Submission + result := db.First(&deletedSubmission, "id = ?", submission.ID) + if result.Error == nil { + t.Error("submission should have been deleted") + } +} + +func TestSubmissionHandler_Stats(t *testing.T) { + db := testutil.SetupTestDB(t) + user := testutil.CreateTestUser(t, db, "test@example.com", "password123", models.RoleUser) + + form := &models.Form{ + UserID: user.ID, + Title: "Test Form", + Status: models.FormStatusPublished, + Fields: models.FormFields{ + {ID: "rating", Label: "Rating", Type: "select"}, + }, + } + db.Create(form) + + // Create submissions with different values + db.Create(&models.Submission{FormID: form.ID, Data: map[string]interface{}{"rating": "good"}}) + db.Create(&models.Submission{FormID: form.ID, Data: map[string]interface{}{"rating": "good"}}) + db.Create(&models.Submission{FormID: form.ID, Data: map[string]interface{}{"rating": "bad"}}) + + handler := NewSubmissionHandler() + router := gin.New() + router.GET("/forms/:id/stats", func(c *gin.Context) { + c.Set("user_id", user.ID) + handler.Stats(c) + }) + + req := httptest.NewRequest(http.MethodGet, "/forms/"+form.ID+"/stats", nil) + w := httptest.NewRecorder() + + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if response["total_submissions"].(float64) != 3 { + t.Errorf("expected 3 total submissions, got %v", response["total_submissions"]) + } +} diff --git a/backend/internal/handlers/types.go b/backend/internal/handlers/types.go new file mode 100644 index 0000000..3691e24 --- /dev/null +++ b/backend/internal/handlers/types.go @@ -0,0 +1,39 @@ +package handlers + +// Swagger API Types - used for documentation only +// Types that are already defined in other files are not duplicated here + +// ErrorResponse represents an error response +type ErrorResponse struct { + Error string `json:"error" example:"Invalid request"` +} + +// MessageResponse represents a simple message response +type MessageResponse struct { + Message string `json:"message" example:"Operation successful"` +} + +// SlugCheckResponse represents slug availability check +type SlugCheckResponse struct { + Available bool `json:"available" example:"true"` + Slug string `json:"slug" example:"contact-form"` + Reason string `json:"reason,omitempty" example:"taken"` +} + +// SubmissionListResponse represents paginated submissions +type SubmissionListResponse struct { + Form interface{} `json:"form"` + Submissions interface{} `json:"submissions"` +} + +// FormStatsResponse represents form statistics +type FormStatsResponse struct { + TotalSubmissions int `json:"total_submissions" example:"150"` + FieldStats map[string]interface{} `json:"field_stats"` +} + +// SubmissionsByDateResponse represents submissions grouped by date +type SubmissionsByDateResponse struct { + Date string `json:"date" example:"2025-01-15"` + Count int `json:"count" example:"25"` +} diff --git a/backend/internal/handlers/upload.go b/backend/internal/handlers/upload.go index bbca5ca..502915c 100644 --- a/backend/internal/handlers/upload.go +++ b/backend/internal/handlers/upload.go @@ -71,7 +71,19 @@ func NewUploadHandler(store storage.Storage) *UploadHandler { } } -// UploadImage handles image uploads for form design backgrounds +// UploadImage godoc +// @Summary Upload image +// @Description Upload an image file (for form design backgrounds) +// @Tags Uploads +// @Accept multipart/form-data +// @Produce json +// @Param file formData file true "Image file" +// @Success 200 {object} storage.UploadResult +// @Failure 400 {object} ErrorResponse "Invalid file" +// @Failure 401 {object} ErrorResponse +// @Failure 429 {object} ErrorResponse "Rate limit exceeded" +// @Security BearerAuth +// @Router /uploads/image [post] func (h *UploadHandler) UploadImage(c *gin.Context) { // Get authenticated user userID := c.GetString("user_id") @@ -154,7 +166,17 @@ func (h *UploadHandler) UploadImage(c *gin.Context) { c.JSON(http.StatusOK, result) } -// UploadFile handles general file uploads (for form submissions) +// UploadFile godoc +// @Summary Upload file +// @Description Upload a file (for form submissions) +// @Tags Uploads +// @Accept multipart/form-data +// @Produce json +// @Param file formData file true "File to upload" +// @Success 200 {object} storage.UploadResult +// @Failure 400 {object} ErrorResponse "Invalid file" +// @Failure 429 {object} ErrorResponse "Rate limit exceeded" +// @Router /public/upload [post] func (h *UploadHandler) UploadFile(c *gin.Context) { // Get authenticated user (or allow anonymous for public form submissions) userID := c.GetString("user_id") @@ -230,7 +252,16 @@ func (h *UploadHandler) UploadFile(c *gin.Context) { c.JSON(http.StatusOK, result) } -// GetFile serves a file by redirecting to the appropriate URL (local or S3 presigned) +// GetFile godoc +// @Summary Get file +// @Description Serve a file by path (streams from storage) +// @Tags Files +// @Produce octet-stream +// @Param path path string true "File path" +// @Success 200 {file} file "File content" +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Router /files/{path} [get] func (h *UploadHandler) GetFile(c *gin.Context) { // Get the file path from URL parameter (e.g., "images/2025/12/abc123.png") filePath := c.Param("path") @@ -251,8 +282,8 @@ func (h *UploadHandler) GetFile(c *gin.Context) { return } - // Get URL for the file (generates presigned URL for S3, returns local URL for local storage) - url, err := h.storage.GetURLByPath(filePath) + // Get file content for streaming + fileContent, err := h.storage.GetFileByPath(filePath) if err != nil { if err == storage.ErrFileNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "File not found"}) @@ -261,13 +292,27 @@ func (h *UploadHandler) GetFile(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get file"}) return } + defer fileContent.Reader.Close() - // For S3, redirect to presigned URL - // For local storage, the URL points to our static file server - c.Redirect(http.StatusTemporaryRedirect, url) + // Set headers for caching (1 year for immutable content-addressed files) + c.Header("Cache-Control", "public, max-age=31536000, immutable") + + // Use Gin's DataFromReader for efficient streaming + c.DataFromReader(http.StatusOK, fileContent.Size, fileContent.ContentType, fileContent.Reader, nil) } -// DeleteFile handles file deletion +// DeleteFile godoc +// @Summary Delete file +// @Description Delete an uploaded file +// @Tags Uploads +// @Produce json +// @Param id path string true "File ID" +// @Success 200 {object} MessageResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Security BearerAuth +// @Router /uploads/{id} [delete] func (h *UploadHandler) DeleteFile(c *gin.Context) { // Only authenticated users can delete userID := c.GetString("user_id") diff --git a/backend/internal/handlers/user.go b/backend/internal/handlers/user.go index 6075f00..5f5eeb7 100644 --- a/backend/internal/handlers/user.go +++ b/backend/internal/handlers/user.go @@ -5,6 +5,7 @@ import ( "formera/internal/database" "formera/internal/models" + "formera/internal/pagination" "github.com/gin-gonic/gin" ) @@ -30,13 +31,20 @@ type UpdateUserRequest struct { } func (h *UserHandler) List(c *gin.Context) { + params := pagination.GetParams(c) + + var totalItems int64 + database.DB.Model(&models.User{}).Count(&totalItems) + var users []models.User - if result := database.DB.Find(&users); result.Error != nil { + if result := database.DB.Order("created_at DESC"). + Scopes(pagination.Paginate(params)). + Find(&users); result.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch users"}) return } - c.JSON(http.StatusOK, users) + c.JSON(http.StatusOK, pagination.CreateResult(users, params, totalItems)) } func (h *UserHandler) Get(c *gin.Context) { @@ -174,10 +182,44 @@ func (h *UserHandler) Delete(c *gin.Context) { } } - if result := database.DB.Delete(&user); result.Error != nil { + // Use transaction to delete user and all their data + tx := database.DB.Begin() + if tx.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to start transaction"}) + return + } + + // Get all forms by this user + var formIDs []string + tx.Model(&models.Form{}).Where("user_id = ?", id).Pluck("id", &formIDs) + + // Delete all submissions for user's forms + if len(formIDs) > 0 { + if result := tx.Where("form_id IN ?", formIDs).Delete(&models.Submission{}); result.Error != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete submissions"}) + return + } + } + + // Delete all forms by this user + if result := tx.Where("user_id = ?", id).Delete(&models.Form{}); result.Error != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete forms"}) + return + } + + // Delete the user + if result := tx.Delete(&user); result.Error != nil { + tx.Rollback() c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete user"}) return } + if err := tx.Commit().Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit transaction"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"}) } diff --git a/backend/internal/handlers/user_test.go b/backend/internal/handlers/user_test.go index 9d0dc6f..8d19bfb 100644 --- a/backend/internal/handlers/user_test.go +++ b/backend/internal/handlers/user_test.go @@ -8,6 +8,7 @@ import ( "testing" "formera/internal/models" + "formera/internal/pagination" "formera/internal/testutil" "github.com/gin-gonic/gin" @@ -31,13 +32,13 @@ func TestUserHandler_List(t *testing.T) { t.Errorf("expected status %d, got %d", http.StatusOK, w.Code) } - var users []models.User - if err := json.Unmarshal(w.Body.Bytes(), &users); err != nil { + var response pagination.Result + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } - if len(users) != 2 { - t.Errorf("expected 2 users, got %d", len(users)) + if response.TotalItems != 2 { + t.Errorf("expected 2 users, got %d", response.TotalItems) } } diff --git a/backend/internal/logger/logger.go b/backend/internal/logger/logger.go new file mode 100644 index 0000000..349348c --- /dev/null +++ b/backend/internal/logger/logger.go @@ -0,0 +1,136 @@ +package logger + +import ( + "io" + "os" + "time" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog" +) + +var Log zerolog.Logger + +// Config holds logger configuration +type Config struct { + Level string // debug, info, warn, error + Pretty bool // Human-readable output (for development) + TimeFormat string // Time format +} + +// Initialize sets up the global logger +func Initialize(cfg Config) { + var output io.Writer = os.Stdout + + if cfg.Pretty { + output = zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: time.RFC3339, + } + } + + level := parseLevel(cfg.Level) + + Log = zerolog.New(output). + Level(level). + With(). + Timestamp(). + Caller(). + Logger() +} + +func parseLevel(level string) zerolog.Level { + switch level { + case "debug": + return zerolog.DebugLevel + case "info": + return zerolog.InfoLevel + case "warn": + return zerolog.WarnLevel + case "error": + return zerolog.ErrorLevel + default: + return zerolog.InfoLevel + } +} + +// GinLogger returns a gin middleware for HTTP request logging +func GinLogger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + query := c.Request.URL.RawQuery + + c.Next() + + latency := time.Since(start) + status := c.Writer.Status() + + event := Log.Info() + if status >= 400 && status < 500 { + event = Log.Warn() + } else if status >= 500 { + event = Log.Error() + } + + event. + Str("method", c.Request.Method). + Str("path", path). + Str("query", query). + Int("status", status). + Dur("latency", latency). + Str("ip", c.ClientIP()). + Str("user_agent", c.Request.UserAgent()). + Msg("HTTP request") + } +} + +// GinRecovery returns a gin middleware for panic recovery with logging +func GinRecovery() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if err := recover(); err != nil { + Log.Error(). + Interface("error", err). + Str("path", c.Request.URL.Path). + Str("method", c.Request.Method). + Msg("Panic recovered") + + c.AbortWithStatus(500) + } + }() + c.Next() + } +} + +// Helper functions for common logging patterns + +func Debug() *zerolog.Event { + return Log.Debug() +} + +func Info() *zerolog.Event { + return Log.Info() +} + +func Warn() *zerolog.Event { + return Log.Warn() +} + +func Error() *zerolog.Event { + return Log.Error() +} + +func Fatal() *zerolog.Event { + return Log.Fatal() +} + +// WithRequestID adds request context to logs +func WithRequestID(requestID string) zerolog.Logger { + return Log.With().Str("request_id", requestID).Logger() +} + +// WithUserID adds user context to logs +func WithUserID(userID string) zerolog.Logger { + return Log.With().Str("user_id", userID).Logger() +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index 12e7b2a..209533b 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -4,7 +4,6 @@ import ( "net/http" "strings" - "formera/internal/database" "formera/internal/models" "github.com/gin-gonic/gin" @@ -14,6 +13,7 @@ import ( type Claims struct { UserID string `json:"user_id"` Email string `json:"email"` + Role string `json:"role"` jwt.RegisteredClaims } @@ -48,34 +48,28 @@ func AuthMiddleware(jwtSecret string) gin.HandlerFunc { c.Set("user_id", claims.UserID) c.Set("email", claims.Email) + c.Set("user_role", claims.Role) c.Next() } } // AdminMiddleware checks if the authenticated user has admin role +// Uses the role from JWT claims instead of querying the database func AdminMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - userID := c.GetString("user_id") - if userID == "" { + role := c.GetString("user_role") + if role == "" { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) c.Abort() return } - var user models.User - if result := database.DB.First(&user, "id = ?", userID); result.Error != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"}) - c.Abort() - return - } - - if user.Role != models.RoleAdmin { + if role != string(models.RoleAdmin) { c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) c.Abort() return } - c.Set("user_role", string(user.Role)) c.Next() } } diff --git a/backend/internal/middleware/auth_test.go b/backend/internal/middleware/auth_test.go index 725082a..b1b7923 100644 --- a/backend/internal/middleware/auth_test.go +++ b/backend/internal/middleware/auth_test.go @@ -7,7 +7,6 @@ import ( "time" "formera/internal/models" - "formera/internal/testutil" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" @@ -17,7 +16,7 @@ func init() { gin.SetMode(gin.TestMode) } -func generateTestToken(secret, userID, email string, expired bool) string { +func generateTestToken(secret, userID, email, role string, expired bool) string { expiresAt := time.Now().Add(time.Hour) if expired { expiresAt = time.Now().Add(-time.Hour) @@ -26,6 +25,7 @@ func generateTestToken(secret, userID, email string, expired bool) string { claims := &Claims{ UserID: userID, Email: email, + Role: role, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(expiresAt), IssuedAt: jwt.NewNumericDate(time.Now()), @@ -39,7 +39,7 @@ func generateTestToken(secret, userID, email string, expired bool) string { func TestAuthMiddleware_ValidToken(t *testing.T) { secret := "test-secret" - token := generateTestToken(secret, "user-123", "test@example.com", false) + token := generateTestToken(secret, "user-123", "test@example.com", "user", false) router := gin.New() router.Use(AuthMiddleware(secret)) @@ -111,7 +111,7 @@ func TestAuthMiddleware_InvalidFormat(t *testing.T) { func TestAuthMiddleware_ExpiredToken(t *testing.T) { secret := "test-secret" - token := generateTestToken(secret, "user-123", "test@example.com", true) + token := generateTestToken(secret, "user-123", "test@example.com", "user", true) router := gin.New() router.Use(AuthMiddleware(secret)) @@ -131,7 +131,7 @@ func TestAuthMiddleware_ExpiredToken(t *testing.T) { } func TestAuthMiddleware_InvalidSecret(t *testing.T) { - token := generateTestToken("correct-secret", "user-123", "test@example.com", false) + token := generateTestToken("correct-secret", "user-123", "test@example.com", "user", false) router := gin.New() router.Use(AuthMiddleware("wrong-secret")) @@ -151,12 +151,10 @@ func TestAuthMiddleware_InvalidSecret(t *testing.T) { } func TestAdminMiddleware_AdminUser(t *testing.T) { - db := testutil.SetupTestDB(t) - admin := testutil.CreateTestUser(t, db, "admin@example.com", "password123", models.RoleAdmin) - router := gin.New() router.Use(func(c *gin.Context) { - c.Set("user_id", admin.ID) + c.Set("user_id", "admin-123") + c.Set("user_role", string(models.RoleAdmin)) c.Next() }) router.Use(AdminMiddleware()) @@ -175,12 +173,10 @@ func TestAdminMiddleware_AdminUser(t *testing.T) { } func TestAdminMiddleware_NonAdminUser(t *testing.T) { - db := testutil.SetupTestDB(t) - user := testutil.CreateTestUser(t, db, "user@example.com", "password123", models.RoleUser) - router := gin.New() router.Use(func(c *gin.Context) { - c.Set("user_id", user.ID) + c.Set("user_id", "user-123") + c.Set("user_role", string(models.RoleUser)) c.Next() }) router.Use(AdminMiddleware()) @@ -198,33 +194,9 @@ func TestAdminMiddleware_NonAdminUser(t *testing.T) { } } -func TestAdminMiddleware_NoUserID(t *testing.T) { - testutil.SetupTestDB(t) - +func TestAdminMiddleware_NoRole(t *testing.T) { router := gin.New() - router.Use(AdminMiddleware()) - router.GET("/admin", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{}) - }) - - req := httptest.NewRequest(http.MethodGet, "/admin", nil) - w := httptest.NewRecorder() - - router.ServeHTTP(w, req) - - if w.Code != http.StatusUnauthorized { - t.Errorf("expected status %d, got %d", http.StatusUnauthorized, w.Code) - } -} - -func TestAdminMiddleware_UserNotFound(t *testing.T) { - testutil.SetupTestDB(t) - - router := gin.New() - router.Use(func(c *gin.Context) { - c.Set("user_id", "non-existent-id") - c.Next() - }) + // No user_role set in context router.Use(AdminMiddleware()) router.GET("/admin", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{}) diff --git a/backend/internal/middleware/ratelimit.go b/backend/internal/middleware/ratelimit.go new file mode 100644 index 0000000..107d057 --- /dev/null +++ b/backend/internal/middleware/ratelimit.go @@ -0,0 +1,199 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// RateLimiter implements a token bucket rate limiter +type RateLimiter struct { + mu sync.RWMutex + clients map[string]*clientLimit + rate int // requests per window + window time.Duration // time window + cleanup time.Duration // cleanup interval for old entries +} + +type clientLimit struct { + count int + resetTime time.Time +} + +// NewRateLimiter creates a new rate limiter +// rate: maximum requests per window +// window: time window (e.g., 1 minute) +func NewRateLimiter(rate int, window time.Duration) *RateLimiter { + rl := &RateLimiter{ + clients: make(map[string]*clientLimit), + rate: rate, + window: window, + cleanup: 5 * time.Minute, + } + + // Start cleanup goroutine + go rl.cleanupLoop() + + return rl +} + +// Allow checks if a request from the given key should be allowed +func (rl *RateLimiter) Allow(key string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := time.Now() + + client, exists := rl.clients[key] + if !exists || now.After(client.resetTime) { + rl.clients[key] = &clientLimit{ + count: 1, + resetTime: now.Add(rl.window), + } + return true + } + + if client.count >= rl.rate { + return false + } + + client.count++ + return true +} + +// Remaining returns the number of remaining requests for a key +func (rl *RateLimiter) Remaining(key string) int { + rl.mu.RLock() + defer rl.mu.RUnlock() + + client, exists := rl.clients[key] + if !exists || time.Now().After(client.resetTime) { + return rl.rate + } + + remaining := rl.rate - client.count + if remaining < 0 { + return 0 + } + return remaining +} + +// ResetTime returns when the rate limit resets for a key +func (rl *RateLimiter) ResetTime(key string) time.Time { + rl.mu.RLock() + defer rl.mu.RUnlock() + + client, exists := rl.clients[key] + if !exists { + return time.Now().Add(rl.window) + } + return client.resetTime +} + +// cleanupLoop periodically removes expired entries +func (rl *RateLimiter) cleanupLoop() { + ticker := time.NewTicker(rl.cleanup) + for range ticker.C { + rl.mu.Lock() + now := time.Now() + for key, client := range rl.clients { + if now.After(client.resetTime) { + delete(rl.clients, key) + } + } + rl.mu.Unlock() + } +} + +// RateLimitConfig holds configuration for the rate limit middleware +type RateLimitConfig struct { + // Rate is the number of requests allowed per window + Rate int + // Window is the time window for rate limiting + Window time.Duration + // KeyFunc extracts the rate limit key from the request (default: IP address) + KeyFunc func(*gin.Context) string + // SkipFunc determines if rate limiting should be skipped for a request + SkipFunc func(*gin.Context) bool +} + +// DefaultKeyFunc returns the client IP as the rate limit key +func DefaultKeyFunc(c *gin.Context) string { + return c.ClientIP() +} + +// RateLimitMiddleware creates a Gin middleware for rate limiting +func RateLimitMiddleware(config RateLimitConfig) gin.HandlerFunc { + if config.Rate <= 0 { + config.Rate = 100 // default: 100 requests + } + if config.Window <= 0 { + config.Window = time.Minute // default: per minute + } + if config.KeyFunc == nil { + config.KeyFunc = DefaultKeyFunc + } + + limiter := NewRateLimiter(config.Rate, config.Window) + + return func(c *gin.Context) { + // Skip rate limiting if configured + if config.SkipFunc != nil && config.SkipFunc(c) { + c.Next() + return + } + + key := config.KeyFunc(c) + + if !limiter.Allow(key) { + remaining := limiter.Remaining(key) + resetTime := limiter.ResetTime(key) + + c.Header("X-RateLimit-Limit", string(rune(config.Rate))) + c.Header("X-RateLimit-Remaining", string(rune(remaining))) + c.Header("X-RateLimit-Reset", resetTime.Format(time.RFC3339)) + c.Header("Retry-After", resetTime.Sub(time.Now()).String()) + + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "Rate limit exceeded", + "retry_after": resetTime.Sub(time.Now()).Seconds(), + }) + return + } + + // Add rate limit headers to response + c.Header("X-RateLimit-Limit", string(rune(config.Rate))) + c.Header("X-RateLimit-Remaining", string(rune(limiter.Remaining(key)))) + + c.Next() + } +} + +// APIRateLimiter creates a rate limiter for general API endpoints +// Default: 100 requests per minute per IP +func APIRateLimiter() gin.HandlerFunc { + return RateLimitMiddleware(RateLimitConfig{ + Rate: 100, + Window: time.Minute, + }) +} + +// AuthRateLimiter creates a stricter rate limiter for auth endpoints +// Default: 10 requests per minute per IP (to prevent brute force) +func AuthRateLimiter() gin.HandlerFunc { + return RateLimitMiddleware(RateLimitConfig{ + Rate: 10, + Window: time.Minute, + }) +} + +// SubmissionRateLimiter creates a rate limiter for form submissions +// Default: 30 requests per minute per IP +func SubmissionRateLimiter() gin.HandlerFunc { + return RateLimitMiddleware(RateLimitConfig{ + Rate: 30, + Window: time.Minute, + }) +} diff --git a/backend/internal/middleware/security.go b/backend/internal/middleware/security.go new file mode 100644 index 0000000..a95105c --- /dev/null +++ b/backend/internal/middleware/security.go @@ -0,0 +1,35 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" +) + +// SecurityHeaders adds security-related HTTP headers to responses +func SecurityHeaders() gin.HandlerFunc { + return func(c *gin.Context) { + // Prevent MIME type sniffing + c.Header("X-Content-Type-Options", "nosniff") + + // Prevent clickjacking + c.Header("X-Frame-Options", "DENY") + + // Enable XSS filter in browsers + c.Header("X-XSS-Protection", "1; mode=block") + + // Control referrer information + c.Header("Referrer-Policy", "strict-origin-when-cross-origin") + + // Prevent caching of sensitive data + c.Header("Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate") + c.Header("Pragma", "no-cache") + c.Header("Expires", "0") + + // Content Security Policy - adjust as needed for your frontend + c.Header("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'") + + // Permissions Policy (formerly Feature-Policy) + c.Header("Permissions-Policy", "geolocation=(), microphone=(), camera=()") + + c.Next() + } +} diff --git a/backend/internal/pagination/pagination.go b/backend/internal/pagination/pagination.go new file mode 100644 index 0000000..04d221a --- /dev/null +++ b/backend/internal/pagination/pagination.go @@ -0,0 +1,78 @@ +package pagination + +import ( + "strconv" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +const ( + DefaultPage = 1 + DefaultPageSize = 20 + MaxPageSize = 100 +) + +// Params holds pagination parameters +type Params struct { + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// Result holds paginated results +type Result struct { + Data interface{} `json:"data"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalItems int64 `json:"total_items"` + TotalPages int `json:"total_pages"` +} + +// GetParams extracts pagination parameters from request +func GetParams(c *gin.Context) Params { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page < 1 { + page = DefaultPage + } + if pageSize < 1 { + pageSize = DefaultPageSize + } + if pageSize > MaxPageSize { + pageSize = MaxPageSize + } + + return Params{ + Page: page, + PageSize: pageSize, + } +} + +// Offset calculates the offset for database queries +func (p Params) Offset() int { + return (p.Page - 1) * p.PageSize +} + +// Paginate applies pagination to a GORM query +func Paginate(params Params) func(db *gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return db.Offset(params.Offset()).Limit(params.PageSize) + } +} + +// CreateResult creates a paginated result +func CreateResult(data interface{}, params Params, totalItems int64) Result { + totalPages := int(totalItems) / params.PageSize + if int(totalItems)%params.PageSize > 0 { + totalPages++ + } + + return Result{ + Data: data, + Page: params.Page, + PageSize: params.PageSize, + TotalItems: totalItems, + TotalPages: totalPages, + } +} diff --git a/backend/internal/sanitizer/sanitizer.go b/backend/internal/sanitizer/sanitizer.go new file mode 100644 index 0000000..d71c731 --- /dev/null +++ b/backend/internal/sanitizer/sanitizer.go @@ -0,0 +1,62 @@ +package sanitizer + +import ( + "github.com/microcosm-cc/bluemonday" +) + +var ( + // strictPolicy strips all HTML - use for text-only fields + strictPolicy *bluemonday.Policy + + // ugcPolicy allows safe HTML for user-generated content + ugcPolicy *bluemonday.Policy +) + +func init() { + // Strict policy: no HTML allowed at all + strictPolicy = bluemonday.StrictPolicy() + + // UGC policy: allows safe HTML tags + ugcPolicy = bluemonday.UGCPolicy() +} + +// StripHTML removes all HTML tags from the input +func StripHTML(input string) string { + return strictPolicy.Sanitize(input) +} + +// SanitizeHTML allows safe HTML while removing dangerous elements +func SanitizeHTML(input string) string { + return ugcPolicy.Sanitize(input) +} + +// SanitizeFormField sanitizes a form field value based on its type +func SanitizeFormField(value interface{}) interface{} { + switch v := value.(type) { + case string: + return StripHTML(v) + case []interface{}: + result := make([]interface{}, len(v)) + for i, item := range v { + result[i] = SanitizeFormField(item) + } + return result + case map[string]interface{}: + result := make(map[string]interface{}) + for key, val := range v { + result[StripHTML(key)] = SanitizeFormField(val) + } + return result + default: + return v + } +} + +// SanitizeSubmissionData sanitizes all values in a submission data map +func SanitizeSubmissionData(data map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + for key, value := range data { + result[key] = SanitizeFormField(value) + } + return result +} diff --git a/backend/internal/storage/local.go b/backend/internal/storage/local.go index fb725bf..2590a6a 100644 --- a/backend/internal/storage/local.go +++ b/backend/internal/storage/local.go @@ -114,6 +114,61 @@ func (s *LocalStorage) GetURLByPath(path string) (string, error) { return fmt.Sprintf("%s/%s", s.baseURL, path), nil } +// GetFileByPath retrieves a file's content from local storage for streaming +func (s *LocalStorage) GetFileByPath(path string) (*FileContent, error) { + fullPath := filepath.Join(s.basePath, path) + + // Check if file exists and get info + info, err := os.Stat(fullPath) + if os.IsNotExist(err) { + return nil, ErrFileNotFound + } + if err != nil { + return nil, err + } + + // Open the file + file, err := os.Open(fullPath) + if err != nil { + return nil, err + } + + // Detect content type from extension + contentType := detectContentTypeFromPath(path) + + return &FileContent{ + Reader: file, + ContentType: contentType, + Size: info.Size(), + }, nil +} + +// detectContentTypeFromPath returns MIME type based on file extension +func detectContentTypeFromPath(path string) string { + ext := filepath.Ext(path) + types := map[string]string{ + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".pdf": "application/pdf", + ".txt": "text/plain", + ".csv": "text/csv", + ".json": "application/json", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + } + + if ct, ok := types[ext]; ok { + return ct + } + return "application/octet-stream" +} + // GetURL returns the URL for accessing a file func (s *LocalStorage) GetURL(fileID string) (string, error) { // For local storage, we need to find the file diff --git a/backend/internal/storage/s3.go b/backend/internal/storage/s3.go index c87bd12..4cff5f7 100644 --- a/backend/internal/storage/s3.go +++ b/backend/internal/storage/s3.go @@ -140,6 +140,39 @@ func (s *S3Storage) GetURLByPath(path string) (string, error) { return s.getPresignedURL(key) } +// GetFileByPath retrieves a file's content from S3 for streaming/proxying +func (s *S3Storage) GetFileByPath(path string) (*FileContent, error) { + ctx := context.TODO() + + // Build the full S3 key by adding our prefix + key := s.prefix + path + + // Get the object from S3 + result, err := s.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, ErrFileNotFound + } + + contentType := "application/octet-stream" + if result.ContentType != nil { + contentType = *result.ContentType + } + + var size int64 + if result.ContentLength != nil { + size = *result.ContentLength + } + + return &FileContent{ + Reader: result.Body, + ContentType: contentType, + Size: size, + }, nil +} + // GetURL returns a presigned URL for accessing a file func (s *S3Storage) GetURL(fileID string) (string, error) { ctx := context.TODO() diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go index a10aee7..5b1efc0 100644 --- a/backend/internal/storage/storage.go +++ b/backend/internal/storage/storage.go @@ -33,6 +33,13 @@ type UploadResult struct { MimeType string `json:"mimeType"` } +// FileContent represents the content of a file for streaming +type FileContent struct { + Reader io.ReadCloser + ContentType string + Size int64 +} + // Storage defines the interface for file storage backends type Storage interface { // Upload stores a file and returns the result @@ -44,6 +51,9 @@ type Storage interface { // GetURLByPath returns the URL for accessing a file by its relative path GetURLByPath(path string) (string, error) + // GetFileByPath retrieves a file's content for streaming/proxying + GetFileByPath(path string) (*FileContent, error) + // Delete removes a file from storage Delete(fileID string) error diff --git a/frontend/app/components/UI/Pagination.vue b/frontend/app/components/UI/Pagination.vue new file mode 100644 index 0000000..f14efb1 --- /dev/null +++ b/frontend/app/components/UI/Pagination.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/frontend/app/composables/useApi.ts b/frontend/app/composables/useApi.ts index 5ba1c84..ae8175b 100644 --- a/frontend/app/composables/useApi.ts +++ b/frontend/app/composables/useApi.ts @@ -65,7 +65,13 @@ export const useApi = () => { }; const formsApi = { - list: (): Promise => request("/forms"), + list: (params?: PaginationParams): Promise> => { + const searchParams = new URLSearchParams(); + if (params?.page) searchParams.append("page", params.page.toString()); + if (params?.pageSize) searchParams.append("page_size", params.pageSize.toString()); + const query = searchParams.toString(); + return request(`/forms${query ? `?${query}` : ""}`); + }, get: (id: string): Promise
=> request(`/forms/${id}`), getPublic: (id: string): Promise => request(`/public/forms/${id}`), create: (form: Partial): Promise => @@ -104,7 +110,13 @@ export const useApi = () => { method: "POST", body: JSON.stringify({ data: formData, metadata }), }), - list: (formId: string): Promise => request(`/forms/${formId}/submissions`), + list: (formId: string, params?: PaginationParams): Promise => { + const searchParams = new URLSearchParams(); + if (params?.page) searchParams.append("page", params.page.toString()); + if (params?.pageSize) searchParams.append("page_size", params.pageSize.toString()); + const query = searchParams.toString(); + return request(`/forms/${formId}/submissions${query ? `?${query}` : ""}`); + }, get: (formId: string, submissionId: string): Promise => request(`/forms/${formId}/submissions/${submissionId}`), delete: (formId: string, submissionId: string): Promise => request(`/forms/${formId}/submissions/${submissionId}`, { @@ -140,7 +152,13 @@ export const useApi = () => { }; const usersApi = { - list: (): Promise => request("/users"), + list: (params?: PaginationParams): Promise> => { + const searchParams = new URLSearchParams(); + if (params?.page) searchParams.append("page", params.page.toString()); + if (params?.pageSize) searchParams.append("page_size", params.pageSize.toString()); + const query = searchParams.toString(); + return request(`/users${query ? `?${query}` : ""}`); + }, get: (id: string): Promise => request(`/users/${id}`), create: (user: { email: string; password: string; name: string; role?: UserRole }): Promise => request("/users", { diff --git a/frontend/app/composables/usePagination.ts b/frontend/app/composables/usePagination.ts new file mode 100644 index 0000000..6ee85b1 --- /dev/null +++ b/frontend/app/composables/usePagination.ts @@ -0,0 +1,96 @@ +export interface PaginationState { + page: number; + pageSize: number; + totalItems: number; + totalPages: number; +} + +export const usePagination = (initialPageSize = 20) => { + const state = reactive({ + page: 1, + pageSize: initialPageSize, + totalItems: 0, + totalPages: 0, + }); + + const updateFromResponse = (response: { page: number; page_size: number; total_items: number; total_pages: number }) => { + state.page = response.page; + state.pageSize = response.page_size; + state.totalItems = response.total_items; + state.totalPages = response.total_pages; + }; + + const setPage = (page: number) => { + if (page >= 1 && page <= state.totalPages) { + state.page = page; + } + }; + + const setPageSize = (size: number) => { + state.pageSize = size; + state.page = 1; // Reset to first page when changing page size + }; + + const nextPage = () => { + if (state.page < state.totalPages) { + state.page++; + } + }; + + const prevPage = () => { + if (state.page > 1) { + state.page--; + } + }; + + const firstPage = () => { + state.page = 1; + }; + + const lastPage = () => { + state.page = state.totalPages; + }; + + const hasNextPage = computed(() => state.page < state.totalPages); + const hasPrevPage = computed(() => state.page > 1); + + const params = computed(() => ({ + page: state.page, + pageSize: state.pageSize, + })); + + // Range of pages to show (e.g., [1, 2, 3, 4, 5] or [3, 4, 5, 6, 7]) + const visiblePages = computed(() => { + const total = state.totalPages; + const current = state.page; + const maxVisible = 5; + + if (total <= maxVisible) { + return Array.from({ length: total }, (_, i) => i + 1); + } + + let start = Math.max(1, current - Math.floor(maxVisible / 2)); + const end = Math.min(total, start + maxVisible - 1); + + if (end - start + 1 < maxVisible) { + start = Math.max(1, end - maxVisible + 1); + } + + return Array.from({ length: end - start + 1 }, (_, i) => start + i); + }); + + return { + state, + params, + updateFromResponse, + setPage, + setPageSize, + nextPage, + prevPage, + firstPage, + lastPage, + hasNextPage, + hasPrevPage, + visiblePages, + }; +}; diff --git a/frontend/app/pages/forms/[id]/responses.vue b/frontend/app/pages/forms/[id]/responses.vue index 097d824..2002a21 100644 --- a/frontend/app/pages/forms/[id]/responses.vue +++ b/frontend/app/pages/forms/[id]/responses.vue @@ -11,6 +11,9 @@ const submissions = ref([]); const stats = ref(null); const isLoading = ref(true); +// Pagination - Default 20 items per page +const pagination = usePagination(5); + // Tab state: 'summary' | 'question' | 'individual' const activeTab = ref<"summary" | "question" | "individual">("summary"); @@ -21,12 +24,20 @@ const selectedFieldId = ref(null); const currentSubmissionIndex = ref(0); const sortOrder = ref<"newest" | "oldest">("newest"); -const loadData = async () => { - isLoading.value = true; +const loadData = async (showLoading = true) => { + if (showLoading) { + isLoading.value = true; + } try { - const [submissionsData, statsData] = await Promise.all([submissionsApi.list(id), submissionsApi.stats(id)]); + const [submissionsData, statsData] = await Promise.all([ + submissionsApi.list(id, pagination.params.value), + submissionsApi.stats(id), + ]); form.value = submissionsData.form; - submissions.value = submissionsData.submissions || []; + submissions.value = submissionsData.submissions?.data || []; + if (submissionsData.submissions) { + pagination.updateFromResponse(submissionsData.submissions); + } stats.value = statsData; // Set default selected field for question tab @@ -47,6 +58,11 @@ const loadData = async () => { } }; +// Watch for pagination changes +watch(() => pagination.params.value, () => { + loadData(false); +}, { deep: true }); + const handleDelete = async (submissionId: string) => { if (!confirm(t("forms.responses.confirmDelete"))) return; @@ -269,7 +285,7 @@ onMounted(() => { -
+

{{ $t("forms.responses.empty.title") }}

{{ $t("forms.responses.empty.description") }}

@@ -279,7 +295,7 @@ onMounted(() => {
- {{ submissions.length }} + {{ pagination.state.totalItems }} {{ $t("forms.responses.summary.responses") }}
@@ -447,6 +463,23 @@ onMounted(() => {
+ + +
@@ -460,7 +493,7 @@ onMounted(() => { @click="selectedFieldId = field.id" > {{ field.label }} - {{ submissions.length }} + {{ pagination.state.totalItems }} @@ -569,6 +602,24 @@ onMounted(() => { + + + @@ -717,6 +768,23 @@ onMounted(() => { + + + @@ -1111,10 +1179,15 @@ onMounted(() => { /* Question View */ .question-view { display: flex; + flex-wrap: wrap; gap: 1.5rem; min-height: 500px; } +.question-pagination { + width: 100%; +} + .question-sidebar { width: 280px; flex-shrink: 0; diff --git a/frontend/app/pages/forms/index.vue b/frontend/app/pages/forms/index.vue index 5a83751..1849573 100644 --- a/frontend/app/pages/forms/index.vue +++ b/frontend/app/pages/forms/index.vue @@ -13,8 +13,9 @@ const sortBy = ref<"updated" | "created" | "title">("updated"); const loadForms = async () => { try { - const data = await formsApi.list(); - forms.value = data || []; + // Load with high page size to get all forms (client-side filtering) + const response = await formsApi.list({ pageSize: 100 }); + forms.value = response.data || []; isLoading.value = false; // Load stats for all forms in parallel (non-blocking) diff --git a/frontend/app/pages/settings.vue b/frontend/app/pages/settings.vue index 78cde5c..f8597c7 100644 --- a/frontend/app/pages/settings.vue +++ b/frontend/app/pages/settings.vue @@ -48,6 +48,7 @@ const selectedTheme = ref<"light" | "dark" | "system">("system"); // Users state const users = ref([]); const isLoadingUsers = ref(false); +const usersPagination = usePagination(5); const showUserModal = ref(false); const editingUser = ref(null); const userForm = ref({ @@ -88,7 +89,9 @@ const loadSettings = async () => { const loadUsers = async () => { isLoadingUsers.value = true; try { - users.value = await usersApi.list(); + const response = await usersApi.list(usersPagination.params.value); + users.value = response.data || []; + usersPagination.updateFromResponse(response); } catch (error) { console.error("Failed to load users:", error); } finally { @@ -96,6 +99,11 @@ const loadUsers = async () => { } }; +// Watch for pagination changes +watch(() => usersPagination.params.value, () => { + loadUsers(); +}, { deep: true }); + const handleSave = async () => { if (!settings.value) return; isSaving.value = true; @@ -681,6 +689,23 @@ onMounted(() => { + + + diff --git a/frontend/app/store/forms.ts b/frontend/app/store/forms.ts index c875369..275ba48 100644 --- a/frontend/app/store/forms.ts +++ b/frontend/app/store/forms.ts @@ -25,8 +25,8 @@ export const useFormsStore = defineStore("forms", () => { isLoading.value = true; try { - const data = await formsApi.list(); - forms.value = data || []; + const response = await formsApi.list({ pageSize: 100 }); + forms.value = response.data || []; lastFetched.value = Date.now(); return forms.value; } catch (error) { diff --git a/frontend/i18n/locales/de.json b/frontend/i18n/locales/de.json index e72c6de..65021be 100644 --- a/frontend/i18n/locales/de.json +++ b/frontend/i18n/locales/de.json @@ -49,6 +49,14 @@ "forms": "Formulare", "settings": "Einstellungen" }, + "pagination": { + "showing": "{start} bis {end} von {total}", + "first": "Erste Seite", + "previous": "Vorherige Seite", + "next": "Nächste Seite", + "last": "Letzte Seite", + "perPage": "Pro Seite:" + }, "settings": { "title": "Einstellungen", "description": "Verwalten Sie Ihre Anwendungseinstellungen", diff --git a/frontend/i18n/locales/en.json b/frontend/i18n/locales/en.json index 5d259ab..c90baf0 100644 --- a/frontend/i18n/locales/en.json +++ b/frontend/i18n/locales/en.json @@ -49,6 +49,14 @@ "forms": "Forms", "settings": "Settings" }, + "pagination": { + "showing": "Showing {start} to {end} of {total}", + "first": "First page", + "previous": "Previous page", + "next": "Next page", + "last": "Last page", + "perPage": "Per page:" + }, "settings": { "title": "Settings", "description": "Manage your application settings", diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index faec3e9..c615fbb 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -73,7 +73,7 @@ export default defineNuxtConfig({ ], defaultLocale: "en", langDir: "locales/", - strategy: "prefix_except_default", + strategy: "no_prefix", detectBrowserLanguage: { useCookie: true, cookieKey: "i18n_locale", diff --git a/frontend/shared/types/index.ts b/frontend/shared/types/index.ts index 3206782..ce71646 100644 --- a/frontend/shared/types/index.ts +++ b/frontend/shared/types/index.ts @@ -198,10 +198,24 @@ export interface AuthResponse { user: User; } +// Generic pagination response from backend +export interface PaginatedResponse { + data: T; + page: number; + page_size: number; + total_items: number; + total_pages: number; +} + +// Pagination parameters for API requests +export interface PaginationParams { + page?: number; + pageSize?: number; +} + export interface SubmissionsResponse { form: Form; - submissions: Submission[]; - count: number; + submissions: PaginatedResponse; } export interface FormStats { From 5c7849242a54a91de657e61f47879651bf402962 Mon Sep 17 00:00:00 2001 From: "Alec S." Date: Thu, 4 Dec 2025 09:26:01 +0100 Subject: [PATCH 2/2] Automates API documentation generation. Sets up a workflow to automatically generate and deploy API documentation using Swagger. This enhances the developer experience by providing up-to-date API documentation automatically. --- .github/workflows/docs.yml | 66 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/docs.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..b618eeb --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,66 @@ +name: API Documentation + +on: + push: + branches: + - main + paths: + - 'backend/**' + - 'api-docs/**' + - '.github/workflows/docs.yml' + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + name: Generate Swagger Docs + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.23' + + - name: Install swag + run: go install github.com/swaggo/swag/cmd/swag@latest + + - name: Generate Swagger documentation + run: swag init -g cmd/server/main.go -o docs --parseInternal --parseDependency + working-directory: backend + + - name: Copy docs to api-docs + run: cp backend/docs/swagger.json api-docs/ + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: 'api-docs' + + deploy: + name: Deploy to GitHub Pages + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4