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/.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
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