├── .github └── workflows │ └── release.yaml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yaml ├── LICENSE ├── Makefile ├── README.md ├── cmd └── web-form │ └── main.go ├── dbconfig.yml ├── docker-compose.yaml ├── docker └── Dockerfile ├── docs ├── CNAME ├── _config.yml ├── authorization.md ├── configuration.md ├── docker.md ├── fields.md ├── form.md ├── index.md ├── notifications.md ├── prefill.md ├── stores.md └── template.md ├── examples ├── README.md ├── assets │ └── img │ │ └── semweb.svg ├── configs │ ├── order-pizza.yaml │ ├── shop.yaml │ ├── simple.yaml │ └── test.yaml ├── docker-compose.yaml └── migrations │ ├── 00001_init.sql │ ├── 00002_simple.sql │ └── 00003_pizza.sql ├── go.mod ├── go.sum └── internal ├── assets ├── init.go ├── static │ ├── css │ │ ├── bulma.min.css │ │ └── materialdesignicons.min.css │ └── fonts │ │ ├── materialdesignicons-webfont.woff │ │ └── materialdesignicons-webfont.woff2 └── views │ ├── access.gohtml │ ├── failed.gohtml │ ├── forbidden.gohtml │ ├── form.gohtml │ ├── form_base.gohtml │ ├── list.gohtml │ └── success.gohtml ├── captcha └── turnstile.go ├── engine ├── form.go ├── form_test.go ├── options.go └── server.go ├── notifications ├── amqp │ ├── amqp.go │ └── amqp_test.go ├── types.go └── webhook │ ├── webhook_test.go │ └── webhooks.go ├── schema ├── loader.go ├── parser.go ├── template.go ├── types.go └── types_test.go ├── storage ├── db.go ├── db_test.go ├── dump.go ├── file.go └── utils.go ├── utils ├── strings.go ├── strings_test.go ├── templates.go └── utils.go └── web └── web.go /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Build and release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | 7 | env: 8 | REGISTRY: ghcr.io 9 | 10 | jobs: 11 | build: 12 | name: Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Log in to the Container registry 16 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 17 | with: 18 | registry: ${{ env.REGISTRY }} 19 | username: ${{ github.actor }} 20 | password: ${{ secrets.GITHUB_TOKEN }} 21 | - name: Set up Go 22 | uses: actions/setup-go@v2 23 | with: 24 | go-version: '~1.21' 25 | id: go 26 | - name: Set up QEMU 27 | uses: docker/setup-qemu-action@v1 28 | - name: Set up Docker Buildx 29 | id: buildx 30 | uses: docker/setup-buildx-action@v1 31 | - name: Check out code into the Go module directory 32 | uses: actions/checkout@v2 33 | with: 34 | lfs: true 35 | fetch-depth: 0 36 | - name: Checkout LFS objects 37 | run: git lfs checkout 38 | - name: Pull tag 39 | run: git fetch --tags 40 | - name: Run GoReleaser 41 | uses: goreleaser/goreleaser-action@v4 42 | with: 43 | version: latest 44 | args: release --rm-dist 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /test-data 2 | /build 3 | /dist 4 | /.idea 5 | /results 6 | 7 | /*.sqlite 8 | .idea 9 | .vscode 10 | .DS_Store 11 | /.local 12 | /*.pdf 13 | /.goreleaser.local.yaml -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | tests: false 3 | linters: 4 | enable-all: true 5 | disable: 6 | - goerr113 7 | - contextcheck 8 | - exhaustivestruct 9 | - wrapcheck 10 | - varnamelen 11 | - exhaustruct 12 | - lll 13 | - wsl 14 | - nlreturn 15 | - gofumpt 16 | - funlen 17 | - gci 18 | - nonamedreturns 19 | - depguard 20 | - musttag 21 | - ireturn 22 | - gosmopolitan 23 | - gomnd 24 | - tagalign 25 | - godox 26 | - maligned # this is pretty good linter, but not really needed here 27 | - whitespace -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | builds: 2 | - env: 3 | - CGO_ENABLED=0 4 | goos: 5 | - linux 6 | - darwin 7 | flags: 8 | - -trimpath 9 | goarch: 10 | - arm64 11 | - amd64 12 | main: ./cmd/web-form/main.go 13 | binary: web-form 14 | checksum: 15 | name_template: 'checksums.txt' 16 | snapshot: 17 | name_template: "{{ incpatch .Version }}-next" 18 | dockers: 19 | - image_templates: 20 | - "ghcr.io/reddec/{{ .ProjectName }}:{{ .Version }}-amd64" 21 | use: buildx 22 | dockerfile: docker/Dockerfile 23 | build_flag_templates: 24 | - "--platform=linux/amd64" 25 | - image_templates: 26 | - "ghcr.io/reddec/{{ .ProjectName }}:{{ .Version }}-arm64v8" 27 | use: buildx 28 | goarch: arm64 29 | dockerfile: docker/Dockerfile 30 | build_flag_templates: 31 | - "--platform=linux/arm64/v8" 32 | docker_manifests: 33 | - name_template: "ghcr.io/reddec/{{ .ProjectName }}:{{ .Version }}" 34 | image_templates: 35 | - "ghcr.io/reddec/{{ .ProjectName }}:{{ .Version }}-amd64" 36 | - "ghcr.io/reddec/{{ .ProjectName }}:{{ .Version }}-arm64v8" 37 | # alias for latest 38 | - name_template: "ghcr.io/reddec/{{ .ProjectName }}:latest" 39 | image_templates: 40 | - "ghcr.io/reddec/{{ .ProjectName }}:{{ .Version }}-amd64" 41 | - "ghcr.io/reddec/{{ .ProjectName }}:{{ .Version }}-arm64v8" 42 | # alias for major version (x) 43 | - name_template: "ghcr.io/reddec/{{ .ProjectName }}:{{.Major}}" 44 | image_templates: 45 | - "ghcr.io/reddec/{{ .ProjectName }}:{{ .Version }}-amd64" 46 | - "ghcr.io/reddec/{{ .ProjectName }}:{{ .Version }}-arm64v8" 47 | # alias for minor version (x.y) 48 | - name_template: "ghcr.io/reddec/{{ .ProjectName }}:{{.Major}}.{{.Minor}}" 49 | image_templates: 50 | - "ghcr.io/reddec/{{ .ProjectName }}:{{ .Version }}-amd64" 51 | - "ghcr.io/reddec/{{ .ProjectName }}:{{ .Version }}-arm64v8" 52 | changelog: 53 | sort: asc 54 | filters: 55 | exclude: 56 | - '^docs:' 57 | - '^test:' -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | LOCAL := $(PWD)/.local 2 | export PATH := $(LOCAL)/bin:$(PATH) 3 | export GOBIN := $(LOCAL)/bin 4 | 5 | MDIVERSION := 7.2.96 6 | LINTER := $(GOBIN)/golangci-lint 7 | GORELEASER := $(GOBIN)/goreleaser 8 | 9 | $(LINTER): 10 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.54.2 11 | 12 | $(GORELEASER): 13 | go install github.com/goreleaser/goreleaser@v1.21.0 14 | 15 | lint: $(LINTER) 16 | $(LINTER) run 17 | .PHONY: lint 18 | 19 | # it will not download heavy eot and ttf 20 | update-assets: 21 | mkdir -p internal/assets/static/css 22 | mkdir -p internal/assets/static/fonts 23 | curl -L -f -o internal/assets/static/css/materialdesignicons.min.css "https://cdn.jsdelivr.net/npm/@mdi/font@$(MDIVERSION)/css/materialdesignicons.min.css" 24 | cd internal/assets/static/css && cat materialdesignicons.min.css | \ 25 | tr -s ';{},' '\n' | \ 26 | grep url | \ 27 | sed -rn 's|.*?url\("([^"]+?)".*|\1|p' | \ 28 | grep -v '#' | \ 29 | grep -v '.eot' | \ 30 | grep -v '.ttf' | \ 31 | cut -d '?' -f 1 | \ 32 | xargs -I{} curl -L -f -o {} "https://cdn.jsdelivr.net/npm/@mdi/font@$(MDIVERSION)/css/{}" 33 | .PHONY: update-assets 34 | 35 | 36 | snapshot: $(GORELEASER) 37 | $(GORELEASER) release --snapshot --clean 38 | docker tag ghcr.io/reddec/$(notdir $(CURDIR)):$$(jq -r .version dist/metadata.json)-amd64 ghcr.io/reddec/$(notdir $(CURDIR)):latest 39 | 40 | local: $(GORELEASER) 41 | $(GORELEASER) release -f .goreleaser.local.yaml --clean 42 | 43 | test: 44 | go test -v ./... 45 | 46 | .PHONY: test 47 | 48 | local-dev: 49 | docker compose stop 50 | docker compose rm -fv 51 | docker compose up -d 52 | 53 | .PHONY: local-dev -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Web Forms 2 | 3 | WebForms is a versatile tool with a focus on DevOps compatibility, designed for creating HTML UI forms 4 | with [backends](https://web-form.reddec.net/stores) that can be hosted in PostgreSQL, SQLite3, or plain JSON files. The 5 | service offers the flexibility to reuse existing tables and database structures and includes automated embedded schema 6 | migration. 7 | 8 | The user interface (UI) is designed to be lightweight and mobile-friendly, and it can function without JavaScript, 9 | although there may be some limitations in timezone detection. The service also includes various security features. 10 | Additionally, there is an option to disable the UI for browsing available forms. The service also 11 | offers [OIDC](https://web-form.reddec.net/authorization) integration for authorization, with the ability to use OIDC 12 | claims, such as usernames, in templating and default values. 13 | 14 | WebForms allows for the sending of multiple notifications ( 15 | ex: [WebHooks](https://web-form.reddec.net/notifications#webhooks) 16 | or [AMQP](https://web-form.reddec.net/notifications#amqp)) after form submissions to facilitate integration with 17 | external systems. It provides a configurable retry strategy for reliability. 18 | 19 | Flexible [templating](https://web-form.reddec.net/template) enables the prefilling of fields and the generation of 20 | personalized greeting messages. 21 | 22 | The service optionally supports [CAPTCHA](https://web-form.reddec.net/configuration#captcha) for verifying the 23 | authenticity of incoming requests during both form submission and access code submission processes. 24 | 25 | Initial setup of the service is straightforward and requires minimal backend configuration and form definition. However, 26 | for those who require more customization, almost every aspect of the service can 27 | be [configured](https://web-form.reddec.net/configuration). It 28 | can be used in a stateless manner and is scalable. Refer to 29 | the [production checklist](https://web-form.reddec.net/configuration#production-checklist) for further details. 30 | 31 | WebForms is available in various formats, including [source code](https://github.com/reddec/web-form), pre-compiled 32 | [binaries](https://github.com/reddec/web-form/releases/latest) for major platforms, 33 | and [containers](https://github.com/reddec/web-form/pkgs/container/web-form) for both AMD and ARM 34 | architectures. 35 | 36 | The project is licensed under MPL-2.0 (Exhibit A), which allows for commercial usage with minimal restrictions, provided 37 | that any changes made are shared with the community. This promotes collaboration and community involvement. 38 | 39 | Read docs for details: https://web-form.reddec.net/ 40 | 41 | ![image](https://github.com/reddec/web-form/assets/6597086/b4dce0e1-30cf-492d-96a4-dbcc98eb787d) 42 | 43 | ## Installation 44 | 45 | - From source code using go 1.21+ `go install github.com/reddec/web-form/cmd/...@latest` 46 | - From [binaries](https://github.com/reddec/web-form/releases/latest) 47 | - From [containers](https://github.com/reddec/web-form/pkgs/container/web-form) - 48 | see [docker](https://web-form.reddec.net/docker) 49 | 50 | ## Examples 51 | 52 | Check examples in corresponding [directory](https://github.com/reddec/web-form/tree/master/examples). 53 | -------------------------------------------------------------------------------- /cmd/web-form/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io/fs" 8 | "log/slog" 9 | "net" 10 | "net/http" 11 | "os" 12 | "os/signal" 13 | "path/filepath" 14 | "time" 15 | 16 | "github.com/reddec/web-form/internal/assets" 17 | "github.com/reddec/web-form/internal/captcha" 18 | "github.com/reddec/web-form/internal/engine" 19 | "github.com/reddec/web-form/internal/notifications/amqp" 20 | "github.com/reddec/web-form/internal/notifications/webhook" 21 | "github.com/reddec/web-form/internal/schema" 22 | "github.com/reddec/web-form/internal/storage" 23 | "github.com/reddec/web-form/internal/web" 24 | 25 | _ "modernc.org/sqlite" 26 | 27 | "github.com/alexedwards/scs/redisstore" 28 | "github.com/alexedwards/scs/v2" 29 | "github.com/coreos/go-oidc/v3/oidc" 30 | "github.com/go-chi/chi/v5" 31 | "github.com/gomodule/redigo/redis" 32 | "github.com/hashicorp/go-multierror" 33 | "github.com/jessevdk/go-flags" 34 | oidclogin "github.com/reddec/oidc-login" 35 | ) 36 | 37 | //nolint:gochecknoglobals 38 | var ( 39 | version = "dev" 40 | commit = "none" 41 | date = "unknown" 42 | builtBy = "unknown" 43 | ) 44 | 45 | const ( 46 | description = "Self-hosted Web Forms" 47 | name = "web-forms" 48 | ) 49 | 50 | type Config struct { 51 | Configs string `long:"configs" env:"CONFIGS" description:"File or directory with YAML configurations" default:"configs"` 52 | Storage string `long:"storage" env:"STORAGE" description:"Storage type" default:"database" choice:"database" choice:"files" choice:"dump"` 53 | DisableListing bool `long:"disable-listing" env:"DISABLE_LISTING" description:"Disable listing in UI"` 54 | DB struct { 55 | Dialect string `long:"dialect" env:"DIALECT" description:"SQL dialect" default:"sqlite3" choice:"postgres" choice:"sqlite3"` 56 | URL string `long:"url" env:"URL" description:"Database URL" default:"file://form.sqlite"` 57 | Migrations string `long:"migrations" env:"MIGRATIONS" description:"Migrations dir" default:"migrations"` 58 | Migrate bool `long:"migrate" env:"MIGRATE" description:"Apply migration on start"` 59 | } `group:"Database storage" namespace:"db" env-namespace:"DB"` 60 | Files struct { 61 | Path string `long:"path" env:"PATH" description:"Root dir for form results" default:"results"` 62 | } `group:"Files storage" namespace:"files" env-namespace:"FILES"` 63 | Webhooks struct { 64 | Buffer int `long:"buffer" env:"BUFFER" description:"Internal queue size before processing" default:"100"` 65 | } `group:"Webhooks general configuration" namespace:"webhooks" env-namespace:"WEBHOOKS"` 66 | AMQP struct { 67 | URL string `long:"url" env:"URL" description:"AMQP broker URL" default:"amqp://guest:guest@localhost"` 68 | Buffer int `long:"buffer" env:"BUFFER" description:"Internal queue size before processing" default:"100"` 69 | Workers int `long:"workers" env:"WORKERS" description:"Number of parallel publishers" default:"4"` 70 | } `group:"AMQP configuration" namespace:"amqp" env-namespace:"AMQP"` 71 | HTTP struct { 72 | Assets string `long:"assets" env:"ASSETS" description:"Directory for assets (static) files"` 73 | Bind string `long:"bind" env:"BIND" description:"Binding address" default:":8080"` 74 | DisableXSRF bool `long:"disable-xsrf" env:"DISABLE_XSRF" description:"Disable XSRF validation. Useful for API"` 75 | TLS bool `long:"tls" env:"TLS" description:"Enable TLS"` 76 | Key string `long:"key" env:"KEY" description:"Private TLS key" default:"server.key"` 77 | Cert string `long:"cert" env:"CERT" description:"Public TLS certificate" default:"server.crt"` 78 | ReadTimeout time.Duration `long:"read-timeout" env:"READ_TIMEOUT" description:"Read timeout to prevent slow client attack" default:"5s"` 79 | WriteTimeout time.Duration `long:"write-timeout" env:"WRITE_TIMEOUT" description:"Write timeout to prevent slow consuming clients attack" default:"5s"` 80 | } `group:"HTTP server configuration" namespace:"http" env-namespace:"HTTP"` 81 | OIDC struct { 82 | Enable bool `long:"enable" env:"ENABLE" description:"Enable OIDC protection"` 83 | ClientID string `long:"client-id" env:"CLIENT_ID" description:"OIDC client ID"` 84 | ClientSecret string `long:"client-secret" env:"CLIENT_SECRET" description:"OIDC client secret"` 85 | Issuer string `long:"issuer" env:"ISSUER" description:"Issuer URL (without .well-known)"` 86 | RedisURL string `long:"redis-url" env:"REDIS_URL" description:"Optional Redis URL for sessions. If not set - in-memory will be used"` 87 | RedisIdle int `long:"redis-idle" env:"REDIS_IDLE" description:"Redis maximum number of idle connections" default:"1"` 88 | RedisMaxConnections int `long:"redis-max-connections" env:"REDIS_MAX_CONNECTIONS" description:"Redis maximum number of active connections" default:"10"` 89 | } `group:"OIDC configuration" namespace:"oidc" env-namespace:"OIDC"` 90 | Captcha struct { 91 | Turnstile captcha.Turnstile `group:"Cloudflare Turnstile" namespace:"turnstile" env-namespace:"TURNSTILE"` 92 | } `group:"Captcha configurations" namespace:"captcha" env-namespace:"CAPTCHA"` 93 | ServerURL string `long:"server-url" env:"SERVER_URL" description:"Server public URL. Used for OIDC redirects. If not set - it will try to deduct"` 94 | } 95 | 96 | func main() { 97 | var config Config 98 | parser := flags.NewParser(&config, flags.Default) 99 | parser.ShortDescription = name 100 | parser.LongDescription = fmt.Sprintf("%s \n%s %s, commit %s, built at %s by %s\nAuthor: reddec ", description, name, version, commit, date, builtBy) 101 | _, err := parser.Parse() 102 | if err != nil { 103 | os.Exit(1) 104 | } 105 | 106 | ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill) 107 | defer cancel() 108 | 109 | if err := run(ctx, config); err != nil { 110 | slog.Error("run failed", "error", err) 111 | os.Exit(2) //nolint:gocritic 112 | } 113 | } 114 | 115 | //nolint:cyclop 116 | func run(ctx context.Context, config Config) error { 117 | router := chi.NewRouter() 118 | 119 | // mock auth by default 120 | var authMiddleware = func(next http.Handler) http.Handler { 121 | return next 122 | } 123 | 124 | if config.OIDC.Enable { 125 | // setup auth provider from OIDC 126 | slog.Info("oidc enabled", "issuer", config.OIDC.Issuer) 127 | sessionManager := scs.New() // by default in-memory session store 128 | router.Use(sessionManager.LoadAndSave) 129 | 130 | if config.OIDC.RedisURL != "" { 131 | // setup redis pool for sessions 132 | slog.Info("oidc redis session storage enabled") 133 | redisPool := &redis.Pool{ 134 | Dial: func() (redis.Conn, error) { 135 | return redis.DialURLContext(ctx, config.OIDC.RedisURL) 136 | }, 137 | MaxIdle: config.OIDC.RedisIdle, 138 | MaxActive: config.OIDC.RedisMaxConnections, 139 | IdleTimeout: time.Hour, 140 | } 141 | defer redisPool.Close() 142 | sessionManager.Store = redisstore.New(redisPool) 143 | } else { 144 | slog.Info("oidc session storage in-memory") 145 | } 146 | 147 | auth, err := config.createAuth(ctx, sessionManager) 148 | if err != nil { 149 | return fmt.Errorf("create auth: %w", err) 150 | } 151 | authMiddleware = auth.Secure 152 | router.Mount(oidclogin.Prefix, auth) 153 | } else { 154 | slog.Info("no authorization used") 155 | } 156 | 157 | // static dir and user-defined asset dir are unprotected 158 | router.Mount("/static/", http.FileServer(http.FS(assets.Static))) 159 | if config.HTTP.Assets != "" { 160 | slog.Info("user-defined assets enabled", "assets-dir", config.HTTP.Assets) 161 | router.Mount("/assets/", http.StripPrefix("/assets", http.FileServer(http.Dir(config.HTTP.Assets)))) 162 | } 163 | 164 | // scan forms from file system 165 | forms, err := schema.FormsFromFS(os.DirFS(config.Configs)) 166 | if err != nil { 167 | return fmt.Errorf("read configs in %q: %w", config.Configs, err) 168 | } 169 | 170 | // create results storage 171 | store, err := config.createStorage(ctx) 172 | if err != nil { 173 | return fmt.Errorf("create storage: %w", err) 174 | } 175 | defer store.Close() 176 | slog.Info("storage prepared") 177 | 178 | // webhooks dispatcher 179 | webhooks := webhook.New(config.Webhooks.Buffer) 180 | // amqp dispatcher - lazy loading, so URL validity not critical here 181 | broker := amqp.New(config.AMQP.URL, config.AMQP.Buffer) 182 | 183 | srv, err := engine.New(engine.Config{ 184 | Forms: forms, 185 | Storage: store, 186 | WebhooksFactory: webhooks, 187 | AMQPFactory: broker, 188 | Listing: !config.DisableListing, 189 | Captcha: config.captcha(), 190 | }, 191 | engine.WithXSRF(!config.HTTP.DisableXSRF), 192 | ) 193 | if err != nil { 194 | return fmt.Errorf("create engine: %w", err) 195 | } 196 | 197 | router.Group(func(r chi.Router) { 198 | r.Use(owasp) 199 | r.Use(authMiddleware) 200 | r.Use(func(handler http.Handler) http.Handler { 201 | return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 202 | creds := credentialsFromRequest(request) 203 | reqCtx := schema.WithCredentials(request.Context(), creds) 204 | handler.ServeHTTP(writer, request.WithContext(reqCtx)) 205 | }) 206 | }) 207 | r.Mount("/", srv) 208 | }) 209 | 210 | server := &http.Server{ 211 | Addr: config.HTTP.Bind, 212 | Handler: router, 213 | ReadTimeout: config.HTTP.ReadTimeout, 214 | WriteTimeout: config.HTTP.WriteTimeout, 215 | BaseContext: func(listener net.Listener) context.Context { 216 | return ctx 217 | }, 218 | } 219 | 220 | var wg multierror.Group 221 | 222 | wg.Go(func() error { 223 | webhooks.Run(ctx) 224 | return nil 225 | }) 226 | 227 | for i := 0; i < config.AMQP.Workers; i++ { 228 | wg.Go(func() error { 229 | broker.Run(ctx) 230 | return nil 231 | }) 232 | } 233 | 234 | wg.Go(func() error { 235 | var err error 236 | if config.HTTP.TLS { 237 | err = server.ListenAndServeTLS(config.HTTP.Cert, config.HTTP.Key) 238 | } else { 239 | err = server.ListenAndServe() 240 | } 241 | if errors.Is(err, http.ErrServerClosed) { 242 | err = nil 243 | } 244 | return err 245 | }) 246 | 247 | wg.Go(func() error { 248 | <-ctx.Done() 249 | return server.Close() 250 | }) 251 | 252 | slog.Info("ready", "bind", config.HTTP.Bind, "storage", config.Storage) 253 | 254 | return wg.Wait().ErrorOrNil() 255 | } 256 | 257 | func (cfg *Config) createStorage(ctx context.Context) (storage.ClosableStorage, error) { 258 | switch cfg.Storage { 259 | case "files": 260 | return storage.NopCloser(storage.NewFileStore(cfg.Files.Path)), nil 261 | case "database": 262 | db, err := storage.NewDB(ctx, cfg.DB.Dialect, cfg.DB.URL) 263 | if err != nil { 264 | return nil, fmt.Errorf("create storag: %w", err) 265 | } 266 | if cfg.shouldMigrate() { 267 | slog.Info("migrating database") 268 | return db, db.Migrate(ctx, cfg.DB.Migrations) 269 | } 270 | slog.Info("migration skipped") 271 | return db, nil 272 | case "dump": 273 | return storage.NopCloser(&storage.Dump{}), nil 274 | default: 275 | return nil, fmt.Errorf("unknown storage type %q", cfg.Storage) 276 | } 277 | } 278 | 279 | func (cfg *Config) createAuth(ctx context.Context, sessionManager *scs.SessionManager) (service *oidclogin.OIDC, err error) { 280 | return oidclogin.New(ctx, oidclogin.Config{ 281 | IssuerURL: cfg.OIDC.Issuer, 282 | ClientID: cfg.OIDC.ClientID, 283 | ClientSecret: cfg.OIDC.ClientSecret, 284 | ServerURL: cfg.ServerURL, 285 | Scopes: []string{oidc.ScopeOpenID, "profile"}, 286 | SessionManager: sessionManager, 287 | BeforeAuth: func(writer http.ResponseWriter, req *http.Request) error { 288 | sessionManager.Put(req.Context(), "redirect-to", req.URL.String()) 289 | return nil 290 | }, 291 | PostAuth: func(writer http.ResponseWriter, req *http.Request, idToken *oidc.IDToken) error { 292 | to := sessionManager.PopString(req.Context(), "redirect-to") 293 | if to != "" { 294 | writer.Header().Set("Location", to) 295 | } 296 | return nil 297 | }, 298 | Logger: &LoggerFunc{}, 299 | }) 300 | } 301 | 302 | func (cfg *Config) shouldMigrate() bool { 303 | if !cfg.DB.Migrate { 304 | return false 305 | } 306 | var migrate = false 307 | err := filepath.WalkDir(cfg.DB.Migrations, func(path string, d fs.DirEntry, err error) error { 308 | if d.IsDir() || err != nil { 309 | return err 310 | } 311 | if filepath.Ext(path) == ".sql" { 312 | migrate = true 313 | return filepath.SkipAll 314 | } 315 | return nil 316 | }) 317 | return migrate && err == nil 318 | } 319 | 320 | func (cfg *Config) captcha() []web.Captcha { 321 | var ans []web.Captcha 322 | if cfg.Captcha.Turnstile.SiteKey != "" { 323 | slog.Info("Cloudflare Turnstile captcha enabled") 324 | ans = append(ans, &cfg.Captcha.Turnstile) 325 | } 326 | return ans 327 | } 328 | 329 | type LoggerFunc struct{} 330 | 331 | func (lf LoggerFunc) Log(level oidclogin.Level, message string) { 332 | switch level { 333 | case oidclogin.LogInfo: 334 | slog.Info(message, "source", "oidc") 335 | case oidclogin.LogWarn: 336 | slog.Warn(message, "source", "oidc") 337 | case oidclogin.LogError: 338 | slog.Error(message, "source", "oidc") 339 | default: 340 | slog.Debug(message, "source", "oidc") 341 | } 342 | } 343 | 344 | func owasp(next http.Handler) http.Handler { 345 | return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 346 | writer.Header().Set("X-Frame-Options", "DENY") 347 | writer.Header().Set("X-XSS-Protection", "1") 348 | writer.Header().Set("X-Content-Type-Options", "nosniff") 349 | writer.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") 350 | next.ServeHTTP(writer, request) 351 | }) 352 | } 353 | 354 | func credentialsFromRequest(req *http.Request) *schema.Credentials { 355 | token := oidclogin.Token(req) 356 | if token == nil { 357 | return nil 358 | } 359 | // optimized version to avoid multiple unmarshalling 360 | var claims struct { 361 | Username string `json:"preferred_username"` //nolint:tagliatelle 362 | Email string `json:"email"` 363 | Groups []string `json:"groups"` 364 | } 365 | _ = token.Claims(&claims) 366 | // workaround for username 367 | claims.Username = firstOf(claims.Username, claims.Email, token.Subject) 368 | return &schema.Credentials{ 369 | User: claims.Username, 370 | Groups: claims.Groups, 371 | Email: claims.Email, 372 | } 373 | } 374 | 375 | func firstOf[T comparable](values ...T) T { 376 | var def T 377 | for _, v := range values { 378 | if v != def { 379 | return v 380 | } 381 | } 382 | return def 383 | } 384 | -------------------------------------------------------------------------------- /dbconfig.yml: -------------------------------------------------------------------------------- 1 | development: 2 | dialect: postgres 3 | datasource: postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable 4 | dir: examples/migrations -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | # this is for development only. 2 | # 3 | # Use examples directory for normal compose. 4 | 5 | services: 6 | db: 7 | image: postgres:14 8 | environment: 9 | POSTGRES_PASSWORD: postgres 10 | ports: 11 | - 127.0.0.1:5432:5432 12 | 13 | rabbitmq: 14 | image: rabbitmq:3.12-management 15 | ports: 16 | - 127.0.0.1:15672:15672 17 | - 127.0.0.1:5672:5672 -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.18.3 AS build 2 | RUN apk --no-cache add ca-certificates tzdata && update-ca-certificates 3 | 4 | FROM scratch 5 | # for UI 6 | EXPOSE 8080/tcp 7 | # for output, should be RW 8 | VOLUME /data 9 | # for migrations, can be RO 10 | VOLUME /migrations 11 | # for form definitions, can be RO 12 | VOLUME /configs 13 | # for user-defined static files avaiable via /assets/ 14 | VOLUME /assets 15 | # pre-populate env 16 | ENV FILES_PATH="/data" \ 17 | CONFIGS="/configs" \ 18 | DB_MIGRATIONS="/migrations" \ 19 | DB_MIGRATE="true" \ 20 | STORAGE="files" \ 21 | HTTP_BIND="0.0.0.0:8080" \ 22 | HTTP_ASSETS="/assets" 23 | 24 | ENTRYPOINT ["/usr/bin/web-form"] 25 | COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo 26 | COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 27 | ADD web-form /usr/bin/web-form -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | web-form.reddec.net -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | remote_theme: pmarsceill/just-the-docs -------------------------------------------------------------------------------- /docs/authorization.md: -------------------------------------------------------------------------------- 1 | # Authorization 2 | 3 | By-default, the solution has no authorization. However, it is possible to use 4 | any [OIDC](https://auth0.com/docs/authenticate/protocols/openid-connect-protocol#:~:text=OpenID%20Connect%20(OIDC)%20is%20an,obtain%20basic%20user%20profile%20information) 5 | -compliant provider to secure access to the forms. 6 | 7 | The alternative solution is to set [codes](#codes) in forms definition. 8 | 9 | ## OIDC 10 | 11 | OAuth callback URL is `/oauth2/callback`. For example, if your public server url 12 | is `https://forms.example.com`, then callback url is `https://forms.example.com/oauth2/callback`. 13 | 14 | It uses [oidc-login](https://github.com/reddec/oidc-login) library in order to provide access and can be configured via: 15 | 16 | OIDC configuration: 17 | --oidc.enable Enable OIDC protection [$OIDC_ENABLE] 18 | --oidc.client-id= OIDC client ID [$OIDC_CLIENT_ID] 19 | --oidc.client-secret= OIDC client secret [$OIDC_CLIENT_SECRET] 20 | --oidc.issuer= Issuer URL (without .well-known) [$OIDC_ISSUER] 21 | --oidc.redis-url= Optional Redis URL for sessions. If not set - in-memory will be used [$OIDC_REDIS_URL] 22 | --oidc.redis-idle= Redis maximum number of idle connections (default: 1) [$OIDC_REDIS_IDLE] 23 | --oidc.redis-max-connections= Redis maximum number of active connections (default: 10) [$OIDC_REDIS_MAX_CONNECTIONS] 24 | 25 | Application Options: 26 | --server-url= Server public URL. Used for OIDC redirects. If not set - it will try to deduct [$SERVER_URL] 27 | 28 | Required: 29 | 30 | - `oidc.enable` should be set to `true` 31 | - `oidc.client-id`, `oidc.client-secret` should be set to credentials from OIDC provider (also known as "private" mode) 32 | - `oidc.issuer` url for the issuer without `.well-known` path. 33 | 34 | Recommended: 35 | 36 | - `server-url` to avoid any problems with callback urls 37 | - `oidc.redis-url` to share sessions between instances 38 | 39 | **Example configuration:** 40 | 41 | ``` 42 | OIDC_ENABLE=true 43 | OIDC_CLIENT_ID=my-super-forms 44 | OIDC_CLIENT_SECRET=M0QmHyHUqt1z17L8XwYiQeah9l1U7zNh 45 | OIDC_ISSUER=https://auth.example.com/realms/reddec 46 | ``` 47 | 48 | ### Access control 49 | 50 | > since: 0.2.0 51 | 52 | Each form has optional field `policy` which is a [CEL](https://github.com/google/cel-spec/blob/master/doc/intro.md) 53 | expression which can be used to define policy of who can access the form. 54 | 55 | - If OIDC is not set there are no restrictions (`policy` effectively is ignored) 56 | - If `policy` absent, it is ignored - all authorized users has access to the form 57 | - If `policy` returns `false` or non-convertable to boolean value - users will not be allow access the form 58 | 59 | The restriction is also applied for listing - users will list of only allowed forms. 60 | 61 | Allowed variables in CEL expression: 62 | 63 | - `user` (string) user name 64 | - `groups` ([]string) slice of user's groups, can be empty/nil 65 | - `email` (string) user email, can be empty 66 | 67 | `user` is picked from the claims by the following priority (first non-empty): 68 | 69 | 1. `preferred_username` 70 | 2. `email` 71 | 3. `sub` (subject) 72 | 73 | ### Examples 74 | 75 | **Group-based access**: 76 | 77 | Allow access only for `admin` group 78 | 79 | ```yaml 80 | policy: '"admin" in groups' 81 | ``` 82 | 83 | Allow access only for `admin` or `sysadmin` groups 84 | 85 | ```yaml 86 | policy: '"admin" in groups || "sysadmin" in groups' 87 | ``` 88 | 89 | **Domain-based access**: 90 | 91 | Allow access only for users with email domain `reddec.net` 92 | 93 | ```yaml 94 | policy: 'email.endsWith("@reddec.net")' 95 | ``` 96 | 97 | **Per-user access**: 98 | 99 | Allow access only for user `reddec` 100 | 101 | ```yaml 102 | policy: 'user == "reddec"' 103 | ``` 104 | 105 | Allow access only for user `reddec` or `admin` 106 | 107 | ```yaml 108 | policy: 'user == "reddec" || user == "admin"' 109 | ``` 110 | 111 | ## Codes 112 | 113 | > since: 0.4.0 114 | 115 | Access control with codes allows you to define one or more codes for each form. Users attempting to access a form must 116 | provide a valid code. If no codes are set or an empty array is defined, access restrictions via codes are disabled, 117 | granting open access to the form. 118 | 119 | This access control mechanism is particularly valuable in the following scenarios: 120 | 121 | - **Public Forms:** Use access codes for public forms to restrict access to users who have the correct code. For 122 | instance, you can protect a public event registration form to ensure that only registered attendees can submit their 123 | information. Note: in this case you may want to disable forms listing (see [configuration](configuration.md)). 124 | 125 | - **Internal Separation:** When OIDC group-based restrictions are not applicable or practical, access 126 | codes offer an alternative method for separating internal users' access. You can use access codes to distinguish 127 | between different departments or roles within your organization. 128 | 129 | To get started with access control using codes for your forms, follow these steps: 130 | 131 | 1. Generate random access code. For example by `pwgen -s 16 1` 132 | 2. Define access codes for the forms where you want to enforce access restrictions in `codes` field. 133 | 134 | User-provided code is available via `.Code` parameter in [template context](template.md#context-for-defaults). 135 | 136 | ### Examples 137 | 138 | ```yaml 139 | codes: 140 | - let-me-in 141 | - my-great-company 142 | ``` -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Here is general server configuration. 4 | 5 | - for fields configuration see additional [document](./fields.md) 6 | - for storage configuration see [stores](./stores.md) 7 | - for OIDC configuration see [authorization](./authorization.md) 8 | 9 | Be aware that CLI defaults and [docker](docker.md) defaults may be different. 10 | 11 | All configuration parameters can be set via environment variables (`$VARNAME`) or via command line arguments. In 12 | documentation most of the time then environment variables will be used for example as a recommended way to configure 13 | application. 14 | 15 | There are **no required** fields, default values will let application start normally. 16 | 17 | **usage** 18 | 19 | ``` 20 | Application Options: 21 | --configs= File or directory with YAML configurations (default: configs) [$CONFIGS] 22 | --storage=[database|files] Storage type (default: database) [$STORAGE] 23 | --server-url= Server public URL. Used for OIDC redirects. If not set - it will try to deduct [$SERVER_URL] 24 | --disable-listing Disable listing in UI [$DISABLE_LISTING] 25 | 26 | Database storage: 27 | --db.dialect=[postgres|sqlite3] SQL dialect (default: sqlite3) [$DB_DIALECT] 28 | --db.url= Database URL (default: file://form.sqlite) [$DB_URL] 29 | --db.migrations= Migrations dir (default: migrations) [$DB_MIGRATIONS] 30 | --db.migrate Apply migration on start [$DB_MIGRATE] 31 | 32 | Files storage: 33 | --files.path= Root dir for form results (default: results) [$FILES_PATH] 34 | 35 | Webhooks general configuration: 36 | --webhooks.buffer= Internal queue size before processing (default: 100) [$WEBHOOKS_BUFFER] 37 | 38 | AMQP configuration: 39 | --amqp.url= AMQP broker URL (default: amqp://guest:guest@localhost) [$AMQP_URL] 40 | --amqp.buffer= Internal queue size before processing (default: 100) [$AMQP_BUFFER] 41 | --amqp.workers= Number of parallel publishers (default: 4) [$AMQP_WORKERS] 42 | 43 | HTTP server configuration: 44 | --http.bind= Binding address (default: :8080) [$HTTP_BIND] 45 | --http.disable-xsrf Disable XSRF validation. Useful for API [$HTTP_DISABLE_XSRF] 46 | --http.tls Enable TLS [$HTTP_TLS] 47 | --http.key= Private TLS key (default: server.key) [$HTTP_KEY] 48 | --http.cert= Public TLS certificate (default: server.crt) [$HTTP_CERT] 49 | --http.read-timeout= Read timeout to prevent slow client attack (default: 5s) [$HTTP_READ_TIMEOUT] 50 | --http.write-timeout= Write timeout to prevent slow consuming clients attack (default: 5s) [$HTTP_WRITE_TIMEOUT] 51 | --http.assets= Directory for assets (static) files [$HTTP_ASSETS] 52 | 53 | OIDC configuration: 54 | --oidc.enable Enable OIDC protection [$OIDC_ENABLE] 55 | --oidc.client-id= OIDC client ID [$OIDC_CLIENT_ID] 56 | --oidc.client-secret= OIDC client secret [$OIDC_CLIENT_SECRET] 57 | --oidc.issuer= Issuer URL (without .well-known) [$OIDC_ISSUER] 58 | --oidc.redis-url= Optional Redis URL for sessions. If not set - in-memory will be used [$OIDC_REDIS_URL] 59 | --oidc.redis-idle= Redis maximum number of idle connections (default: 1) [$OIDC_REDIS_IDLE] 60 | --oidc.redis-max-connections= Redis maximum number of active connections (default: 10) [$OIDC_REDIS_MAX_CONNECTIONS] 61 | 62 | Cloudflare Turnstile: 63 | --captcha.turnstile.site-key= Widget access key [$CAPTCHA_TURNSTILE_SITE_KEY] 64 | --captcha.turnstile.secret-key= Server side secret key [$CAPTCHA_TURNSTILE_SECRET_KEY] 65 | --captcha.turnstile.timeout= Validation request timeout (default: 3s) [$CAPTCHA_TURNSTILE_TIMEOUT] 66 | ``` 67 | 68 | - By-default, by the root path `/` listing of all forms available. It can be disabled by `DISABLE_LISTING=true` 69 | 70 | ## Captcha 71 | 72 | *since 0.4.0* 73 | 74 | WebForms offers CAPTCHA functionality for POST requests, which serves as a mechanism for verifying the 75 | authenticity of incoming requests during both form submission and access code submission processes. To enable CAPTCHA, 76 | JavaScript on the client side is a prerequisite. 77 | 78 | Currently only [Cloudflare Turnstile](https://www.cloudflare.com/products/turnstile/) captcha is supported. 79 | 80 | ## HTTP and TLS 81 | 82 | Service supports HTTPS but doesn't support dynamic reload. If you are using short-lived certificates such as Let's 83 | Encrypt it could be necessary to restart application after renewal. 84 | 85 | **Example configuration** 86 | 87 | ``` 88 | HTTP_TLS=yes 89 | HTTP_KEY=/path/to/key.pem 90 | HTTP_CERT=/path/to/cert.pem 91 | ``` 92 | 93 | ### Assets 94 | 95 | since 0.2.0 96 | 97 | WebForms can handle user-defined static files (assets) via `/assets/` path if `--http.assets` flag is defined. 98 | By-default, it's disabled in CLI mode and enabled in [docker](docker.md) mode. 99 | 100 | It could be useful for embedding images or other things in templates. For example: 101 | 102 | ```yaml 103 | --- 104 | table: shop 105 | title: Order Pizza 106 | description: | 107 | Welcome to our pizzeria! 108 | 109 | ![](/assets/logo.png) 110 | 111 | # other configuration 112 | ``` 113 | 114 | ### Security 115 | 116 | The service has incorporated built-in protection 117 | against [CSRF](https://en.wikipedia.org/wiki/Cross-site_request_forgery) by employing an HTTP-only cookie and a 118 | concealed input (Double Submit Cookie) mechanism. However, if the service is to be utilized programmatically, such as 119 | through an API, CSRF validation may pose potential issues. To disable this validation, you can 120 | set `HTTP_DISABLE_XSRF=true`, but exercise caution and ensure a thorough understanding of the 121 | associated [risks](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html) 122 | before proceeding. 123 | 124 | The service configures protective headers to thwart clickjacking, provide XSS protection, and enforce a strict referer 125 | policy. For now, this protection can not be disabled. You can find the complete list of headers below: 126 | 127 | | Header | Value | 128 | |--------------------------|-----------------------------------| 129 | | `X-Frame-Options` | `DENY` | 130 | | `X-XSS-Protection` | `1` | 131 | | `X-Content-Type-Options` | `nosniff` | 132 | | `Referrer-Policy` | `strict-origin-when-cross-origin` | 133 | 134 | ## Production checklist 135 | 136 | The recommended configuration checklist for the production: 137 | 138 | - [ ] put service behind reverse-proxy with TLS (nginx, caddy, haproxy, etc..) 139 | - [ ] secure connection between reverse-proxy and service via internal certificates (overkill for small/medium setups) 140 | - [ ] set public server URL (`--server-url/$SERVER_URL`) 141 | - [ ] use [postgres storage](./stores.md#postgres) with TLS 142 | - [ ] use [OIDC](./authorization.md) with Redis for session storage 143 | - [ ] use non-privileged user for service/container (non-root is supported natively) 144 | - [ ] use specific version of container and reference it by digest 145 | - [ ] set correct permissions for directories: 146 | - [ ] read only for configs (`/configs` by default for container) 147 | - [ ] read only for migrations (if applicable) (`/migrations` by default for container) 148 | - [ ] read only for TLS files 149 | - [ ] for data directory (`/data` by default for container) 150 | - write-only for `files` 151 | - read-write for `sqlite3` 152 | - no access needed for `postgres` 153 | 154 | For extra safety (overkill for small/medium setups) migrations for database can be done outside of the service and 155 | therefore 156 | minimal possible (`INSERT` only for specific tables) permission can be issued. -------------------------------------------------------------------------------- /docs/docker.md: -------------------------------------------------------------------------------- 1 | # Docker 2 | 3 | By default, in docker mode: 4 | 5 | - database migrations are enabled and expected in `/migrations` dir 6 | - storage type is `files` and storage path is `/data` 7 | - configurations should be mounted to `/configs` 8 | - [assets](configuration.md#assets) files served from `/assets` dir (since 0.2.0) 9 | 10 | If storage is `database` and migration directory (`DB_MIGRATIONS`, default is `/migrations`) contains at least one 11 | SQL file (`.sql`) then migration will be applied automatically 12 | using [sql-migrate](https://github.com/rubenv/sql-migrate). 13 | 14 | 15 | For example 16 | 17 | docker run -v $(pwd):/migrations:ro -e STORAGE=database -e DB_URL=:memory: -e DB_DIALECT=sqlite3 ghcr.io/reddec/web-form:latest 18 | 19 | It also simplifies Kubernetes deployment, since you can use config maps for migrations and mount them as a volume. -------------------------------------------------------------------------------- /docs/fields.md: -------------------------------------------------------------------------------- 1 | # Fields 2 | 3 | 4 | 5 | Only field `name` is required for the field. 6 | 7 | For all fields text values will be trimmed from leading and trailing white spaces. 8 | 9 | Configurations: 10 | 11 | | Name | Type | Default | Description | 12 | |---------------|---------------------|----------|--------------------------------------------------------------------------------------------| 13 | | *`name`* | string | | column name in database and unique identifier of the field | 14 | | `label` | string | `.name` | name of field if UI | 15 | | `description` | string | | short description for the field, will be shown in UI as hint | 16 | | `required` | boolean | false | is field required | 17 | | `disabled` | boolean | false | is user allowed to edit field | 18 | | `hidden` | boolean | false | do not show field in UI. Implicitly disables field | 19 | | `default` | string | | default value for the field. Supports [template](template.md) | 20 | | `type` | [type](#types) | `string` | field type | 21 | | `pattern` | string | | validate user input by regular expression (only for `string` types) | 22 | | `options` | [][Option](#option) | | enum of allowed values | 23 | | `multiple` | boolean | false | allow multiple options | 24 | | `multiline` | boolean | false | tell UI to show multi-line input. Has no effect for backend | 25 | | `icon` | string | | (0.2.0+) icon name, currently supported only [MDI](https://pictogrammers.com/library/mdi/) | 26 | 27 | Notes: 28 | 29 | - `required` fields can not be empty 30 | - `default` field can be used for [prefill](prefill.md) 31 | - `description` supports markdown and supports [templating](template.md#context-for-defaults) 32 | - if `options` is set then: 33 | - with `multiple: true` it acts as "any of" (multiple choice) 34 | - otherwise it acts "one of" (single pick) 35 | - if `multiple` is true, depending on [storage](stores.md), information will be stored as array or as plain string 36 | - `icon` should contain type (`mdi`) and icon name. For example: `mdi mdi-cake` 37 | 38 | The system has minimal trust to user input therefore: 39 | 40 | - `hidden` or `disabled` fields are ignored even if it was provided in POST request 41 | - `type` and `pattern` verification will be additionally checked on backend side 42 | 43 | **Examples** 44 | 45 | ```yaml 46 | - name: delivery_date 47 | label: When to deliver 48 | default: '{{now | date "2006-01-02T15:04"}}' 49 | required: true 50 | type: date-time 51 | 52 | 53 | - name: birthday 54 | label: Your birthday 55 | default: '{{now | date "2006-01-02"}}' 56 | description: We will give you a discount 57 | type: date 58 | icon: "mdi mdi-cake" 59 | 60 | - name: client_id 61 | label: Customer 62 | default: '{{.User}}' # from OIDC 63 | required: true 64 | disabled: true 65 | 66 | - name: dough 67 | label: Dough kind 68 | default: "thin" 69 | options: 70 | - label: Hand made 71 | value: hand-made 72 | - label: Thin crust 73 | value: thin 74 | 75 | - name: cheese 76 | label: Pick cheese 77 | required: true 78 | multiple: true 79 | options: 80 | - label: Italian Mozzarella 81 | value: mozzarella 82 | - label: Spanish Cheddar 83 | value: cheddar 84 | - label: Something Else 85 | value: something 86 | 87 | - name: phone 88 | label: Phone number 89 | required: true 90 | description: Please use real phone number - we will contact you 91 | ``` 92 | 93 | ## Types 94 | 95 | | Type | Format | Example | 96 | |-----------|--------------------|------------------| 97 | | string | any string | foo bar baz | 98 | | integer | number | 1234 | 99 | | float | number with dot | 12345.789 | 100 | | boolean | `true/false` | false | 101 | | date | `YYYY-MM-DD` | 2023-01-30 | 102 | | date-time | `YYYY-MM-DDTHH:mm` | 2023-01-30T16:05 | 103 | 104 | Notes: 105 | 106 | - `boolean` requires [string representation](https://pkg.go.dev/strconv#ParseBool) of true/false 107 | - time/date handling in Go is quite [special](https://pkg.go.dev/time#pkg-constants): 108 | - use `2006-01-02` for date 109 | - use `2006-01-02T15:04` for date-time 110 | 111 | ## Option 112 | 113 | | Name | Type | Default | Description | 114 | |-----------|--------|---------|-----------------------------------------------------| 115 | | *`label`* | string | | UI visible label for the option | 116 | | `value` | string | | Value for the option which will be saved in storage | 117 | 118 | ```yaml 119 | - name: dough 120 | label: Dough kind 121 | default: "thin" 122 | options: 123 | - label: Hand made 124 | value: hand-made 125 | - label: Thin crust 126 | value: thin 127 | ``` 128 | 129 | -------------------------------------------------------------------------------- /docs/form.md: -------------------------------------------------------------------------------- 1 | # Form 2 | 3 | 4 | 5 | Form is yaml document stored under `configs` directory (see [configuration](./configuration.md)). 6 | One file may contain multiple form definitions (multi-document YAML); however, in that case `name` should be 7 | explicitly set since it's inferred from file name (without extension) and must be unique. 8 | 9 | Supported extensions: `.yaml`, `.yml`, `.json`. 10 | 11 | The only "required" field is `table` which defines table name for database [storage](stores.md) or directory for `files` 12 | mode. If `table` is not defined, `name` will be used as table. 13 | 14 | See [examples](https://github.com/reddec/web-form/tree/master/examples) for inspirations. 15 | 16 | | Field | Type | Description | 17 | |---------------|----------------------------------------|------------------------------------------------------------------------------------------------| 18 | | `name` | string | unique form name, if not set - file name without extension will be used | 19 | | `table` | string | database table name (database mode), or directory name (files mode) | 20 | | `title` | string | short form title/name | 21 | | `description` | string | **markdown + [template](template.md)** description of the form | 22 | | `fields` | [][Field](fields.md) | list of fields definitions | 23 | | `webhooks` | [][Webhook](notifications.md#webhooks) | list of webhooks | 24 | | `amqp` | [][AMQP](notifications.md#amqp) | list of AMQP notifications | 25 | | `success` | string | **markdown + [template](template.md)** message to show in case submission was successful | 26 | | `failed` | string | **markdown + [template](template.md)** message to show in case submission failed | 27 | | `policy` | string | optional policy expression (OIDC only) - see details [here](./authorization.md#access-control) | 28 | 29 | Default message for `success`: 30 | 31 | Thank you for the submission! 32 | 33 | Default message for `faield`: 34 | 35 | Something went wrong: `{{.Error}}` 36 | 37 | **Comprehensive example:** 38 | 39 | ```yaml 40 | --- 41 | table: shop 42 | title: Order Pizza 43 | description: | 44 | Welcome {{.User}}! 45 | 46 | Order HOT pizza RIGHT NOW and get 47 | **huge** discount! 48 | 49 | _T&C_ can be applied 50 | fields: 51 | - name: delivery_date 52 | label: When to deliver 53 | default: '{{now | date "2006-01-02T15:04"}}' 54 | required: true 55 | type: date-time 56 | 57 | 58 | - name: birthday 59 | label: Your birthday 60 | default: '{{now | date "2006-01-02"}}' 61 | description: We will give you a discount 62 | type: date 63 | 64 | - name: client_id 65 | label: Customer 66 | default: '{{.User}}' # from OIDC 67 | required: true 68 | disabled: true 69 | 70 | - name: dough 71 | label: Dough kind 72 | default: "thin" 73 | options: 74 | - label: Hand made 75 | value: hand-made 76 | - label: Thin crust 77 | value: thin 78 | 79 | - name: cheese 80 | label: Pick cheese 81 | required: true 82 | multiple: true 83 | options: 84 | - label: Italian Mozzarella 85 | value: mozzarella 86 | - label: Spanish Cheddar 87 | value: cheddar 88 | - label: Something Else 89 | value: something 90 | 91 | - name: phone 92 | label: Phone number 93 | required: true 94 | description: Please use real phone number - we will contact you 95 | 96 | - name: email 97 | label: EMail 98 | pattern: '[^@]+@[^@]+' 99 | default: "{{.Email}}" # from OIDC 100 | required: true 101 | 102 | - name: notify_sms 103 | label: Notify by SMS 104 | type: boolean 105 | 106 | - name: zip 107 | label: ZIP code 108 | required: true 109 | type: integer 110 | 111 | - name: address 112 | label: Full address 113 | required: true 114 | multiline: true 115 | 116 | success: | 117 | ## Thank you! 118 | 119 | Your order {{or .Result.ID .Result.id}} is on the way 120 | 121 | failed: | 122 | ## Sorry! 123 | 124 | Something went wrong. Please contact our support and tell them the following message: 125 | 126 | {{.Error}} 127 | 128 | 129 | webhooks: 130 | - url: https://example.com/new-pizza 131 | name: order 132 | retry: 3 133 | interval: 10s 134 | timeout: 30s 135 | method: PUT 136 | 137 | - url: https://example.com/notify-to-telegram 138 | message: | 139 | #{{ .Result.ID }} New pizza ordered. 140 | 141 | amqp: 142 | - key: "form.shop.submission" 143 | ``` 144 | 145 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Web Forms 2 | 3 | WebForms is a versatile tool with a focus on DevOps compatibility, designed for creating HTML UI forms 4 | with [backends](./stores.md) that can be hosted in PostgreSQL, SQLite3, or plain JSON files. The service offers the 5 | flexibility to reuse existing tables and database structures and includes automated embedded schema migration. 6 | 7 | The user interface (UI) is designed to be lightweight and mobile-friendly, and it can function without JavaScript, 8 | although there may be some limitations in timezone detection. The service also includes various security features. 9 | Additionally, there is an option to disable the UI for browsing available forms. The service also 10 | offers [OIDC](authorization.md) integration for authorization, with the ability to use OIDC claims, such as usernames, 11 | in templating and default values. 12 | 13 | WebForms allows for the sending of multiple notifications (ex: [WebHooks](notifications.md#webhooks) 14 | or [AMQP](notifications.md#amqp)) after form submissions to facilitate integration with external systems. It provides a 15 | configurable retry strategy for reliability. 16 | 17 | Flexible [templating](template.md) enables the prefilling of fields and the generation of personalized greeting 18 | messages. 19 | 20 | Initial setup of the service is straightforward and requires minimal backend configuration and form definition. However, 21 | for those who require more customization, almost every aspect of the service can be [configured](configuration.md). It 22 | can be used in a stateless manner and is scalable. Refer to 23 | the [production checklist](configuration.md#production-checklist) for further details. 24 | 25 | WebForms is available in various formats, including [source code](https://github.com/reddec/web-form), pre-compiled 26 | [binaries](https://github.com/reddec/web-form/releases/latest) for major platforms, 27 | and [containers](https://github.com/reddec/web-form/pkgs/container/web-form) for both AMD and ARM 28 | architectures. 29 | 30 | The project is licensed under MPL-2.0 (Exhibit A), which allows for commercial usage with minimal restrictions, provided 31 | that any changes made are shared with the community. This promotes collaboration and community involvement. 32 | 33 | ## Installation 34 | 35 | - From source code using go 1.21+ `go install github.com/reddec/web-form/cmd/...@latest` 36 | - From [binaries](https://github.com/reddec/web-form/releases/latest) 37 | - From [containers](https://github.com/reddec/web-form/pkgs/container/web-form) - see [docker](./docker.md) 38 | 39 | ## Quick start 40 | 41 | Let's imagine situation when you are going to collect opinions about which pizzas to order into the office. 42 | 43 | Create file `order-pizza.yaml` in directory `configs` with the following content: 44 | 45 | ```yaml 46 | title: Let's order pizza! 47 | table: pizza 48 | description: | 49 | Dear colleagues, I'm going to order some pizza this Friday. 50 | 51 | Please, write your preferences bellow. 52 | fields: 53 | - name: employee 54 | label: Your name 55 | required: true 56 | - name: pizza_kind 57 | label: Which pizza do you want? 58 | required: true 59 | options: 60 | - label: Hawaii 61 | - label: 4-cheese 62 | - label: Meaty 63 | - name: extra_cheese 64 | label: Do you want extra cheese? 65 | type: boolean 66 | default: "true" 67 | ``` 68 | 69 | Create directory `data`. 70 | 71 | Run docker container 72 | 73 | docker run --rm -v $(pwd)/configs:/configs:ro -v $(pwd)/data:/data -p 8080:8080 ghcr.io/reddec/web-form:latest 74 | 75 | Open in you browser http://localhost:8080 and click to the form (direct link will 76 | be http://localhost:8080/forms/order-pizza). 77 | 78 | Once submitted - check `data` directory. It will contain your result. Of course, the main power comes with 79 | proper [configuration](configuration.md). 80 | 81 | ![image](https://github.com/reddec/web-form/assets/6597086/b4dce0e1-30cf-492d-96a4-dbcc98eb787d) 82 | 83 | ## Examples 84 | 85 | Check examples in corresponding [directory](https://github.com/reddec/web-form/tree/master/examples). 86 | 87 | ## Next steps 88 | 89 | - Read about [form](form.md) definitions 90 | - And about [configuration](configuration.md) -------------------------------------------------------------------------------- /docs/notifications.md: -------------------------------------------------------------------------------- 1 | # Notifications 2 | 3 | 4 | 5 | WebForm provides seamless integration with your business logic through notifications, which are triggered after form 6 | submission. You can easily integrate almost any self-hosted solution with WebForms. Additionally, cloud-based solutions 7 | like [Workato](https://www.workato.com/) or [IFTTT](https://ifttt.com/) can be integrated using webhooks or direct API 8 | calls. 9 | 10 | There is no fixed order for handling notifications. All notifications are dispatched in parallel. However, depending on 11 | the notification sub-system, some messages may be sent in a semi-sequential manner. 12 | 13 | By default, all notification sub-systems come with reasonable retry and timeout settings. To avoid blocking form 14 | submissions, all notifications are first enqueued in an internal in-memory queue and then processed by the notification 15 | sub-system. You can configure the size of the queue for each sub-system using the `buffer` parameter. If the queue 16 | becomes full, further submissions will be blocked. 17 | 18 | ## Webhooks 19 | 20 | For each form submission an HTTP sub-request from the server can be performed to any other resource (webhook). 21 | 22 | The HTTP `method` is by default `POST`, `headers` has no predefined values. If `message` is not set, payload is JSON 23 | representation of storage result (effectively newly created object), otherwise it is interpreted 24 | as [template](template.md#context-for-notifications). 25 | 26 | There is no limits for reasonable number of webhooks and number is limited only by server resources (CPU mostly). 27 | 28 | The server will retry delivery up to `retry` times (default is 3), with constant `interval` between attempts (default is 29 | 10 seconds) until remote resource will return 2xx (200, 201, ..., 299) code. A webhook request duration is limited 30 | to `timeout` per attempt (default 30 seconds). 31 | 32 | Delivery made in non-blocking semi-parallel way, after saving information to the storage, and only in case of success. 33 | 34 | The minimal definition is `url` only: 35 | 36 | ```yaml 37 | webhooks: 38 | - url: https://example.com/new-pizza 39 | ``` 40 | 41 | ### Type 42 | 43 | | Field | Type | Default | Description | 44 | |------------|---------------------------------------------------|---------|----------------------------------------------------------------------| 45 | | **`url`** | string | | WebHook HTTP(s) URL. Required | 46 | | `method` | string | POST | HTTP method (GET, POST, PUT, etc...) | 47 | | `retry` | int | 3 | Maximum number of retries | 48 | | `timeout` | [Duration](https://pkg.go.dev/time#ParseDuration) | 10s | Request timeout | 49 | | `interval` | [Duration](https://pkg.go.dev/time#ParseDuration) | 15s | Interval between retries | 50 | | `headers` | map[string]string | | Any additional headers, for example `Authorization` | 51 | | `message` | string | | [template](template.md#context-for-notifications for message payload | 52 | 53 | Notes: 54 | 55 | - negative `retry` disables retries 56 | - empty `message` means JSON representation of the result returned by storage 57 | 58 | Updates: 59 | 60 | - `method` supported since 0.2.0 61 | 62 | The full definition is: 63 | 64 | ```yaml 65 | webhooks: 66 | - url: https://example.com/new-pizza 67 | retry: 3 68 | interval: 10s 69 | timeout: 30s 70 | method: POST 71 | message: | 72 | New pizza order #{{ .Result.ID }}. 73 | ``` 74 | 75 | ## AMQP 76 | 77 | *since 0.3.0* 78 | 79 | For a more robust notification solution, consider using a message broker. Currently, only AMQP 0.9.1 brokers are 80 | supported. RabbitMQ is the officially tested option, but other brokers should work seamlessly, as there are no specific 81 | dependencies tied to RabbitMQ. 82 | 83 | AMQP notifications typically provide at-least-once delivery guarantees. Therefore, it's advisable to implement 84 | client-side deduplication using attributes like message ID. In practice, duplicate messages may arise only in case of 85 | network delays. 86 | 87 | Connections to the brokers are established in a lazy manner. You can configure the maximum number of parallel 88 | submissions using the `workers` parameter (see [configuration](configuration.md#configuration)). WebForms will 89 | automatically reconnect to the broker in case of any issues. 90 | WebForm doesn't handle the definition of AMQP objects such as exchanges, queues, or bindings. 91 | This responsibility lies with the user. 92 | 93 | The minimal definition is `key` only: 94 | 95 | ```yaml 96 | amqp: 97 | - key: "events.form-submission" 98 | ``` 99 | 100 | ### Global configuration 101 | 102 | ``` 103 | AMQP configuration: 104 | --amqp.url= AMQP broker URL (default: amqp://guest:guest@localhost) [$AMQP_URL] 105 | --amqp.buffer= Internal queue size before processing (default: 100) [$AMQP_BUFFER] 106 | --amqp.workers= Number of parallel publishers (default: 4) [$AMQP_WORKERS] 107 | ``` 108 | 109 | ### Type 110 | 111 | | Field | Type | Default | Description | 112 | |---------------|---------------------------------------------------|---------|-----------------------------------------------------------------------| 113 | | **`key`** | string | | [template](template.md#context-for-notifications) for routing key | 114 | | `exchange` | string | | exchange name | 115 | | `type` | string | | content type property (see notes) | 116 | | `id` | string | | [template](template.md#context-for-notifications) for message ID | 117 | | `correlation` | string | | [template](template.md#context-for-notifications) for correlation ID | 118 | | `retry` | int | 3 | Maximum number of retries to publish message | 119 | | `timeout` | [Duration](https://pkg.go.dev/time#ParseDuration) | 10s | Publish timeout | 120 | | `interval` | [Duration](https://pkg.go.dev/time#ParseDuration) | 15s | Interval between retries | 121 | | `headers` | map[string]string | | Any additional headers, for example `Authorization` | 122 | | `message` | string | | [template](template.md#context-for-notifications) for message payload | 123 | 124 | - negative `retry` disables retries 125 | - empty `message` means JSON representation of the result returned by storage 126 | - if `type` and `message` are not specified, `type` will be set to `application/json` 127 | - in RabbitMQ, if `exchange` is not set, `key` acts as queue name 128 | 129 | > Due to AMQP protocol specification content type in header is not the same as `type` (which is mapped to ContentType) 130 | > property in message. 131 | 132 | -------------------------------------------------------------------------------- /docs/prefill.md: -------------------------------------------------------------------------------- 1 | # Prefill 2 | 3 | Prefill, or default values can be set via `default` section in yaml. 4 | 5 | For example, if you want to prefill field from query param: 6 | 7 | 8 | ```yaml 9 | - name: email 10 | label: EMail 11 | default: '{{.Query.Get "email" }}' 12 | ``` 13 | 14 | Then you can pre-fill email like: 15 | 16 | https://my-site/forms/my-form?email=foo@bar.baz 17 | 18 | Supports all type except `multiple: true` (arrays). 19 | 20 | -------------------------------------------------------------------------------- /docs/stores.md: -------------------------------------------------------------------------------- 1 | # Stores 2 | 3 | MySQL not supported due to: no `RETURNING` clause, different escape approaches and other similar uncommon behaviour. 4 | 5 | Storage can be picked by 6 | 7 | --storage=[database|files|dump] Storage type (default: database) [$STORAGE] 8 | 9 | ## Database 10 | 11 | Requires 12 | 13 | --db.dialect=[postgres|sqlite3] SQL dialect (default: sqlite3) [$DB_DIALECT] 14 | --db.url= Database URL (default: file://form.sqlite) [$DB_URL] 15 | 16 | Optional 17 | 18 | --db.migrations= Migrations dir (default: migrations) [$DB_MIGRATIONS] 19 | --db.migrate Apply migration on start [$DB_MIGRATE] 20 | 21 | By default, migration is disabled in CLI mode and enabled in [Docker](docker.md) mode. 22 | 23 | ### Postgres 24 | 25 | Supported all major types and arrays of text (`TEXT[]`). 26 | 27 | Enums are not supported. 28 | 29 | **Example environment:** 30 | 31 | ``` 32 | STORAGE=database 33 | DB_DIALECT=postgres 34 | DB_URL=posgres://postgres:postgres@localhost/postgres?sslmod=disable 35 | ``` 36 | 37 | > Postgres by default converts name text case of column names from `CREATE TABLE` statement to lower. 38 | > 39 | > For example: `CREATE TABLE foo (ID INT)` will cause `id` in returned data. 40 | 41 | ### SQLite 42 | 43 | Since SQLite doesn't support arrays, multiselect will be saved as text where selected options merged by `,`. For 44 | example: if user selected option `Foo` and `Bar`, then `Foo,Bar` will be stored in the database. 45 | 46 | **Example environment:** 47 | 48 | ``` 49 | STORAGE=database 50 | DB_DIALECT=sqlite3 51 | DB_URL=file://forms.sqlite 52 | ``` 53 | 54 | > SQLite keeps case of column names from `CREATE TABLE` statement. 55 | > 56 | > For example: `CREATE TABLE foo (ID INT)` will cause `ID` in returned data. 57 | 58 | ## Files 59 | 60 | Stores each submission as single file in JSON with [ULID](https://github.com/ulid/spec) + `.json` in the directory, 61 | equal to table name. ULID is picked since it's uniq as UUID, but allows sorting by time. 62 | 63 | It uses atomic write (temp + rename) and should be safe for multiprocess writing; however, in case of unexpected 64 | termination, the temporary files may stay in file system. 65 | 66 | It **DOES NOT** escape table name AT ALL; it's up to user to take care of proper table name. 67 | 68 | Result-set contains all source fields plus `ID` (string). 69 | 70 | Requires: 71 | 72 | --files.path= Root dir for form results (default: results) [$FILES_PATH] 73 | 74 | **Example environment:** 75 | 76 | ``` 77 | STORAGE=files 78 | FILES_PATH=results 79 | ``` 80 | 81 | ## Dump 82 | 83 | > since 0.4.1 84 | 85 | Dumps record to STDOUT. Used for debugging or database-less forms. -------------------------------------------------------------------------------- /docs/template.md: -------------------------------------------------------------------------------- 1 | # Templates 2 | 3 | 4 | 5 | For `default` values, for result messages, for description 6 | the [Go template](https://pkg.go.dev/text/template#hdr-Actions) can be used. 7 | 8 | In addition to standard library, the following extra additions are avaialable: 9 | 10 | - everything from [sprig](http://masterminds.github.io/sprig/) 11 | - `markdown` filter which converts any text to markdown using GFM syntax 12 | - `html` filter which marks any text as safe HTML (danger!) 13 | - `timezone` filter which returns name of current timezone, commonly it's just `Local` though not very useful. 14 | 15 | The result of evaluation should match [type](fields.md#types) of field. 16 | 17 | ## Context for defaults 18 | 19 | Context variables accessible as `{{$.}}`. 20 | For example `{{$.User}}` will return username or empty string. 21 | 22 | > Note: if you advance user of Go templates, then you know when `$` can be omitted, 23 | > otherwise it's safe to use it always. 24 | 25 | | Name | Type | Description | 26 | |-----------|-------------------------------------------------|----------------------------------------------------------------------------------| 27 | | `Headers` | [url.Values](https://pkg.go.dev/net/url#Values) | Access to raw request headers | 28 | | `Query` | [url.Values](https://pkg.go.dev/net/url#Values) | Access to raw request URL query params | 29 | | `Form` | [url.Values](https://pkg.go.dev/net/url#Values) | Access to raw request form values | 30 | | `User` | string | (optional) username from OIDC claims | 31 | | `Groups` | []string | (optional) list of user groups from OIDC claims | 32 | | `Email` | string | (optional) user email from OIDC claims | 33 | | `Code` | string | (optional) [access code](authorization.md#codes) used by user to access the form | 34 | 35 | ## Context for notifications 36 | 37 | | Name | Type | Description | 38 | |----------|-------------------------------------------------|------------------------| 39 | | `Form` | [form definititon](../internal/schema/types.go) | Parsed form definition | 40 | | `Result` | `map[string]any` | Result from storage | 41 | 42 | ## Context for result 43 | 44 | | Name | Type | Description | 45 | |----------|-------------------------------------------------|------------------------------| 46 | | `Form` | [form definititon](../internal/schema/types.go) | Parsed form definition | 47 | | `Result` | `map[string]any` | Optional result from storage | 48 | | `Error` | `error` | Optional error | 49 | 50 | If `.Error` is defined, then `.Result` is `nil`. 51 | 52 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | Run examples by docker compose: 4 | 5 | docker compose up -------------------------------------------------------------------------------- /examples/assets/img/semweb.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /examples/configs/order-pizza.yaml: -------------------------------------------------------------------------------- 1 | title: Let's order pizza! 2 | table: pizza 3 | description: | 4 | Dear colleagues, I'm going to order some pizza this Friday. 5 | 6 | Please, write your preferences bellow. 7 | fields: 8 | - name: employee 9 | label: Your name 10 | required: true 11 | - name: pizza_kind 12 | label: Which pizza do you want? 13 | required: true 14 | options: 15 | - label: Hawaii 16 | - label: 4-cheese 17 | - label: Meaty 18 | - name: extra_cheese 19 | label: Do you want extra cheese? 20 | type: boolean 21 | default: "true" -------------------------------------------------------------------------------- /examples/configs/shop.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | table: shop 3 | title: Order Pizza 4 | description: | 5 | Welcome {{.User}}! 6 | 7 | Order HOT pizza RIGHT NOW and get 8 | **huge** discount! 9 | 10 | ![](/assets/img/semweb.svg) 11 | 12 | _T&C_ can be applied 13 | fields: 14 | - name: delivery_date 15 | label: When to deliver 16 | default: '{{now | date "2006-01-02T15:04"}}' 17 | required: true 18 | type: date-time 19 | icon: "mdi mdi-calendar-range" 20 | 21 | - name: birthday 22 | label: Your birthday 23 | default: '{{now | date "2006-01-02"}}' 24 | description: We will give you a discount 25 | type: date 26 | icon: "mdi mdi-cake" 27 | 28 | - name: client_id 29 | label: Customer 30 | default: '{{.User}}' # from OIDC 31 | required: true 32 | disabled: true 33 | icon: "mdi mdi-account" 34 | 35 | - name: dough 36 | label: Dough kind 37 | default: "thin" 38 | icon: "mdi mdi-bread-slice" 39 | options: 40 | - label: Hand made 41 | value: hand-made 42 | - label: Thin crust 43 | value: thin 44 | 45 | - name: cheese 46 | label: Pick cheese 47 | required: true 48 | multiple: true 49 | icon: mdi mdi-cheese 50 | options: 51 | - label: Italian Mozzarella 52 | value: mozzarella 53 | - label: Spanish Cheddar 54 | value: cheddar 55 | - label: Something Else 56 | value: something 57 | 58 | - name: phone 59 | label: Phone number 60 | required: true 61 | icon: mdi mdi-phone-dial 62 | description: Please use real phone number - we will contact you 63 | 64 | - name: email 65 | label: EMail 66 | pattern: '[^@]+@[^@]+' 67 | default: "{{.Email}}" # from OIDC 68 | required: true 69 | icon: mdi mdi-email 70 | 71 | - name: notify_sms 72 | label: Notify by SMS 73 | type: boolean 74 | icon: mdi mdi-message-alert 75 | 76 | - name: zip 77 | label: ZIP code 78 | required: true 79 | type: integer 80 | 81 | - name: address 82 | label: Full address 83 | required: true 84 | multiline: true 85 | icon: mdi mdi-map-marker 86 | 87 | success: | 88 | ## Thank you! 89 | 90 | Your order {{or .Result.ID .Result.id}} is on the way 91 | 92 | failed: | 93 | ## Sorry! 94 | 95 | Something went wrong. Please contact our support and tell them the following message: 96 | 97 | ``` 98 | {{.Error}} 99 | ``` 100 | 101 | webhooks: 102 | - url: https://example.com/new-pizza 103 | name: order 104 | retry: 3 105 | interval: 10s 106 | timeout: 30s 107 | method: PUT 108 | headers: 109 | Authorization: token xyz 110 | 111 | - url: https://example.com/notify-to-telegram 112 | message: | 113 | #{{ .Result.id }} New pizza ordered. 114 | 115 | amqp: 116 | - key: "form.shop.submission" 117 | 118 | - key: "form.shop.withtype" 119 | exchange: amq.topic 120 | type: application/json 121 | 122 | - exchange: amq.topic 123 | key: "form.shop.{{.Result.id}}" 124 | retry: 3 125 | interval: 10s 126 | timeout: 30s 127 | headers: 128 | source: pizza-shop 129 | id: "{{.Result.id}}" 130 | correlation: "corr-{{.Result.id}}" 131 | message: | 132 | #{{ .Result.id }} New pizza ordered. 133 | -------------------------------------------------------------------------------- /examples/configs/simple.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | title: Simple example 3 | table: simple 4 | fields: 5 | - name: name 6 | required: true 7 | - name: year 8 | required: true 9 | - name: comment 10 | multiline: true 11 | --- 12 | # multi documents YAML also supported but requires explicit name 13 | name: birthdays 14 | table: birthday 15 | # (OIDC only) allow only for groups admin OR for users with email domains example.com 16 | policy: '"admin" in groups || email.endsWith("@example.com")' 17 | description: | 18 | Collect employees birthdays 19 | fields: 20 | - name: employee 21 | label: Your name 22 | required: true 23 | - name: birthday 24 | label: Birthday 25 | type: date 26 | required: true 27 | --- 28 | name: code-access 29 | table: simple 30 | description: Code-based access 31 | fields: 32 | - name: name 33 | required: true 34 | default: "{{.Code}}" 35 | - name: year 36 | required: true 37 | default: '{{.Query.Get "year"}}' 38 | - name: comment 39 | multiline: true 40 | codes: 41 | - reddec -------------------------------------------------------------------------------- /examples/configs/test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | table: test 3 | fields: 4 | - name: allowed 5 | type: boolean 6 | default: "false" 7 | 8 | - name: disallowed 9 | type: boolean 10 | default: "true" 11 | 12 | - name: maybe 13 | type: boolean 14 | 15 | - name: text 16 | type: string 17 | default: "foobar" 18 | 19 | - name: float 20 | type: float 21 | default: "123.123" 22 | 23 | 24 | - name: integer 25 | type: integer 26 | default: "321" -------------------------------------------------------------------------------- /examples/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:14 4 | environment: 5 | POSTGRES_PASSWORD: postgres 6 | 7 | forms: 8 | image: ghcr.io/reddec/web-form:latest 9 | restart: unless-stopped 10 | depends_on: 11 | - db 12 | environment: 13 | STORAGE: database 14 | AMQP_URL: "amqp://guest:guest@rabbitmq" 15 | DB_URL: "postgres://postgres:postgres@db:5432/postgres?sslmode=disable" 16 | DB_DIALECT: postgres 17 | ports: 18 | - 127.0.0.1:8080:8080 19 | volumes: 20 | - ./configs:/configs:ro 21 | - ./migrations:/migrations:ro 22 | - ./assets:/assets:ro 23 | 24 | # everything bellow is optional 25 | rabbitmq: 26 | image: rabbitmq:3.12 -------------------------------------------------------------------------------- /examples/migrations/00001_init.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | CREATE TABLE shop 4 | ( 5 | ID BIGSERIAL NOT NULL PRIMARY KEY, 6 | CREATED_AT TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, 7 | DELIVERY_DATE TIMESTAMPTZ NOT NULL, 8 | BIRTHDAY DATE NOT NULL, 9 | CLIENT_ID TEXT NOT NULL, 10 | DOUGH TEXT NOT NULL DEFAULT 'thin', 11 | CHEESE TEXT[] NOT NULL, 12 | PHONE TEXT NOT NULL, 13 | EMAIL TEXT, 14 | NOTIFY_SMS BOOLEAN NOT NULL DEFAULT FALSE, 15 | ZIP INTEGER NOT NULL, 16 | ADDRESS TEXT NOT NULL 17 | ); 18 | 19 | -- +migrate Down 20 | DROP TABLE shop; 21 | -------------------------------------------------------------------------------- /examples/migrations/00002_simple.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | CREATE TABLE simple 4 | ( 5 | ID BIGSERIAL NOT NULL PRIMARY KEY, 6 | CREATED_AT TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, 7 | NAME TEXT NOT NULL, 8 | YEAR INTEGER NOT NULL, 9 | COMMENT TEXT 10 | ); 11 | 12 | CREATE TABLE birthday 13 | ( 14 | ID BIGSERIAL NOT NULL PRIMARY KEY, 15 | CREATED_AT TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, 16 | EMPLOYEE TEXT NOT NULL, 17 | BIRTHDAY DATE NOT NULL 18 | ); 19 | 20 | 21 | -- +migrate Down 22 | DROP TABLE simple; 23 | DROP TABLE birthday; 24 | -------------------------------------------------------------------------------- /examples/migrations/00003_pizza.sql: -------------------------------------------------------------------------------- 1 | -- +migrate Up 2 | 3 | CREATE TABLE pizza 4 | ( 5 | ID BIGSERIAL NOT NULL PRIMARY KEY, 6 | CREATED_AT TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, 7 | EMPLOYEE TEXT NOT NULL, 8 | PIZZA_KIND TEXT NOT NULL, 9 | EXTRA_CHEESE BOOLEAN NOT NULL DEFAULT false 10 | ); 11 | 12 | 13 | -- +migrate Down 14 | DROP TABLE pizza; 15 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/reddec/web-form 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/Masterminds/sprig/v3 v3.2.3 7 | github.com/alexedwards/scs/redisstore v0.0.0-20230902070821-95fa2ac9d520 8 | github.com/alexedwards/scs/v2 v2.5.1 9 | github.com/coreos/go-oidc/v3 v3.6.0 10 | github.com/go-chi/chi/v5 v5.0.10 11 | github.com/gomodule/redigo v1.8.9 12 | github.com/google/cel-go v0.18.1 13 | github.com/hashicorp/go-multierror v1.1.1 14 | github.com/jackc/pgx/v5 v5.4.3 15 | github.com/jessevdk/go-flags v1.5.0 16 | github.com/jmoiron/sqlx v1.3.5 17 | github.com/oklog/ulid/v2 v2.1.0 18 | github.com/ory/dockertest/v3 v3.10.0 19 | github.com/rabbitmq/amqp091-go v1.9.0 20 | github.com/reddec/oidc-login v0.2.1 21 | github.com/rubenv/sql-migrate v1.5.2 22 | github.com/stretchr/testify v1.8.4 23 | github.com/yuin/goldmark v1.5.6 24 | gopkg.in/yaml.v3 v3.0.1 25 | modernc.org/sqlite v1.25.0 26 | ) 27 | 28 | require ( 29 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect 30 | github.com/Masterminds/goutils v1.1.1 // indirect 31 | github.com/Masterminds/semver/v3 v3.2.0 // indirect 32 | github.com/Microsoft/go-winio v0.6.1 // indirect 33 | github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect 34 | github.com/PuerkitoBio/goquery v1.8.1 // indirect 35 | github.com/andybalholm/cascadia v1.3.1 // indirect 36 | github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230305170008-8188dc5388df // indirect 37 | github.com/cenkalti/backoff/v4 v4.2.1 // indirect 38 | github.com/containerd/continuity v0.4.2 // indirect 39 | github.com/davecgh/go-spew v1.1.1 // indirect 40 | github.com/docker/cli v24.0.6+incompatible // indirect 41 | github.com/docker/docker v24.0.6+incompatible // indirect 42 | github.com/docker/go-connections v0.4.0 // indirect 43 | github.com/docker/go-units v0.5.0 // indirect 44 | github.com/dustin/go-humanize v1.0.1 // indirect 45 | github.com/go-gorp/gorp/v3 v3.1.0 // indirect 46 | github.com/go-jose/go-jose/v3 v3.0.0 // indirect 47 | github.com/gogo/protobuf v1.3.2 // indirect 48 | github.com/golang/protobuf v1.5.3 // indirect 49 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 50 | github.com/google/uuid v1.3.0 // indirect 51 | github.com/hashicorp/errwrap v1.1.0 // indirect 52 | github.com/huandu/xstrings v1.4.0 // indirect 53 | github.com/imdario/mergo v0.3.16 // indirect 54 | github.com/jackc/pgpassfile v1.0.0 // indirect 55 | github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect 56 | github.com/jackc/puddle/v2 v2.2.1 // indirect 57 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 58 | github.com/mattn/go-isatty v0.0.17 // indirect 59 | github.com/mitchellh/copystructure v1.2.0 // indirect 60 | github.com/mitchellh/mapstructure v1.5.0 // indirect 61 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 62 | github.com/moby/term v0.5.0 // indirect 63 | github.com/opencontainers/go-digest v1.0.0 // indirect 64 | github.com/opencontainers/image-spec v1.0.2 // indirect 65 | github.com/opencontainers/runc v1.1.9 // indirect 66 | github.com/pkg/errors v0.9.1 // indirect 67 | github.com/pmezard/go-difflib v1.0.0 // indirect 68 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 69 | github.com/rogpeppe/go-internal v1.11.0 // indirect 70 | github.com/shopspring/decimal v1.3.1 // indirect 71 | github.com/sirupsen/logrus v1.9.3 // indirect 72 | github.com/spf13/cast v1.5.0 // indirect 73 | github.com/stoewer/go-strcase v1.2.0 // indirect 74 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect 75 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 76 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 77 | golang.org/x/crypto v0.13.0 // indirect 78 | golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect 79 | golang.org/x/mod v0.12.0 // indirect 80 | golang.org/x/net v0.15.0 // indirect 81 | golang.org/x/oauth2 v0.12.0 // indirect 82 | golang.org/x/sync v0.3.0 // indirect 83 | golang.org/x/sys v0.12.0 // indirect 84 | golang.org/x/text v0.13.0 // indirect 85 | golang.org/x/tools v0.13.0 // indirect 86 | google.golang.org/appengine v1.6.8 // indirect 87 | google.golang.org/genproto/googleapis/api v0.0.0-20230803162519-f966b187b2e5 // indirect 88 | google.golang.org/genproto/googleapis/rpc v0.0.0-20230803162519-f966b187b2e5 // indirect 89 | google.golang.org/protobuf v1.31.0 // indirect 90 | gopkg.in/yaml.v2 v2.4.0 // indirect 91 | lukechampine.com/uint128 v1.2.0 // indirect 92 | modernc.org/cc/v3 v3.40.0 // indirect 93 | modernc.org/ccgo/v3 v3.16.13 // indirect 94 | modernc.org/libc v1.24.1 // indirect 95 | modernc.org/mathutil v1.5.0 // indirect 96 | modernc.org/memory v1.6.0 // indirect 97 | modernc.org/opt v0.1.3 // indirect 98 | modernc.org/strutil v1.1.3 // indirect 99 | modernc.org/token v1.0.1 // indirect 100 | ) 101 | -------------------------------------------------------------------------------- /internal/assets/init.go: -------------------------------------------------------------------------------- 1 | package assets 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | ) 7 | 8 | // Static stores static assets. 9 | // 10 | //go:embed static 11 | var Static embed.FS 12 | 13 | // Views stores dynamic templates for UI pages. 14 | // 15 | //go:embed views 16 | var Views embed.FS 17 | 18 | func InsideViews() fs.FS { 19 | v, err := fs.Sub(Views, "views") 20 | if err != nil { 21 | panic(err) 22 | } 23 | return v 24 | } 25 | -------------------------------------------------------------------------------- /internal/assets/static/fonts/materialdesignicons-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reddec/web-form/437b2da0abaeb099ba0ebbbdb39ac34e9a1af637/internal/assets/static/fonts/materialdesignicons-webfont.woff -------------------------------------------------------------------------------- /internal/assets/static/fonts/materialdesignicons-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reddec/web-form/437b2da0abaeb099ba0ebbbdb39ac34e9a1af637/internal/assets/static/fonts/materialdesignicons-webfont.woff2 -------------------------------------------------------------------------------- /internal/assets/views/access.gohtml: -------------------------------------------------------------------------------- 1 | {{define "main"}} 2 |
3 | {{$.EmbedXSRF}} 4 | {{$.EmbedSession}} 5 |
6 | 9 |
10 | 12 |
13 |
14 |
15 | {{$.EmbedCaptcha}} 16 |
17 | 18 |
19 |
20 |
21 | {{end}} -------------------------------------------------------------------------------- /internal/assets/views/failed.gohtml: -------------------------------------------------------------------------------- 1 | {{define "main"}} 2 |
3 | {{ $.State.Result | $.State.Form.Failed.String | markdown}} 4 |
5 | 6 | {{- if $.State.Form.HasCodeAccess}} 7 |
8 | {{$.EmbedXSRF}} 9 | {{$.EmbedSession}} 10 | 11 |
12 | {{- else}} 13 | send one more time.. 14 | {{- end}} 15 | 16 | {{end}} -------------------------------------------------------------------------------- /internal/assets/views/forbidden.gohtml: -------------------------------------------------------------------------------- 1 | {{- define "main"}} 2 |

Access forbidden

3 | refresh page... 4 | {{- end}} -------------------------------------------------------------------------------- /internal/assets/views/form.gohtml: -------------------------------------------------------------------------------- 1 | {{- define "renderField" -}} 2 | {{- $defaultValue := (index $.defaults $.field.Name) -}} 3 | {{- if .field.Options}} 4 | {{- if .field.Multiple }} 5 | {{- range $opt := .field.Options}} 6 | 15 | {{- end}} 16 | {{- else}} 17 |
18 | 23 |
24 | {{- end}} 25 | {{- else if .field.Type.Is "string"}} 26 | {{- if .field.Multiline}} 27 | 29 | {{- else}} 30 | 35 | {{- end}} 36 | {{- else if .field.Type.Is "integer"}} 37 | 39 | {{- else if .field.Type.Is "float"}} 40 | 42 | {{- else if .field.Type.Is "boolean"}} 43 | 52 | {{- else if .field.Type.Is "date"}} 53 | 58 | {{- else if .field.Type.Is "date-time"}} 59 | 64 | 65 | {{end}} 66 | {{- end -}} 67 | 68 | {{define "main"}} 69 |
70 |

{{($.State.Description) | markdown}}

71 |
72 |
73 | 74 |
75 | {{$.EmbedXSRF}} 76 | {{$.EmbedSession}} 77 | {{- range $field := $.State.Form.Fields}} 78 | {{- if not $field.Hidden}} 79 |
80 | 91 |
92 | {{template "renderField" (dict "field" $field "defaults" $.State.Defaults)}} 93 |
94 | {{- with $field.Description}} 95 |

{{.}}

96 | {{- end}} 97 | {{- range ($.Messages $field.Name)}} 98 |

{{.Text}}

99 | {{- end}} 100 |
101 | {{- end}} 102 | {{- end}} 103 |
104 | {{$.EmbedCaptcha}} 105 |
106 | 107 |
108 |
109 | 112 | 113 | 114 | 121 |
122 | {{end}} -------------------------------------------------------------------------------- /internal/assets/views/form_base.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{or .State.Form.Title .State.Form.Name .State.Form.Table}} 7 | 8 | 9 |
10 |
11 |
12 |

{{or .State.Form.Title .State.Form.Name .State.Form.Table}}

13 | 14 | {{block "main" .}} 15 | 16 | {{end}} 17 | 18 | {{- range .Messages}} 19 | {{if eq .Name ""}} 20 |
21 | {{.Text}} 22 |
23 | {{- end }} 24 | {{- end }} 25 |
26 |
27 |
28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /internal/assets/views/list.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | All forms 7 | 8 | 9 | 10 |
11 |
12 | {{range $form := .State.Definitions}} 13 |
14 |
15 |

16 | {{or $form.Title $form.Name}} 17 |

18 |
19 |
20 |
21 | {{$.State.Context | $form.Description.String | markdown}} 22 |
23 |
24 | 27 |
28 |
29 | {{end}} 30 |
31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /internal/assets/views/success.gohtml: -------------------------------------------------------------------------------- 1 | {{define "main"}} 2 |
3 | {{ $.State.Result | $.State.Form.Success.String | markdown}} 4 |
5 | 6 | {{- if $.State.Form.HasCodeAccess}} 7 |
8 | {{$.EmbedXSRF}} 9 | {{$.EmbedSession}} 10 | 11 |
12 | {{- else}} 13 | send one more time.. 14 | {{- end}} 15 | {{end}} -------------------------------------------------------------------------------- /internal/captcha/turnstile.go: -------------------------------------------------------------------------------- 1 | package captcha 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "html/template" 7 | "log/slog" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | "time" 12 | 13 | "github.com/reddec/web-form/internal/web" 14 | ) 15 | 16 | type Turnstile struct { 17 | SiteKey string `long:"site-key" env:"SITE_KEY" description:"Widget access key"` 18 | SecretKey string `long:"secret-key" env:"SECRET_KEY" description:"Server side secret key"` 19 | Timeout time.Duration `long:"timeout" env:"TIMEOUT" description:"Validation request timeout" default:"3s"` 20 | } 21 | 22 | func (widget *Turnstile) Embed() template.HTML { 23 | //nolint:gosec 24 | return template.HTML(` 25 |
26 | 27 | `) 28 | } 29 | 30 | func (widget *Turnstile) Validate(form *http.Request) bool { 31 | const validateURL = `https://challenges.cloudflare.com/turnstile/v0/siteverify` 32 | var response struct { 33 | Success bool `json:"success"` 34 | } 35 | ctx, cancel := context.WithTimeout(form.Context(), widget.Timeout) 36 | defer cancel() 37 | 38 | var formFields = make(url.Values) 39 | formFields.Add("secret", widget.SecretKey) 40 | formFields.Add("response", form.Form.Get("cf-turnstile-response")) 41 | formFields.Add("remoteip", web.GetClientIP(form)) 42 | 43 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, validateURL, strings.NewReader(formFields.Encode())) 44 | if err != nil { 45 | slog.Error("failed create request to check turnstile captcha", "error", err) 46 | return false 47 | } 48 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") 49 | 50 | res, err := http.DefaultClient.Do(req) 51 | if err != nil { 52 | slog.Error("failed execute request to check turnstile captcha", "error", err) 53 | return false 54 | } 55 | defer res.Body.Close() 56 | 57 | if err := json.NewDecoder(res.Body).Decode(&response); err != nil { 58 | slog.Error("failed decode response from turnstile captcha", "error", err) 59 | return false 60 | } 61 | 62 | return response.Success 63 | } 64 | -------------------------------------------------------------------------------- /internal/engine/form.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "html/template" 7 | "net/http" 8 | "net/url" 9 | "sync" 10 | "time" 11 | 12 | "github.com/reddec/web-form/internal/notifications" 13 | "github.com/reddec/web-form/internal/schema" 14 | "github.com/reddec/web-form/internal/utils" 15 | "github.com/reddec/web-form/internal/web" 16 | ) 17 | 18 | const ( 19 | accessCodeField = "accessCode" 20 | freshField = "fresh" 21 | tzField = "tz" 22 | ) 23 | 24 | type Storage interface { 25 | Store(ctx context.Context, table string, fields map[string]any) (map[string]any, error) 26 | } 27 | 28 | type WebhooksFactory interface { 29 | Create(webhook schema.Webhook) notifications.Notification 30 | } 31 | 32 | type AMQPFactory interface { 33 | Create(definition schema.AMQP) notifications.Notification 34 | } 35 | 36 | type FormConfig struct { 37 | Definition schema.Form // schema definition 38 | ViewForm *template.Template // template to show main form 39 | ViewSuccess *template.Template // template to show result (success) after submit 40 | ViewFail *template.Template // template to show result (fail) after submit 41 | ViewCode *template.Template // template to show code access 42 | ViewForbidden *template.Template // template to show access denied 43 | Storage Storage // where to store data 44 | WebhooksFactory WebhooksFactory 45 | AMQPFactory AMQPFactory 46 | XSRF bool // check XSRF token. Disable if form is exposed as API. 47 | Captcha []web.Captcha 48 | } 49 | 50 | func NewForm(config FormConfig, options ...FormOption) http.HandlerFunc { 51 | for _, opt := range options { 52 | opt(&config) 53 | } 54 | 55 | var destinations []notifications.Notification 56 | 57 | if config.WebhooksFactory != nil { 58 | for _, webhook := range config.Definition.Webhooks { 59 | destinations = append(destinations, config.WebhooksFactory.Create(webhook)) 60 | } 61 | } 62 | 63 | if config.AMQPFactory != nil { 64 | for _, definition := range config.Definition.AMQP { 65 | destinations = append(destinations, config.AMQPFactory.Create(definition)) 66 | } 67 | } 68 | 69 | return func(writer http.ResponseWriter, request *http.Request) { 70 | defer request.Body.Close() 71 | 72 | f := &formRequest{ 73 | FormConfig: &config, 74 | destinations: destinations, 75 | } 76 | 77 | r := web.NewRequest(writer, request).WithCaptcha(config.Captcha...).Set("Form", &f.Definition) 78 | f.Serve(r) 79 | } 80 | } 81 | 82 | type formRequest struct { 83 | *FormConfig 84 | destinations []notifications.Notification 85 | } 86 | 87 | //nolint:cyclop 88 | func (fr *formRequest) Serve(request *web.Request) { 89 | // check XSRF tokens (POST only) 90 | if fr.XSRF && !request.VerifyXSRF() { 91 | request.Error("XSRF validation failed") 92 | request.Render(http.StatusForbidden, fr.ViewForbidden) 93 | return 94 | } 95 | 96 | // check credentials access (OIDC) 97 | if !fr.Definition.IsAllowed(request.Credentials()) { 98 | request.Render(http.StatusForbidden, fr.ViewForbidden) 99 | return 100 | } 101 | 102 | // for code access forms, all interactions should be done via POST 103 | if fr.Definition.HasCodeAccess() && request.Request().Method != http.MethodPost { 104 | request.Render(http.StatusUnauthorized, fr.ViewCode) 105 | return 106 | } 107 | 108 | // check code access 109 | if !validateCode(&fr.Definition, request) { 110 | request.Error("invalid code") 111 | request.Render(http.StatusUnauthorized, fr.ViewCode) 112 | return 113 | } 114 | 115 | // pre-render default values 116 | if err := fr.preRender(request); err != nil { 117 | request.Error("render defaults " + err.Error()) 118 | } 119 | 120 | // if it's fresh start - show page without processing data 121 | if request.Pop(freshField) == "true" || request.Request().Method == http.MethodGet { 122 | request.Render(http.StatusOK, fr.ViewForm) 123 | return 124 | } 125 | 126 | // check captcha (form post only) 127 | if !request.VerifyCaptcha() { 128 | request.Error("invalid captcha") 129 | request.Render(http.StatusBadRequest, fr.ViewForm) 130 | return 131 | } 132 | 133 | // it's not fresh start or get - submit the form 134 | fr.submitForm(request) 135 | } 136 | 137 | func (fr *formRequest) submitForm(request *web.Request) { 138 | tz := request.Session()[tzField] 139 | 140 | // workaround for some browsers sending value in url-encoded format 141 | if v, err := url.QueryUnescape(tz); err == nil { 142 | tz = v 143 | } 144 | 145 | tzLocation, err := time.LoadLocation(tz) 146 | if err != nil { 147 | request.Logger().Warn("failed load client's timezone location - local will be used", "tz", tz, "error", err) 148 | tzLocation = time.Local 149 | } 150 | 151 | _ = request.Request().FormValue("") // parse form using Go defaults 152 | 153 | values, fieldErrors := schema.ParseForm(&fr.Definition, tzLocation, newRequestContext(request)) 154 | 155 | // save flash messages with name related to field name 156 | for _, fieldError := range fieldErrors { 157 | request.Flash(fieldError.Name, fieldError.Error, web.FlashError) 158 | } 159 | 160 | if len(fieldErrors) > 0 { 161 | request.Logger().Info("form validation failed", toLogErrors(fieldErrors)...) 162 | request.Render(http.StatusUnprocessableEntity, fr.ViewForm) 163 | return 164 | } 165 | 166 | // bellow we will show success or failed page 167 | request.Push(freshField, "true") 168 | result, storeErr := fr.Storage.Store(request.Context(), fr.Definition.Table, values) 169 | if storeErr != nil { 170 | request.Error("failed to store data") 171 | request.Set("Result", &schema.ResultContext{ 172 | Form: &fr.Definition, 173 | Result: result, 174 | Error: storeErr, 175 | }) 176 | request.Render(http.StatusInternalServerError, fr.ViewFail) 177 | return 178 | } 179 | 180 | request.Set("Result", &schema.ResultContext{ 181 | Form: &fr.Definition, 182 | Result: result, 183 | }).Render(http.StatusOK, fr.ViewSuccess) 184 | 185 | fr.sendNotifications(request, schema.NotifyContext{ 186 | Form: &fr.Definition, 187 | Result: result, 188 | }) 189 | } 190 | 191 | func (fr *formRequest) sendNotifications(request *web.Request, rc schema.NotifyContext) { 192 | ctx := request.Context() 193 | // send all notifications in parallel to avoid blocking in case one of dispatcher is slow/full 194 | var wg sync.WaitGroup 195 | 196 | for _, notify := range fr.destinations { 197 | notify := notify 198 | wg.Add(1) 199 | go func() { 200 | defer wg.Done() 201 | if err := notify.Dispatch(ctx, rc); err != nil { 202 | request.Logger().Error("failed dispatch notification", "error", err) 203 | } 204 | }() 205 | } 206 | 207 | wg.Wait() 208 | } 209 | 210 | func (fr *formRequest) preRender(request *web.Request) error { 211 | var defaultValues = make(map[string]any, len(fr.Definition.Fields)) 212 | rct := newRequestContext(request) 213 | 214 | description, err := fr.Definition.Description.String(rct) 215 | if err != nil { 216 | return fmt.Errorf("render description: %w", err) 217 | } 218 | request.Set("Description", description) 219 | 220 | for _, field := range fr.Definition.Fields { 221 | 222 | // if there is old value - keep it as default 223 | if oldValues := request.Request().Form[field.Name]; len(oldValues) > 0 { 224 | // multiselect is special case - we need to preserve options 225 | if field.Multiple && len(field.Options) > 0 { 226 | defaultValues[field.Name] = utils.NewSet(oldValues...) 227 | } else { 228 | defaultValues[field.Name] = oldValues[0] 229 | } 230 | } else { 231 | // fresh default value 232 | value, err := field.Default.String(rct) 233 | if err != nil { 234 | return fmt.Errorf("compute default value for field %q: %w", field.Name, err) 235 | } 236 | defaultValues[field.Name] = value 237 | } 238 | } 239 | request.Set("Defaults", defaultValues) 240 | return nil 241 | } 242 | 243 | func newRequestContext(request *web.Request) *schema.RequestContext { 244 | return &schema.RequestContext{ 245 | Headers: request.Request().Header, 246 | Query: request.Request().URL.Query(), 247 | Form: request.Request().PostForm, 248 | Code: request.Session()[accessCodeField], 249 | Credentials: request.Credentials(), 250 | } 251 | } 252 | 253 | func validateCode(form *schema.Form, request *web.Request) bool { 254 | if !form.HasCodeAccess() { 255 | return true 256 | } 257 | 258 | // only post allowed 259 | if request.Request().Method != http.MethodPost { 260 | return false 261 | } 262 | 263 | // check session value 264 | code := request.Session()[accessCodeField] 265 | if code == "" { 266 | // maybe it's a form post 267 | code = request.Request().PostFormValue(accessCodeField) 268 | request.Push(freshField, "true") 269 | } 270 | 271 | if !form.Codes.Has(code) { 272 | return false 273 | } 274 | request.Session()[accessCodeField] = code // save code in order to re-use 275 | request.Set("code", code) // for UI 276 | return true 277 | } 278 | 279 | func toLogErrors(fieldError []schema.FieldError) []any { 280 | var ans = make([]any, 0, 2*len(fieldError)) 281 | for _, f := range fieldError { 282 | ans = append(ans, "field."+f.Name, f.Error) 283 | } 284 | return ans 285 | } 286 | -------------------------------------------------------------------------------- /internal/engine/form_test.go: -------------------------------------------------------------------------------- 1 | package engine_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "maps" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "strings" 11 | "sync" 12 | "sync/atomic" 13 | "testing" 14 | 15 | "github.com/PuerkitoBio/goquery" 16 | "github.com/reddec/web-form/internal/engine" 17 | "github.com/reddec/web-form/internal/schema" 18 | "github.com/reddec/web-form/internal/utils" 19 | "github.com/stretchr/testify/assert" 20 | "github.com/stretchr/testify/require" 21 | ) 22 | 23 | const def = ` 24 | name: code-access 25 | table: code 26 | description: Code-based access 27 | fields: 28 | - name: name 29 | required: true 30 | default: "{{.Code}}" 31 | - name: year 32 | required: true 33 | - name: comment 34 | multiline: true 35 | codes: 36 | - reddec 37 | 38 | --- 39 | name: plain 40 | table: plain 41 | fields: 42 | - name: name 43 | required: true 44 | - name: year 45 | required: true 46 | - name: comment 47 | multiline: true 48 | --- 49 | name: crash 50 | table: crash 51 | fields: 52 | - name: name 53 | required: true 54 | - name: year 55 | required: true 56 | - name: comment 57 | multiline: true 58 | ` 59 | 60 | func TestBasic(t *testing.T) { 61 | storage := &mockStorage{ 62 | failedTables: utils.NewSet("crash"), 63 | } 64 | forms, err := schema.FormsFromStream(strings.NewReader(def)) 65 | require.NoError(t, err) 66 | 67 | srv, err := engine.New(engine.Config{ 68 | Forms: forms, 69 | Storage: storage, 70 | Listing: true, 71 | }) 72 | require.NoError(t, err) 73 | 74 | t.Run("should show listing", func(t *testing.T) { 75 | req := httptest.NewRequest(http.MethodGet, "/", nil) 76 | rec := httptest.NewRecorder() 77 | 78 | srv.ServeHTTP(rec, req) 79 | require.Equal(t, http.StatusOK, rec.Code) 80 | 81 | doc, err := goquery.NewDocumentFromReader(rec.Body) 82 | require.NoError(t, err) 83 | assertHasElement(t, doc, `a[href="forms/code-access"]`) 84 | }) 85 | 86 | t.Run("should show code", func(t *testing.T) { 87 | req := httptest.NewRequest(http.MethodGet, "/forms/code-access", nil) 88 | rec := httptest.NewRecorder() 89 | 90 | srv.ServeHTTP(rec, req) 91 | require.Equal(t, http.StatusUnauthorized, rec.Code) 92 | 93 | doc, err := goquery.NewDocumentFromReader(rec.Body) 94 | require.NoError(t, err) 95 | assertHasElement(t, doc, `input[name="_xsrf"]`) 96 | assertHasElement(t, doc, `input[name="accessCode"]`) 97 | assertHasElement(t, doc, `button[type="submit"]`) 98 | }) 99 | 100 | t.Run("should show form", func(t *testing.T) { 101 | req := httptest.NewRequest(http.MethodGet, "/forms/plain", nil) 102 | rec := httptest.NewRecorder() 103 | 104 | srv.ServeHTTP(rec, req) 105 | require.Equal(t, http.StatusOK, rec.Code) 106 | 107 | doc, err := goquery.NewDocumentFromReader(rec.Body) 108 | require.NoError(t, err) 109 | assertHasElement(t, doc, `input[name="_xsrf"]`) 110 | assertHasElement(t, doc, `input[name="name"]`) 111 | assertHasElement(t, doc, `input[name="year"]`) 112 | assertHasElement(t, doc, `textarea[name="comment"]`) 113 | assertHasElement(t, doc, `button[type="submit"]`) 114 | }) 115 | 116 | t.Run("should show result (success)", func(t *testing.T) { 117 | var params = make(url.Values) 118 | params.Set("_xsrf", "demo") 119 | params.Set("name", "RedDec") 120 | params.Set("year", "2023") 121 | params.Set("comment", "it works!") 122 | 123 | req := httptest.NewRequest(http.MethodPost, "/forms/plain", strings.NewReader(params.Encode())) 124 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 125 | req.AddCookie(&http.Cookie{ 126 | Name: "_xsrf", 127 | Value: "demo", 128 | }) 129 | rec := httptest.NewRecorder() 130 | 131 | srv.ServeHTTP(rec, req) 132 | require.Equal(t, http.StatusOK, rec.Code) 133 | 134 | doc, err := goquery.NewDocumentFromReader(rec.Body) 135 | require.NoError(t, err) 136 | assertHasElement(t, doc, `a[href="plain"]`) 137 | }) 138 | 139 | t.Run("should show result (failed)", func(t *testing.T) { 140 | var params = make(url.Values) 141 | params.Set("_xsrf", "demo") 142 | params.Set("name", "RedDec") 143 | params.Set("year", "2023") 144 | params.Set("comment", "it works!") 145 | 146 | req := httptest.NewRequest(http.MethodPost, "/forms/crash", strings.NewReader(params.Encode())) 147 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 148 | req.AddCookie(&http.Cookie{ 149 | Name: "_xsrf", 150 | Value: "demo", 151 | }) 152 | rec := httptest.NewRecorder() 153 | 154 | srv.ServeHTTP(rec, req) 155 | require.Equal(t, http.StatusInternalServerError, rec.Code) 156 | 157 | doc, err := goquery.NewDocumentFromReader(rec.Body) 158 | require.NoError(t, err) 159 | assertHasElement(t, doc, `a[href="crash"]`) 160 | }) 161 | } 162 | 163 | func assertHasElement(t *testing.T, doc *goquery.Document, selector string) { 164 | assert.True(t, doc.Find(selector).Length() > 0, "exists element: %q", selector) 165 | } 166 | 167 | type mockStorage struct { 168 | tables sync.Map // table -> *mockTable 169 | failedTables utils.Set[string] 170 | } 171 | 172 | func (ms *mockStorage) Store(_ context.Context, table string, fields map[string]any) (map[string]any, error) { 173 | if ms.failedTables.Has(table) { 174 | return nil, fmt.Errorf("simulated error") 175 | } 176 | id := ms.getTable(table).Add(fields) 177 | s := maps.Clone(fields) 178 | s["id"] = id 179 | return s, nil 180 | } 181 | 182 | func (ms *mockStorage) getTable(name string) *mockTable { 183 | v, _ := ms.tables.LoadOrStore(name, &mockTable{}) 184 | return v.(*mockTable) 185 | } 186 | 187 | type mockTable struct { 188 | id atomic.Int64 189 | rows sync.Map // id -> map[string]any 190 | } 191 | 192 | func (mt *mockTable) Add(row map[string]any) int64 { 193 | id := mt.id.Add(1) 194 | mt.rows.Store(id, row) 195 | return id 196 | } 197 | -------------------------------------------------------------------------------- /internal/engine/options.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | type FormOption func(config *FormConfig) 4 | 5 | func WithXSRF(enable bool) FormOption { 6 | return func(config *FormConfig) { 7 | config.XSRF = enable 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /internal/engine/server.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "html/template" 7 | "io/fs" 8 | "net/http" 9 | 10 | "github.com/reddec/web-form/internal/assets" 11 | "github.com/reddec/web-form/internal/schema" 12 | "github.com/reddec/web-form/internal/utils" 13 | "github.com/reddec/web-form/internal/web" 14 | 15 | "github.com/go-chi/chi/v5" 16 | ) 17 | 18 | var ErrDuplicatedName = errors.New("duplicated form name") 19 | 20 | type Config struct { 21 | Forms []schema.Form 22 | Storage Storage 23 | WebhooksFactory WebhooksFactory 24 | AMQPFactory AMQPFactory 25 | Listing bool 26 | Captcha []web.Captcha 27 | } 28 | 29 | func New(cfg Config, options ...FormOption) (http.Handler, error) { 30 | views := assets.InsideViews() 31 | listView := mustParse(views, "list.gohtml") 32 | viewForm := mustParse(views, "form_base.gohtml", "form.gohtml") 33 | viewSuccess := mustParse(views, "form_base.gohtml", "success.gohtml") 34 | viewFail := mustParse(views, "form_base.gohtml", "failed.gohtml") 35 | viewCode := mustParse(views, "form_base.gohtml", "access.gohtml") 36 | viewForbidden := mustParse(views, "form_base.gohtml", "forbidden.gohtml") 37 | 38 | mux := chi.NewMux() 39 | 40 | var usedName = utils.NewSet[string]() 41 | for _, formDef := range cfg.Forms { 42 | if usedName.Has(formDef.Name) { 43 | return nil, fmt.Errorf("form %q: %w", formDef.Name, ErrDuplicatedName) 44 | } 45 | usedName.Add(formDef.Name) 46 | mux.Mount("/forms/"+formDef.Name, NewForm(FormConfig{ 47 | Definition: formDef, 48 | ViewForm: viewForm, 49 | ViewSuccess: viewSuccess, 50 | ViewFail: viewFail, 51 | ViewCode: viewCode, 52 | ViewForbidden: viewForbidden, 53 | Storage: cfg.Storage, 54 | WebhooksFactory: cfg.WebhooksFactory, 55 | AMQPFactory: cfg.AMQPFactory, 56 | Captcha: cfg.Captcha, 57 | }, options...)) 58 | } 59 | if cfg.Listing { 60 | mux.Get("/", listViewHandler(cfg.Forms, listView)) 61 | } 62 | return mux, nil 63 | } 64 | 65 | func listViewHandler(forms []schema.Form, listView *template.Template) http.HandlerFunc { 66 | return func(writer http.ResponseWriter, request *http.Request) { 67 | req := web.NewRequest(writer, request) 68 | 69 | creds := schema.CredentialsFromContext(request.Context()) 70 | var filteredForms = make([]schema.Form, 0, len(forms)) 71 | for _, f := range forms { 72 | if f.IsAllowed(creds) { 73 | filteredForms = append(filteredForms, f) 74 | } 75 | } 76 | 77 | req.Set("Definitions", filteredForms) 78 | req.Set("Context", newRequestContext(req)) 79 | req.Render(http.StatusOK, listView) 80 | } 81 | } 82 | 83 | func mustParse(src fs.FS, base string, overlay ...string) *template.Template { 84 | v, err := baseParse(src, base, overlay...) 85 | if err != nil { 86 | panic(err) 87 | } 88 | return v 89 | } 90 | 91 | func baseParse(src fs.FS, base string, overlay ...string) (*template.Template, error) { 92 | var root = template.New("").Funcs(utils.TemplateFuncs()) 93 | var files = append([]string{base}, overlay...) 94 | 95 | for _, file := range files { 96 | content, err := fs.ReadFile(src, file) 97 | if err != nil { 98 | return nil, fmt.Errorf("read %q: %w", file, err) 99 | } 100 | sub, err := root.Parse(string(content)) 101 | if err != nil { 102 | return nil, fmt.Errorf("parse %q: %w", file, err) 103 | } 104 | root = sub 105 | } 106 | return root, nil 107 | } 108 | -------------------------------------------------------------------------------- /internal/notifications/amqp/amqp.go: -------------------------------------------------------------------------------- 1 | package amqp 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "net" 8 | "time" 9 | 10 | "github.com/rabbitmq/amqp091-go" 11 | "github.com/reddec/web-form/internal/notifications" 12 | "github.com/reddec/web-form/internal/schema" 13 | ) 14 | 15 | const ( 16 | defaultTimeout = 10 * time.Second 17 | defaultRetries = 3 18 | defaultInterval = 15 * time.Second 19 | ) 20 | 21 | // copied from amp091 defaults. 22 | const ( 23 | defaultHeartbeat = 10 * time.Second 24 | defaultLocale = "en_US" 25 | ) 26 | 27 | func New(url string, buffer int) *AMQP { 28 | return &AMQP{url: url, tasks: make(chan task, buffer)} 29 | } 30 | 31 | type AMQP struct { 32 | url string 33 | tasks chan task 34 | } 35 | 36 | //nolint:cyclop 37 | func (amqp *AMQP) Create(definition schema.AMQP) notifications.Notification { 38 | if definition.Timeout <= 0 { 39 | definition.Timeout = defaultTimeout 40 | } 41 | if definition.Retry == 0 { 42 | definition.Retry = defaultRetries 43 | } 44 | if definition.Interval <= 0 { 45 | definition.Interval = defaultInterval 46 | } 47 | if !definition.Message.Valid { 48 | // nil message causes JSON payload 49 | if definition.Type == "" { 50 | definition.Type = "application/json" 51 | } 52 | definition.Message = schema.MustTemplate[schema.NotifyContext]("{{.Result | toJson}}") 53 | } 54 | 55 | return notifications.NotificationFunc(func(ctx context.Context, event schema.NotifyContext) error { 56 | payload, err := definition.Message.Bytes(&event) 57 | if err != nil { 58 | return fmt.Errorf("render payload: %w", err) 59 | } 60 | 61 | key, err := definition.Key.String(&event) 62 | if err != nil { 63 | return fmt.Errorf("render routing key: %w", err) 64 | } 65 | 66 | correlationID, err := definition.Correlation.String(&event) 67 | if err != nil { 68 | return fmt.Errorf("render correlation ID: %w", err) 69 | } 70 | 71 | messageID, err := definition.ID.String(&event) 72 | if err != nil { 73 | return fmt.Errorf("render message ID: %w", err) 74 | } 75 | 76 | t := task{ 77 | definition: definition, 78 | key: key, 79 | correlationID: correlationID, 80 | messageID: messageID, 81 | payload: payload, 82 | } 83 | 84 | select { 85 | case amqp.tasks <- t: 86 | return nil 87 | case <-ctx.Done(): 88 | return ctx.Err() 89 | } 90 | }) 91 | } 92 | 93 | func (amqp *AMQP) Run(ctx context.Context) { 94 | w := &worker{url: amqp.url} 95 | defer w.close() 96 | 97 | for { 98 | select { 99 | case t, ok := <-amqp.tasks: 100 | if !ok { 101 | return 102 | } 103 | amqp.sendTask(ctx, w, t) 104 | case <-ctx.Done(): 105 | return 106 | } 107 | } 108 | } 109 | 110 | func (amqp *AMQP) sendTask(ctx context.Context, w *worker, t task) { 111 | var attempt int 112 | logger := slog.With("routing-key", t.key, "exchange", t.definition.Exchange, "message-id", t.messageID) 113 | for { 114 | if err := amqp.trySendTask(ctx, w, t); err != nil { 115 | w.close() // reset state on error 116 | logger.Warn("failed publish AMQP message", "error", err, "attempt", attempt+1, "retries", t.definition.Retry, "retry-after", t.definition.Interval) 117 | } else { 118 | logger.Info("message published to AMQP broker", "attempt", attempt+1, "retries", t.definition.Retry) 119 | break 120 | } 121 | 122 | if attempt >= t.definition.Retry { 123 | break 124 | } 125 | select { 126 | case <-time.After(t.definition.Timeout): 127 | case <-ctx.Done(): 128 | logger.Info("publishing stopped due to global context stop") 129 | return 130 | } 131 | attempt++ 132 | } 133 | } 134 | 135 | func (amqp *AMQP) trySendTask(global context.Context, w *worker, t task) error { 136 | ctx, cancel := context.WithTimeout(global, t.definition.Timeout) 137 | defer cancel() 138 | 139 | ch, err := w.getChannel(ctx) 140 | if err != nil { 141 | return fmt.Errorf("get channel: %w", err) 142 | } 143 | 144 | headers := make(amqp091.Table, len(t.definition.Headers)) 145 | for k, v := range t.definition.Headers { 146 | headers[k] = v 147 | } 148 | 149 | return ch.PublishWithContext(ctx, t.definition.Exchange, t.key, false, false, amqp091.Publishing{ 150 | MessageId: t.messageID, 151 | CorrelationId: t.correlationID, 152 | Timestamp: time.Now(), 153 | Headers: headers, 154 | ContentType: t.definition.Type, 155 | Body: t.payload, 156 | }) 157 | } 158 | 159 | type worker struct { 160 | url string 161 | connection *amqp091.Connection 162 | channel *amqp091.Channel 163 | } 164 | 165 | func (worker *worker) getChannel(ctx context.Context) (*amqp091.Channel, error) { 166 | if worker.channel != nil { 167 | return worker.channel, nil 168 | } 169 | 170 | connection, err := worker.getConnection(ctx) 171 | if err != nil { 172 | return nil, fmt.Errorf("get connection: %w", err) 173 | } 174 | 175 | channel, err := connection.Channel() 176 | if err != nil { 177 | worker.close() 178 | return nil, fmt.Errorf("allocate channel: %w", err) 179 | } 180 | worker.channel = channel 181 | 182 | return channel, nil 183 | } 184 | 185 | func (worker *worker) getConnection(ctx context.Context) (*amqp091.Connection, error) { 186 | if worker.connection != nil { 187 | return worker.connection, nil 188 | } 189 | 190 | conn, err := amqp091.DialConfig(worker.url, amqp091.Config{ 191 | Heartbeat: defaultHeartbeat, 192 | Locale: defaultLocale, 193 | Dial: func(network, addr string) (net.Conn, error) { 194 | var dialer net.Dialer 195 | return dialer.DialContext(ctx, network, addr) 196 | }, 197 | }) 198 | if err != nil { 199 | return nil, fmt.Errorf("dial broker: %w", err) 200 | } 201 | worker.connection = conn 202 | return conn, nil 203 | } 204 | 205 | func (worker *worker) close() { 206 | if worker.channel != nil { 207 | _ = worker.channel.Close() 208 | } 209 | worker.channel = nil 210 | 211 | if worker.connection != nil { 212 | _ = worker.connection.Close() 213 | } 214 | worker.connection = nil 215 | } 216 | 217 | type task struct { 218 | definition schema.AMQP 219 | key string 220 | correlationID string 221 | messageID string 222 | payload []byte 223 | } 224 | -------------------------------------------------------------------------------- /internal/notifications/amqp/amqp_test.go: -------------------------------------------------------------------------------- 1 | package amqp_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net" 8 | "os" 9 | "testing" 10 | "time" 11 | 12 | "github.com/ory/dockertest/v3" 13 | "github.com/rabbitmq/amqp091-go" 14 | "github.com/reddec/web-form/internal/notifications/amqp" 15 | "github.com/reddec/web-form/internal/schema" 16 | "github.com/stretchr/testify/assert" 17 | "github.com/stretchr/testify/require" 18 | ) 19 | 20 | var amqpURL string 21 | 22 | func TestAMQP_Run(t *testing.T) { 23 | factory := amqp.New(amqpURL, 3) 24 | 25 | ctx, cancel := context.WithTimeout(context.Background(), time.Minute) 26 | defer cancel() 27 | 28 | go factory.Run(ctx) 29 | 30 | t.Run("simple", func(t *testing.T) { 31 | require.NoError(t, define("", t.Name(), "")) 32 | 33 | notify := factory.Create(schema.AMQP{ 34 | Key: schema.MustTemplate[schema.NotifyContext](t.Name()), // when exchange not specified, routing key is queue name 35 | }) 36 | 37 | err := notify.Dispatch(ctx, schema.NotifyContext{ 38 | Result: map[string]any{"Name": t.Name()}, 39 | }) 40 | require.NoError(t, err) 41 | 42 | msg, err := getMessage(ctx, t.Name()) 43 | require.NoError(t, err) 44 | 45 | assert.Equal(t, `{"Name":"`+t.Name()+`"}`, string(msg.Body)) 46 | }) 47 | 48 | t.Run("full", func(t *testing.T) { 49 | require.NoError(t, define("test", t.Name(), "full")) 50 | 51 | notify := factory.Create(schema.AMQP{ 52 | Exchange: "test", 53 | Key: schema.MustTemplate[schema.NotifyContext]("full"), 54 | Headers: map[string]string{ 55 | "X-Hello": "World", 56 | }, 57 | Correlation: schema.MustTemplate[schema.NotifyContext]("reply-{{.Result.ID}}"), 58 | ID: schema.MustTemplate[schema.NotifyContext]("{{.Result.ID}}"), 59 | Message: schema.MustTemplate[schema.NotifyContext]("{{.Result.Name}}"), 60 | }) 61 | 62 | err := notify.Dispatch(ctx, schema.NotifyContext{ 63 | Result: map[string]any{"Name": t.Name(), "ID": 1234}, 64 | }) 65 | require.NoError(t, err) 66 | 67 | msg, err := getMessage(ctx, t.Name()) 68 | require.NoError(t, err) 69 | 70 | assert.Equal(t, t.Name(), string(msg.Body)) 71 | assert.Equal(t, "1234", msg.MessageId) 72 | assert.Equal(t, "reply-1234", msg.CorrelationId) 73 | assert.Equal(t, amqp091.Table{ 74 | "X-Hello": "World", 75 | }, msg.Headers) 76 | assert.Equal(t, "test", msg.Exchange) 77 | assert.Equal(t, "full", msg.RoutingKey) 78 | }) 79 | } 80 | 81 | func getMessage(ctx context.Context, queue string) (amqp091.Delivery, error) { 82 | con, err := amqp091.DialConfig(amqpURL, amqp091.Config{ 83 | Dial: func(network, addr string) (net.Conn, error) { 84 | return net.DialTimeout(network, addr, 5*time.Second) 85 | }, 86 | }) 87 | if err != nil { 88 | return amqp091.Delivery{}, fmt.Errorf("dial: %w", err) 89 | } 90 | defer con.Close() 91 | 92 | ch, err := con.Channel() 93 | if err != nil { 94 | return amqp091.Delivery{}, fmt.Errorf("channel: %w", err) 95 | } 96 | defer ch.Close() 97 | 98 | m, err := ch.ConsumeWithContext(ctx, queue, "", true, false, false, false, nil) 99 | if err != nil { 100 | return amqp091.Delivery{}, fmt.Errorf("consume: %w", err) 101 | } 102 | 103 | select { 104 | case v, ok := <-m: 105 | if !ok { 106 | return amqp091.Delivery{}, fmt.Errorf("consumer closed") 107 | } 108 | return v, nil 109 | case <-ctx.Done(): 110 | return amqp091.Delivery{}, ctx.Err() 111 | } 112 | } 113 | 114 | func define(exchange string, queue string, routingKey string) error { 115 | con, err := amqp091.DialConfig(amqpURL, amqp091.Config{ 116 | Dial: func(network, addr string) (net.Conn, error) { 117 | return net.DialTimeout(network, addr, 5*time.Second) 118 | }, 119 | }) 120 | if err != nil { 121 | return fmt.Errorf("dial: %w", err) 122 | } 123 | defer con.Close() 124 | 125 | ch, err := con.Channel() 126 | if err != nil { 127 | return fmt.Errorf("channel: %w", err) 128 | } 129 | defer ch.Close() 130 | 131 | if exchange != "" { 132 | if err := ch.ExchangeDeclare(exchange, "direct", true, false, false, false, nil); err != nil { 133 | return fmt.Errorf("decalre exchange: %w", err) 134 | } 135 | } 136 | 137 | if queue != "" { 138 | if _, err := ch.QueueDeclare(queue, true, false, false, false, nil); err != nil { 139 | return fmt.Errorf("decalre queue: %w", err) 140 | } 141 | } 142 | 143 | if routingKey != "" && queue != "" && exchange != "" { 144 | if err := ch.QueueBind(queue, routingKey, exchange, false, nil); err != nil { 145 | return fmt.Errorf("bind queue: %w", err) 146 | } 147 | } 148 | return nil 149 | } 150 | 151 | func TestMain(m *testing.M) { 152 | // uses a sensible default on windows (tcp/http) and linux/osx (socket) 153 | pool, err := dockertest.NewPool("") 154 | if err != nil { 155 | log.Fatalf("Could not construct pool: %s", err) 156 | } 157 | 158 | // uses pool to try to connect to Docker 159 | err = pool.Client.Ping() 160 | if err != nil { 161 | log.Fatalf("Could not connect to Docker: %s", err) 162 | } 163 | 164 | // pulls an image, creates a container based on it and runs it 165 | resource, err := pool.Run("rabbitmq", "3.12", []string{}) 166 | if err != nil { 167 | log.Fatalf("Could not start resource: %s", err) 168 | } 169 | 170 | // exponential backoff-retry, because the application in the container might not be ready to accept connections yet 171 | if err := pool.Retry(func() error { 172 | amqpURL = fmt.Sprintf("amqp://guest:guest@localhost:%s", resource.GetPort("5672/tcp")) 173 | 174 | con, err := amqp091.DialConfig(amqpURL, amqp091.Config{ 175 | Dial: func(network, addr string) (net.Conn, error) { 176 | return net.DialTimeout(network, addr, 5*time.Second) 177 | }, 178 | }) 179 | if err != nil { 180 | return err 181 | } 182 | _ = con.Close() 183 | return nil 184 | }); err != nil { 185 | log.Fatalf("Could not connect to database: %s", err) 186 | } 187 | 188 | code := m.Run() 189 | 190 | // You can't defer this because os.Exit doesn't care for defer 191 | if err := pool.Purge(resource); err != nil { 192 | log.Fatalf("Could not purge resource: %s", err) 193 | } 194 | 195 | os.Exit(code) 196 | } 197 | -------------------------------------------------------------------------------- /internal/notifications/types.go: -------------------------------------------------------------------------------- 1 | package notifications 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/reddec/web-form/internal/schema" 7 | ) 8 | 9 | type Notification interface { 10 | Dispatch(ctx context.Context, event schema.NotifyContext) error 11 | } 12 | 13 | type NotificationFunc func(ctx context.Context, event schema.NotifyContext) error 14 | 15 | func (nf NotificationFunc) Dispatch(ctx context.Context, event schema.NotifyContext) error { 16 | return nf(ctx, event) 17 | } 18 | -------------------------------------------------------------------------------- /internal/notifications/webhook/webhook_test.go: -------------------------------------------------------------------------------- 1 | package webhook_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net/http" 8 | "net/http/httptest" 9 | "testing" 10 | "time" 11 | 12 | "github.com/reddec/web-form/internal/notifications/webhook" 13 | "github.com/reddec/web-form/internal/schema" 14 | "github.com/stretchr/testify/assert" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | func TestDispatcher_Dispatch(t *testing.T) { 19 | global, globalCancel := context.WithTimeout(context.Background(), time.Minute) 20 | defer globalCancel() 21 | 22 | dispatcher := webhook.New(1) 23 | go func() { 24 | dispatcher.Run(global) 25 | }() 26 | 27 | t.Run("defaults", func(t *testing.T) { 28 | ctx, cancel := createTestContext(global) 29 | defer cancel() 30 | server, requests := createTestServer(t) 31 | defer server.Close() 32 | 33 | notify := dispatcher.Create(schema.Webhook{ 34 | URL: server.URL, 35 | }) 36 | 37 | err := notify.Dispatch(ctx, schema.NotifyContext{ 38 | Result: map[string]any{"Name": t.Name()}, 39 | }) 40 | 41 | require.NoError(t, err) 42 | 43 | req := requireReceive(t, ctx, requests) 44 | pd, err := io.ReadAll(req.Body) 45 | require.NoError(t, err) 46 | assert.Equal(t, `{"Name":"`+t.Name()+`"}`, string(pd)) 47 | assert.Equal(t, http.MethodPost, req.Method) 48 | }) 49 | 50 | t.Run("custom method", func(t *testing.T) { 51 | ctx, cancel := createTestContext(global) 52 | defer cancel() 53 | server, requests := createTestServer(t) 54 | defer server.Close() 55 | 56 | notify := dispatcher.Create(schema.Webhook{ 57 | URL: server.URL, 58 | Method: http.MethodPut, 59 | }) 60 | 61 | err := notify.Dispatch(ctx, schema.NotifyContext{ 62 | Result: map[string]any{"Name": t.Name()}, 63 | }) 64 | require.NoError(t, err) 65 | 66 | req := requireReceive(t, ctx, requests) 67 | pd, err := io.ReadAll(req.Body) 68 | require.NoError(t, err) 69 | assert.Equal(t, `{"Name":"`+t.Name()+`"}`, string(pd)) 70 | assert.Equal(t, http.MethodPut, req.Method) 71 | }) 72 | 73 | t.Run("custom headers", func(t *testing.T) { 74 | ctx, cancel := createTestContext(global) 75 | defer cancel() 76 | server, requests := createTestServer(t) 77 | defer server.Close() 78 | 79 | notify := dispatcher.Create(schema.Webhook{ 80 | URL: server.URL, 81 | Headers: map[string]string{ 82 | "Authorization": "foo bar", 83 | }, 84 | }) 85 | 86 | err := notify.Dispatch(ctx, schema.NotifyContext{ 87 | Result: map[string]any{"Name": t.Name()}, 88 | }) 89 | require.NoError(t, err) 90 | 91 | req := requireReceive(t, ctx, requests) 92 | pd, err := io.ReadAll(req.Body) 93 | require.NoError(t, err) 94 | assert.Equal(t, `{"Name":"`+t.Name()+`"}`, string(pd)) 95 | assert.Equal(t, http.MethodPost, req.Method) 96 | assert.Equal(t, "foo bar", req.Header.Get("Authorization")) 97 | }) 98 | } 99 | 100 | func createTestServer(t *testing.T) (*httptest.Server, <-chan *http.Request) { 101 | var arrived = make(chan *http.Request, 1) 102 | 103 | testServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { 104 | buffer, err := io.ReadAll(request.Body) 105 | require.NoError(t, err) 106 | request.Body = io.NopCloser(bytes.NewReader(buffer)) 107 | arrived <- request 108 | writer.WriteHeader(http.StatusOK) 109 | })) 110 | 111 | return testServer, arrived 112 | } 113 | 114 | func createTestContext(parent context.Context) (context.Context, context.CancelFunc) { 115 | return context.WithTimeout(parent, 5*time.Second) 116 | } 117 | 118 | func requireReceive(t *testing.T, ctx context.Context, requests <-chan *http.Request) *http.Request { 119 | select { 120 | case v, ok := <-requests: 121 | require.True(t, ok) 122 | return v 123 | case <-ctx.Done(): 124 | require.NoError(t, ctx.Err()) 125 | panic("finished") 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /internal/notifications/webhook/webhooks.go: -------------------------------------------------------------------------------- 1 | package webhook 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "log/slog" 10 | "net/http" 11 | "net/url" 12 | "sync" 13 | "time" 14 | 15 | "github.com/reddec/web-form/internal/notifications" 16 | "github.com/reddec/web-form/internal/schema" 17 | ) 18 | 19 | var ( 20 | ErrNonSuccessCode = errors.New("non-2xx response code") 21 | ) 22 | 23 | const ( 24 | defaultTimeout = 10 * time.Second 25 | defaultRetries = 3 26 | defaultInterval = 15 * time.Second 27 | defaultMethod = http.MethodPost 28 | ) 29 | 30 | func New(buffer int) *Dispatcher { 31 | return &Dispatcher{tasks: make(chan webhookTask, buffer)} 32 | } 33 | 34 | type Dispatcher struct { 35 | tasks chan webhookTask 36 | } 37 | 38 | func (wd *Dispatcher) Create(webhook schema.Webhook) notifications.Notification { 39 | if webhook.Timeout <= 0 { 40 | webhook.Timeout = defaultTimeout 41 | } 42 | if webhook.Retry == 0 { 43 | webhook.Retry = defaultRetries 44 | } 45 | if webhook.Interval <= 0 { 46 | webhook.Interval = defaultInterval 47 | } 48 | if webhook.Method == "" { 49 | webhook.Method = defaultMethod 50 | } 51 | if !webhook.Message.Valid { 52 | if webhook.Headers == nil { 53 | webhook.Headers = make(map[string]string) 54 | } 55 | if _, ok := webhook.Headers["Content-Type"]; !ok { 56 | webhook.Headers["Content-Type"] = "application/json" 57 | } 58 | webhook.Message = schema.MustTemplate[schema.NotifyContext]("{{.Result | toJson}}") 59 | } 60 | 61 | return notifications.NotificationFunc(func(ctx context.Context, event schema.NotifyContext) error { 62 | payload, err := webhook.Message.Bytes(&event) 63 | if err != nil { 64 | return fmt.Errorf("render webhook: %w", err) 65 | } 66 | return wd.enqueue(ctx, webhook, payload) 67 | }) 68 | } 69 | 70 | func (wd *Dispatcher) Run(ctx context.Context) { 71 | var wg sync.WaitGroup 72 | defer wg.Wait() 73 | for { 74 | select { 75 | case task, ok := <-wd.tasks: 76 | if !ok { 77 | return 78 | } 79 | wg.Add(1) 80 | go func() { 81 | defer wg.Done() 82 | task.Send(ctx) 83 | }() 84 | case <-ctx.Done(): 85 | return 86 | } 87 | } 88 | } 89 | 90 | func (wd *Dispatcher) enqueue(ctx context.Context, webhook schema.Webhook, payload []byte) error { 91 | select { 92 | case wd.tasks <- webhookTask{webhook: webhook, payload: payload}: 93 | case <-ctx.Done(): 94 | return ctx.Err() 95 | } 96 | return nil 97 | } 98 | 99 | type webhookTask struct { 100 | webhook schema.Webhook 101 | payload []byte 102 | } 103 | 104 | func (wt *webhookTask) Send(global context.Context) { 105 | u, err := url.Parse(wt.webhook.URL) 106 | if err != nil { 107 | slog.Error("webhook url invalid - skipping", "url", wt.webhook.URL, "error", err) 108 | return 109 | } 110 | var attempt int 111 | for { 112 | if err := wt.trySend(global); err != nil { 113 | slog.Warn("failed deliver webhook", "url", u.Redacted(), "error", err, "attempt", attempt+1, "retries", wt.webhook.Retry, "retry-after", wt.webhook.Interval) 114 | } else { 115 | slog.Info("webhook delivered", "url", u.Redacted(), "attempt", attempt+1, "retries", wt.webhook.Retry) 116 | break 117 | } 118 | 119 | if attempt >= wt.webhook.Retry { 120 | break 121 | } 122 | select { 123 | case <-time.After(wt.webhook.Timeout): 124 | case <-global.Done(): 125 | slog.Info("webhook retry stopped due to global context stop") 126 | return 127 | } 128 | attempt++ 129 | } 130 | } 131 | 132 | func (wt *webhookTask) trySend(global context.Context) error { 133 | ctx, cancel := context.WithTimeout(global, wt.webhook.Timeout) 134 | defer cancel() 135 | 136 | req, err := http.NewRequestWithContext(ctx, wt.webhook.Method, wt.webhook.URL, bytes.NewReader(wt.payload)) 137 | if err != nil { 138 | return fmt.Errorf("create request: %w", err) 139 | } 140 | 141 | for k, v := range wt.webhook.Headers { 142 | req.Header.Set(k, v) 143 | } 144 | 145 | res, err := http.DefaultClient.Do(req) 146 | if err != nil { 147 | return fmt.Errorf("execute request: %w", err) 148 | } 149 | defer res.Body.Close() 150 | 151 | _, _ = io.Copy(io.Discard, res.Body) // drain content to keep connection healthy 152 | 153 | if res.StatusCode/100 != 2 { 154 | return fmt.Errorf("%w: %d", ErrNonSuccessCode, res.StatusCode) 155 | } 156 | return nil 157 | } 158 | -------------------------------------------------------------------------------- /internal/schema/loader.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "io/fs" 8 | "path/filepath" 9 | "strings" 10 | 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | func FormsFromStream(reader io.Reader) ([]Form, error) { 15 | dec := yaml.NewDecoder(reader) 16 | var forms []Form 17 | for { 18 | form := Default() 19 | err := dec.Decode(&form) 20 | if errors.Is(err, io.EOF) { 21 | break 22 | } 23 | if err != nil { 24 | return nil, err 25 | } 26 | forms = append(forms, form) 27 | } 28 | 29 | return forms, nil 30 | } 31 | 32 | func FormsFromFile(fs fs.FS, file string) ([]Form, error) { 33 | f, err := fs.Open(file) 34 | if err != nil { 35 | return nil, fmt.Errorf("open: %w", err) 36 | } 37 | defer f.Close() 38 | 39 | forms, err := FormsFromStream(f) 40 | if err != nil { 41 | return nil, fmt.Errorf("read %q: %w", file, err) 42 | } 43 | 44 | name := file[:len(file)-len(filepath.Ext(file))] 45 | for i := range forms { 46 | if forms[i].Name == "" { 47 | forms[i].Name = name 48 | } 49 | if forms[i].Table == "" { 50 | forms[i].Table = name 51 | } 52 | } 53 | 54 | return forms, nil 55 | } 56 | 57 | func FormsFromFS(src fs.FS) ([]Form, error) { 58 | var forms []Form 59 | err := fs.WalkDir(src, ".", func(path string, d fs.DirEntry, err error) error { 60 | if err != nil { 61 | return err 62 | } 63 | ext := strings.ToLower(filepath.Ext(path)) 64 | if d.IsDir() || !(ext == ".yaml" || ext == ".yml" || ext == ".json") { 65 | return nil 66 | } 67 | 68 | list, err := FormsFromFile(src, path) 69 | if err != nil { 70 | return fmt.Errorf("read forms from FS: %w", err) 71 | } 72 | forms = append(forms, list...) 73 | return nil 74 | }) 75 | 76 | if err != nil { 77 | return nil, fmt.Errorf("read forms: %w", err) 78 | } 79 | return forms, nil 80 | } 81 | -------------------------------------------------------------------------------- /internal/schema/parser.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/google/cel-go/cel" 13 | "github.com/reddec/web-form/internal/utils" 14 | ) 15 | 16 | var ( 17 | ErrInvalidType = errors.New("field type invalid") 18 | ErrRequiredField = errors.New("required field not set") 19 | ErrWrongPattern = errors.New("doesn't match pattern") 20 | ) 21 | 22 | func (t *Type) UnmarshalText(text []byte) error { 23 | v := Type(text) 24 | switch v { 25 | case "": 26 | *t = TypeString 27 | case TypeString, TypeBoolean, TypeFloat, TypeInteger, TypeDate, TypeDateTime: 28 | *t = v 29 | default: 30 | return fmt.Errorf("field type %q: %w", v, ErrInvalidType) 31 | } 32 | return nil 33 | } 34 | 35 | func (t Type) Parse(value string, locale *time.Location) (any, error) { 36 | switch t { 37 | case TypeString: 38 | return value, nil 39 | case TypeInteger: 40 | return strconv.ParseInt(value, 10, 64) 41 | case TypeFloat: 42 | return strconv.ParseFloat(value, 64) 43 | case TypeBoolean: 44 | return strconv.ParseBool(value) 45 | case TypeDate: 46 | return time.ParseInLocation("2006-01-02", value, locale) 47 | case TypeDateTime: 48 | return time.ParseInLocation("2006-01-02T15:04", value, locale) // html type 49 | default: 50 | return value, nil 51 | } 52 | } 53 | 54 | func (f *Field) Parse(value string, locale *time.Location, viewCtx *RequestContext) (any, error) { 55 | value = strings.TrimSpace(value) 56 | if value == "" { 57 | v, err := f.Default.String(viewCtx) 58 | if err != nil { 59 | return nil, fmt.Errorf("render default value: %w", err) 60 | } 61 | value = v 62 | } 63 | if value == "" && f.Required { 64 | return nil, ErrRequiredField 65 | } 66 | 67 | if (f.Type == "" || f.Type == TypeString) && f.Pattern != "" { 68 | if ok, _ := regexp.MatchString(f.Pattern, value); !ok { 69 | return nil, fmt.Errorf("%w: %q", ErrWrongPattern, f.Pattern) 70 | } 71 | } 72 | 73 | return f.Type.Parse(value, locale) 74 | } 75 | 76 | func OptionValues(options ...Option) utils.Set[string] { 77 | var ans = make([]string, 0, len(options)) 78 | for _, opt := range options { 79 | if opt.Value != "" { 80 | ans = append(ans, opt.Value) 81 | } else { 82 | ans = append(ans, opt.Label) 83 | } 84 | } 85 | return utils.NewSet(ans...) 86 | } 87 | 88 | func (p *Policy) UnmarshalText(text []byte) error { 89 | env, err := cel.NewEnv( 90 | cel.Variable("user", cel.StringType), 91 | cel.Variable("email", cel.StringType), 92 | cel.Variable("groups", cel.ListType(cel.StringType)), 93 | ) 94 | if err != nil { 95 | return fmt.Errorf("create CEL env: %w", err) 96 | } 97 | ast, issues := env.Compile(string(text)) 98 | if issues != nil { 99 | return fmt.Errorf("parse policy %q: %w", string(text), issues.Err()) 100 | } 101 | prog, err := env.Program(ast) 102 | if err != nil { 103 | return fmt.Errorf("compile CEL AST %q: %w", string(text), err) 104 | } 105 | p.Program = prog 106 | return nil 107 | } 108 | 109 | type credsKey struct{} 110 | 111 | func WithCredentials(ctx context.Context, creds *Credentials) context.Context { 112 | return context.WithValue(ctx, credsKey{}, creds) 113 | } 114 | 115 | func CredentialsFromContext(ctx context.Context) *Credentials { 116 | c, _ := ctx.Value(credsKey{}).(*Credentials) 117 | return c 118 | } 119 | 120 | // ParseForm converts user request to parsed field. 121 | // 122 | //nolint:cyclop 123 | func ParseForm(definition *Form, tzLocation *time.Location, viewCtx *RequestContext) (map[string]any, []FieldError) { 124 | var fields = make(map[string]any, len(definition.Fields)) 125 | var fieldErrors []FieldError 126 | 127 | for _, field := range definition.Fields { 128 | field := field 129 | 130 | var values []string 131 | if field.Hidden || field.Disabled { 132 | // if field is non-accessible by user we shall ignore user input and use default as value 133 | v, err := field.Default.String(viewCtx) 134 | if err != nil { 135 | // super abnormal situation - default field failed so it's unfixable by user until configuration update 136 | fieldErrors = append(fieldErrors, FieldError{ 137 | Name: field.Name, 138 | Error: err, 139 | }) 140 | continue 141 | } 142 | values = append(values, v) 143 | } else { 144 | // collect all user input (could be more than one in case of array) 145 | values = utils.Uniq(viewCtx.Form[field.Name]) 146 | } 147 | 148 | // corner case for checkbox - browser will not send value if field not selected. 149 | // we will try using default value. 150 | if field.Type == TypeBoolean && len(values) == 0 && field.Default.Valid { 151 | v, err := field.Default.String(viewCtx) 152 | if err != nil { 153 | fieldErrors = append(fieldErrors, FieldError{ 154 | Name: field.Name, 155 | Error: err, 156 | }) 157 | continue 158 | } 159 | values = append(values, v) 160 | } 161 | 162 | if len(field.Options) > 0 { 163 | // we need to check that all values belong to allowed values before parsing 164 | // since we are using plain text comparison. 165 | options := OptionValues(field.Options...) 166 | if !options.Has(values...) { 167 | fieldErrors = append(fieldErrors, FieldError{ 168 | Name: field.Name, 169 | Error: errors.New("selected not allowed option"), 170 | }) 171 | continue 172 | } 173 | } 174 | 175 | parsedValues, err := parseValues(values, &field, tzLocation, viewCtx) 176 | if err != nil { 177 | fieldErrors = append(fieldErrors, FieldError{ 178 | Name: field.Name, 179 | Error: err, 180 | }) 181 | continue 182 | } 183 | 184 | if field.Required && len(parsedValues) == 0 { 185 | // corner case - empty array for required field. Can happen if multi-select and nothing selected. 186 | // for empty values it will be checked by field parser. 187 | // 188 | // We also need parse first to exclude empty values. 189 | fieldErrors = append(fieldErrors, FieldError{ 190 | Name: field.Name, 191 | Error: errors.New("required field is not provided"), 192 | }) 193 | continue 194 | } 195 | 196 | if field.Multiple { 197 | fields[field.Name] = parsedValues 198 | } else if len(parsedValues) > 0 { 199 | fields[field.Name] = parsedValues[0] 200 | } 201 | } 202 | return fields, fieldErrors 203 | } 204 | 205 | func parseValues(values []string, field *Field, tzLocation *time.Location, viewCtx *RequestContext) ([]any, error) { 206 | var ans = make([]any, 0, len(values)) 207 | for _, value := range values { 208 | value = strings.TrimSpace(value) 209 | if len(value) == 0 { 210 | continue 211 | } 212 | v, err := field.Parse(value, tzLocation, viewCtx) 213 | if err != nil { 214 | return nil, err 215 | } 216 | ans = append(ans, v) 217 | } 218 | return ans, nil 219 | } 220 | 221 | type FieldError struct { 222 | Name string 223 | Error error 224 | } 225 | -------------------------------------------------------------------------------- /internal/schema/template.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/url" 7 | "text/template" 8 | 9 | "github.com/reddec/web-form/internal/utils" 10 | ) 11 | 12 | func MustTemplate[T any](text string) Template[T] { 13 | v, err := NewTemplate[T](text) 14 | if err != nil { 15 | panic(err) 16 | } 17 | return v 18 | } 19 | 20 | func NewTemplate[T any](text string) (Template[T], error) { 21 | v, err := template.New("").Funcs(utils.TemplateFuncs()).Parse(text) 22 | if err != nil { 23 | return Template[T]{}, err 24 | } 25 | return Template[T]{Value: v, Valid: true}, nil 26 | } 27 | 28 | type Template[T any] struct { 29 | Valid bool 30 | Value *template.Template 31 | } 32 | 33 | func (t *Template[T]) UnmarshalText(text []byte) error { 34 | v, err := NewTemplate[T](string(text)) 35 | if err != nil { 36 | return err 37 | } 38 | *t = v 39 | return nil 40 | } 41 | 42 | func (t *Template[T]) Bytes(data *T) ([]byte, error) { 43 | if !t.Valid { 44 | return nil, nil 45 | } 46 | var buf bytes.Buffer 47 | err := t.Value.Execute(&buf, data) 48 | return buf.Bytes(), err 49 | } 50 | 51 | func (t *Template[T]) String(data *T) (string, error) { 52 | if !t.Valid { 53 | return "", nil 54 | } 55 | v, err := t.Bytes(data) 56 | if err != nil { 57 | return "", err 58 | } 59 | return string(v), nil 60 | } 61 | 62 | // RequestContext is used for rendering default values. 63 | type RequestContext struct { 64 | Headers http.Header 65 | Query url.Values 66 | Form url.Values 67 | Code string // access code 68 | Credentials *Credentials // optional user credentials 69 | } 70 | 71 | func (rc *RequestContext) User() string { 72 | if rc.Credentials == nil { 73 | return "" 74 | } 75 | return rc.Credentials.User 76 | } 77 | 78 | func (rc *RequestContext) Groups() []string { 79 | if rc.Credentials == nil { 80 | return nil 81 | } 82 | return rc.Credentials.Groups 83 | } 84 | 85 | func (rc *RequestContext) Email() string { 86 | if rc.Credentials == nil { 87 | return "" 88 | } 89 | return rc.Credentials.Email 90 | } 91 | 92 | // ResultContext is used for rendering result message (success or fail). 93 | type ResultContext struct { 94 | Form *Form 95 | Result map[string]any 96 | Error error 97 | } 98 | 99 | // NotifyContext is used for rendering notification message. 100 | type NotifyContext struct { 101 | Form *Form 102 | Result map[string]any 103 | } 104 | -------------------------------------------------------------------------------- /internal/schema/types.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "log/slog" 5 | "time" 6 | 7 | "github.com/google/cel-go/cel" 8 | "github.com/reddec/web-form/internal/utils" 9 | ) 10 | 11 | func Default() Form { 12 | return Form{ 13 | Success: MustTemplate[ResultContext]("Thank you for the submission!"), 14 | Failed: MustTemplate[ResultContext]("Something went wrong: `{{.Error}}`"), 15 | } 16 | } 17 | 18 | type Policy struct{ cel.Program } 19 | 20 | type Form struct { 21 | Name string // unique form name, if not set - file name without extension will be used. 22 | Table string // database table name 23 | Title string // optional title for the form 24 | Description Template[RequestContext] // (markdown) optional description of the form 25 | Fields []Field // form fields 26 | Webhooks []Webhook // Webhook (HTTP) notification 27 | AMQP []AMQP // AMQP notification 28 | Success Template[ResultContext] // markdown message for success (also go template with available .Result) 29 | Failed Template[ResultContext] // markdown message for failed (also go template with .Error) 30 | Policy *Policy // optional access policy 31 | Codes utils.Set[string] // optional access tokens 32 | } 33 | 34 | // IsAllowed checks permission for the provided credentials. 35 | // Always allowed for nil policy or for nil creds, and always prohibited if policy returns non-boolean value. 36 | func (f *Form) IsAllowed(creds *Credentials) bool { 37 | if f.Policy == nil || creds == nil { 38 | return true 39 | } 40 | out, _, err := f.Policy.Eval(map[string]any{ 41 | "user": creds.User, 42 | "email": creds.Email, 43 | "groups": creds.Groups, 44 | }) 45 | if err != nil { 46 | slog.Error("failed evaluate policy", "error", err) 47 | return false 48 | } 49 | 50 | v, ok := out.ConvertToType(cel.BoolType).Value().(bool) 51 | return v && ok 52 | } 53 | 54 | func (f *Form) HasCodeAccess() bool { 55 | return len(f.Codes) > 0 56 | } 57 | 58 | type Type string 59 | 60 | const ( 61 | TypeString Type = "string" // default, also for enums 62 | TypeInteger Type = "integer" 63 | TypeFloat Type = "float" 64 | TypeBoolean Type = "boolean" 65 | TypeDate Type = "date" 66 | TypeDateTime Type = "date-time" 67 | ) 68 | 69 | func (t Type) Is(value string) bool { 70 | // special case 71 | if value == "number" { 72 | return t == TypeInteger || t == TypeFloat 73 | } 74 | if t == "" { 75 | return value == "string" 76 | } 77 | return Type(value) == t 78 | } 79 | 80 | type Field struct { 81 | Name string // column name in database. 82 | Label string // short name of field which will be shown in UI, if not set - [Field.Name] is used. 83 | Description string // (markdown) optional description for the field, also shown in UI as help text. 84 | Required bool // make field as required: empty values will not be accepted as well as at least one option should be selected. 85 | Disabled bool // user input will be ignored, by field will be visible in UI. Doesn't apply for options. 86 | Hidden bool // user input will be ignored, field not visible in UI 87 | Default Template[RequestContext] // golang template expression for the default value. Doesn't apply for options with [Field.Multiple]. 88 | Type Type // (default [TypeString]) field type used for user input validation. 89 | Pattern string // optional regexp to validate content, applicable only for string type 90 | Options []Option // allowed values. If [Field.Multiple] set, it acts as "any of", otherwise "one of". 91 | Multiple bool // allow picking multiple options. Column type in database MUST be ARRAY of corresponding type. 92 | Multiline bool // multiline input (for [TypeString] only) 93 | Icon string // optional MDI icon 94 | } 95 | 96 | type Webhook struct { 97 | URL string // URL for POST webhook, where payload is JSON with fields from database column. 98 | Method string // HTTP method to perform, default is POST 99 | Retry int // maximum number of retries (negative means no retries) 100 | Timeout time.Duration // request timeout 101 | Interval time.Duration // interval between attempts (for non 2xx code) 102 | Headers map[string]string // arbitrary headers (ex: Authorization) 103 | Message Template[NotifyContext] // payload content, if not set - JSON representation of storage result 104 | } 105 | 106 | type AMQP struct { 107 | Exchange string // Exchange name, can be empty 108 | Key Template[NotifyContext] // Routing key, usually required 109 | Retry int // maximum number of retries (negative means no retries) 110 | Timeout time.Duration // publish timeout 111 | Interval time.Duration // interval between attempts (publish message) 112 | Headers map[string]string // arbitrary headers (only string supported) 113 | Type string // optional content type property; if not set and message is nil, type is set to application/json 114 | Correlation Template[NotifyContext] // optional correlation ID template (commonly result ID) 115 | ID Template[NotifyContext] // optional correlation ID template (commonly result ID), useful for client-side deduplication 116 | Message Template[NotifyContext] // payload content, if not set - JSON representation of storage result 117 | } 118 | 119 | type Option struct { 120 | Label string // label for UI 121 | Value string // if not set - Label is used, allowed value should match textual representation of form value 122 | } 123 | 124 | type Credentials struct { 125 | User string 126 | Groups []string 127 | Email string 128 | } 129 | -------------------------------------------------------------------------------- /internal/schema/types_test.go: -------------------------------------------------------------------------------- 1 | package schema_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/reddec/web-form/internal/schema" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestPolicy_UnmarshalText(t *testing.T) { 12 | t.Run("groups", func(t *testing.T) { 13 | const txt = ` 14 | policy: '"admin" in groups' 15 | ` 16 | 17 | f, err := schema.FormsFromStream(strings.NewReader(txt)) 18 | require.NoError(t, err) 19 | require.NotEmpty(t, f) 20 | form := f[0] 21 | require.NotNil(t, form.Policy) 22 | 23 | creds := &schema.Credentials{ 24 | User: "foo", 25 | Groups: []string{"bar", "admin"}, 26 | Email: "foo@xample.com", 27 | } 28 | 29 | require.True(t, form.IsAllowed(creds)) 30 | 31 | // check negative 32 | creds = &schema.Credentials{ 33 | User: "foo", 34 | Groups: []string{"bar", "user"}, 35 | Email: "foo@xample.com", 36 | } 37 | 38 | require.False(t, form.IsAllowed(creds)) 39 | }) 40 | 41 | t.Run("name", func(t *testing.T) { 42 | const txt = ` 43 | policy: 'user == "admin"' 44 | ` 45 | 46 | f, err := schema.FormsFromStream(strings.NewReader(txt)) 47 | require.NoError(t, err) 48 | require.NotEmpty(t, f) 49 | form := f[0] 50 | require.NotNil(t, form.Policy) 51 | 52 | creds := &schema.Credentials{ 53 | User: "admin", 54 | Groups: []string{"bar", "admin"}, 55 | Email: "foo@xample.com", 56 | } 57 | 58 | require.True(t, form.IsAllowed(creds)) 59 | 60 | creds = &schema.Credentials{ 61 | User: "not-admin", 62 | Groups: []string{"bar", "admin"}, 63 | Email: "foo@xample.com", 64 | } 65 | 66 | require.False(t, form.IsAllowed(creds)) 67 | }) 68 | 69 | t.Run("emain", func(t *testing.T) { 70 | const txt = ` 71 | policy: 'email.endsWith("@reddec.net")' 72 | ` 73 | 74 | f, err := schema.FormsFromStream(strings.NewReader(txt)) 75 | require.NoError(t, err) 76 | require.NotEmpty(t, f) 77 | form := f[0] 78 | require.NotNil(t, form.Policy) 79 | 80 | creds := &schema.Credentials{ 81 | User: "admin", 82 | Groups: []string{"bar", "admin"}, 83 | Email: "foo@reddec.net", 84 | } 85 | 86 | require.True(t, form.IsAllowed(creds)) 87 | 88 | creds = &schema.Credentials{ 89 | User: "not-admin", 90 | Groups: []string{"bar", "admin"}, 91 | Email: "foo@xample.com", 92 | } 93 | 94 | require.False(t, form.IsAllowed(creds)) 95 | }) 96 | 97 | t.Run("incorrect policy", func(t *testing.T) { 98 | const txt = ` 99 | policy: '"admin" ins groups' 100 | ` 101 | 102 | _, err := schema.FormsFromStream(strings.NewReader(txt)) 103 | require.Error(t, err) 104 | }) 105 | 106 | t.Run("type cast always false", func(t *testing.T) { 107 | const txt = ` 108 | policy: 1 109 | ` 110 | 111 | f, err := schema.FormsFromStream(strings.NewReader(txt)) 112 | require.NoError(t, err) 113 | require.NotEmpty(t, f) 114 | form := f[0] 115 | require.NotNil(t, form.Policy) 116 | 117 | creds := &schema.Credentials{ 118 | User: "admin", 119 | Groups: []string{"bar", "admin"}, 120 | Email: "foo@reddec.net", 121 | } 122 | 123 | require.False(t, form.IsAllowed(creds)) 124 | }) 125 | 126 | t.Run("nil policy means allowed", func(t *testing.T) { 127 | const txt = `name: foo` 128 | 129 | f, err := schema.FormsFromStream(strings.NewReader(txt)) 130 | require.NoError(t, err) 131 | require.NotEmpty(t, f) 132 | form := f[0] 133 | require.Nil(t, form.Policy) 134 | 135 | creds := &schema.Credentials{ 136 | User: "admin", 137 | Groups: []string{"bar", "admin"}, 138 | Email: "foo@reddec.net", 139 | } 140 | 141 | require.True(t, form.IsAllowed(creds)) 142 | }) 143 | } 144 | -------------------------------------------------------------------------------- /internal/storage/db.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "reflect" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/reddec/web-form/internal/utils" 13 | "golang.org/x/exp/maps" 14 | 15 | "github.com/jackc/pgx/v5/stdlib" 16 | migrate "github.com/rubenv/sql-migrate" 17 | 18 | "github.com/jackc/pgx/v5/pgxpool" 19 | "github.com/jmoiron/sqlx" 20 | ) 21 | 22 | var ErrUnsupportedDatabase = errors.New("unsupported database schema") 23 | 24 | // MySQL not supported due to: no RETURNING clause, different escape approaches and other similar uncommon behaviour. 25 | 26 | type DBStore interface { 27 | ClosableStorage 28 | Exec(ctx context.Context, query string) error 29 | Migrate(ctx context.Context, sourceDir string) error 30 | } 31 | 32 | func NewDB(ctx context.Context, dialect string, dbURL string) (DBStore, error) { 33 | switch dialect { 34 | case "postgres", "postgresql", "pgx", "pg", "postgress": 35 | pool, err := pgxpool.New(ctx, dbURL) 36 | if err != nil { 37 | return nil, fmt.Errorf("create pool: %w", err) 38 | } 39 | return &pgStore{pool: pool}, nil 40 | case "sqlite", "sqlite3", "lite", "file", ":memory:", "": 41 | db, err := sqlx.Open("sqlite", dbURL) 42 | if err != nil { 43 | return nil, fmt.Errorf("open DB: %w", err) 44 | } 45 | return &liteStore{pool: db}, nil 46 | default: 47 | return nil, fmt.Errorf("%q: %w", dialect, ErrUnsupportedDatabase) 48 | } 49 | } 50 | 51 | type pgStore struct { 52 | pool *pgxpool.Pool 53 | } 54 | 55 | func (s *pgStore) Store(ctx context.Context, table string, fields map[string]any) (map[string]any, error) { 56 | var query strings.Builder 57 | 58 | keys := maps.Keys(fields) 59 | 60 | query.WriteString("INSERT INTO ") 61 | utils.QuoteBuilder(&query, table, '"') 62 | query.WriteString(" (") 63 | 64 | // escape columns 65 | for i, name := range keys { 66 | if i > 0 { 67 | _, _ = query.WriteString(", ") 68 | } 69 | utils.QuoteBuilder(&query, name, '"') 70 | } 71 | query.WriteString(") VALUES (") 72 | 73 | // generate params 74 | var params = make([]any, 0, len(fields)) 75 | for i := range keys { 76 | if i > 0 { 77 | _, _ = query.WriteString(", ") 78 | } 79 | query.WriteRune('$') 80 | query.WriteString(strconv.Itoa(i + 1)) 81 | params = append(params, fields[keys[i]]) 82 | } 83 | query.WriteString(") RETURNING *") 84 | slog.Debug(query.String()) 85 | 86 | var result = make(map[string]any) 87 | rows, err := s.pool.Query(ctx, query.String(), params...) 88 | if err != nil { 89 | return nil, fmt.Errorf("execute query: %w", err) 90 | } 91 | defer rows.Close() 92 | if !rows.Next() { 93 | return nil, fmt.Errorf("get row: %w", rows.Err()) 94 | } 95 | 96 | descriptions := rows.FieldDescriptions() 97 | values, err := rows.Values() 98 | if err != nil { 99 | return nil, fmt.Errorf("get row values: %w", err) 100 | } 101 | 102 | for i, f := range descriptions { 103 | result[f.Name] = values[i] 104 | } 105 | return result, nil 106 | } 107 | 108 | func (s *pgStore) Exec(ctx context.Context, query string) error { 109 | _, err := s.pool.Exec(ctx, query) 110 | return err 111 | } 112 | 113 | func (s *pgStore) Migrate(ctx context.Context, sourceDir string) error { 114 | cfg := s.pool.Config().ConnConfig 115 | db := stdlib.OpenDB(*cfg) 116 | defer db.Close() 117 | _, err := migrate.ExecContext(ctx, db, "postgres", migrate.FileMigrationSource{Dir: sourceDir}, migrate.Up) 118 | return err 119 | } 120 | 121 | func (s *pgStore) Close() error { 122 | s.pool.Close() 123 | return nil 124 | } 125 | 126 | type liteStore struct { 127 | pool *sqlx.DB 128 | } 129 | 130 | func (s *liteStore) Store(ctx context.Context, table string, fields map[string]any) (map[string]any, error) { 131 | var query strings.Builder 132 | 133 | keys := maps.Keys(fields) 134 | 135 | query.WriteString("INSERT INTO ") 136 | utils.QuoteBuilder(&query, table, '"') 137 | query.WriteString(" (") 138 | 139 | // escape columns 140 | for i, name := range keys { 141 | if i > 0 { 142 | _, _ = query.WriteString(", ") 143 | } 144 | utils.QuoteBuilder(&query, name, '"') 145 | } 146 | query.WriteString(") VALUES (") 147 | 148 | // generate params 149 | var params = make([]any, 0, len(fields)) 150 | for i := range keys { 151 | if i > 0 { 152 | _, _ = query.WriteString(", ") 153 | } 154 | query.WriteRune('?') // difference between PG 155 | 156 | param := fields[keys[i]] 157 | 158 | if isArray(param) { 159 | // corner case for sqlite since it's not supporting native arrays. 160 | // it will concat values via comma without escaping. 161 | // it's slow and prone to errors if values have commas. 162 | param = joinIterable(param, ",") 163 | } 164 | 165 | params = append(params, param) 166 | } 167 | query.WriteString(") RETURNING *") 168 | slog.Debug(query.String()) 169 | 170 | var result = make(map[string]any) 171 | row := s.pool.QueryRowxContext(ctx, query.String(), params...) 172 | 173 | if err := row.MapScan(result); err != nil { 174 | return nil, fmt.Errorf("execute query: %w", err) 175 | } 176 | return result, nil 177 | } 178 | 179 | func (s *liteStore) Exec(ctx context.Context, query string) error { 180 | _, err := s.pool.ExecContext(ctx, query) 181 | return err 182 | } 183 | 184 | func (s *liteStore) Close() error { 185 | return s.pool.Close() 186 | } 187 | 188 | func (s *liteStore) Migrate(ctx context.Context, sourceDir string) error { 189 | _, err := migrate.ExecContext(ctx, s.pool.DB, "sqlite3", migrate.FileMigrationSource{Dir: sourceDir}, migrate.Up) 190 | return err 191 | } 192 | 193 | func isArray(value any) bool { 194 | kind := reflect.TypeOf(value).Kind() 195 | return kind == reflect.Array || kind == reflect.Slice 196 | } 197 | 198 | func joinIterable(value any, sep string) string { 199 | v := reflect.ValueOf(value) 200 | n := v.Len() 201 | var out strings.Builder 202 | for i := 0; i < n; i++ { 203 | if i > 0 { 204 | out.WriteString(sep) 205 | } 206 | out.WriteString(fmt.Sprint(v.Index(i).Interface())) 207 | } 208 | return out.String() 209 | } 210 | -------------------------------------------------------------------------------- /internal/storage/db_test.go: -------------------------------------------------------------------------------- 1 | package storage_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | "testing" 9 | "time" 10 | 11 | "github.com/reddec/web-form/internal/storage" 12 | 13 | "github.com/jackc/pgx/v5" 14 | "github.com/ory/dockertest/v3" 15 | "github.com/stretchr/testify/assert" 16 | "github.com/stretchr/testify/require" 17 | _ "modernc.org/sqlite" 18 | ) 19 | 20 | var dbURL string 21 | 22 | func TestMain(m *testing.M) { 23 | // uses a sensible default on windows (tcp/http) and linux/osx (socket) 24 | pool, err := dockertest.NewPool("") 25 | if err != nil { 26 | log.Fatalf("Could not construct pool: %s", err) 27 | } 28 | 29 | // uses pool to try to connect to Docker 30 | err = pool.Client.Ping() 31 | if err != nil { 32 | log.Fatalf("Could not connect to Docker: %s", err) 33 | } 34 | 35 | // pulls an image, creates a container based on it and runs it 36 | resource, err := pool.Run("postgres", "14", []string{"POSTGRES_PASSWORD=password"}) 37 | if err != nil { 38 | log.Fatalf("Could not start resource: %s", err) 39 | } 40 | 41 | // exponential backoff-retry, because the application in the container might not be ready to accept connections yet 42 | if err := pool.Retry(func() error { 43 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 44 | defer cancel() 45 | 46 | dbURL = fmt.Sprintf("postgres://postgres:password@localhost:%s/postgres?sslmode=disable", resource.GetPort("5432/tcp")) 47 | db, err := pgx.Connect(ctx, dbURL) 48 | if err != nil { 49 | return err 50 | } 51 | defer db.Close(ctx) 52 | return db.Ping(ctx) 53 | }); err != nil { 54 | log.Fatalf("Could not connect to database: %s", err) 55 | } 56 | 57 | code := m.Run() 58 | 59 | // You can't defer this because os.Exit doesn't care for defer 60 | if err := pool.Purge(resource); err != nil { 61 | log.Fatalf("Could not purge resource: %s", err) 62 | } 63 | 64 | os.Exit(code) 65 | } 66 | 67 | func TestNewDB_pg(t *testing.T) { 68 | // NOTE: postgres by default converts name text case of column names from CREATE TABLE statement to lower. 69 | ctx, cancel := context.WithTimeout(context.Background(), time.Minute) 70 | defer cancel() 71 | 72 | s, err := storage.NewDB(ctx, "postgres", dbURL) 73 | require.NoError(t, err) 74 | defer s.Close() 75 | 76 | err = s.Exec(ctx, `CREATE TABLE pizza ( 77 | ID BIGSERIAL NOT NULL PRIMARY KEY, 78 | CUSTOMER TEXT NOT NULL, 79 | ADDRESS TEXT[] NOT NULL, 80 | QTY BIGINT NOT NULL, 81 | PRICE DOUBLE PRECISION NOT NULL , -- do not real DOUBLE in production 82 | DELIVERED BOOLEAN NOT NULL DEFAULT FALSE, 83 | "WEIRD COLUMN" TEXT NOT NULL DEFAULT 'hello world' 84 | )`) 85 | require.NoError(t, err) 86 | 87 | res, err := s.Store(ctx, "pizza", map[string]any{ 88 | "customer": "demo", 89 | "address": []string{"Little Village", "New York"}, 90 | "qty": 2, 91 | "price": 123.456, 92 | }) 93 | require.NoError(t, err) 94 | 95 | assert.Equal(t, map[string]any{ 96 | "id": int64(1), 97 | "customer": "demo", 98 | "address": []any{"Little Village", "New York"}, 99 | "qty": int64(2), 100 | "price": 123.456, 101 | "delivered": false, 102 | "WEIRD COLUMN": "hello world", 103 | }, res) 104 | } 105 | 106 | func TestNewDB_sqlite(t *testing.T) { 107 | // NOTE: sqlite keep case of column names from CREATE TABLE statement. 108 | 109 | ctx, cancel := context.WithTimeout(context.Background(), time.Minute) 110 | defer cancel() 111 | 112 | s, err := storage.NewDB(ctx, "sqlite", "file::memory:?cache=shared") 113 | require.NoError(t, err) 114 | defer s.Close() 115 | 116 | err = s.Exec(ctx, `CREATE TABLE pizza ( 117 | id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 118 | customer TEXT NOT NULL, 119 | address TEXT NOT NULL, 120 | qty INTEGER NOT NULL, 121 | price DOUBLE PRECISION NOT NULL , -- do not real DOUBLE in production 122 | delivered BOOLEAN NOT NULL DEFAULT FALSE, 123 | "WEIRD COLUMN" TEXT NOT NULL DEFAULT 'hello world' 124 | )`) 125 | require.NoError(t, err) 126 | 127 | res, err := s.Store(ctx, "pizza", map[string]any{ 128 | "customer": "demo", 129 | "address": []string{"Little Village", "New York"}, 130 | "qty": 2, 131 | "price": 123.456, 132 | }) 133 | require.NoError(t, err) 134 | 135 | assert.Equal(t, map[string]any{ 136 | "id": int64(1), 137 | "customer": "demo", 138 | "address": "Little Village,New York", 139 | "qty": int64(2), 140 | "price": 123.456, 141 | "delivered": int64(0), 142 | "WEIRD COLUMN": "hello world", 143 | }, res) 144 | } 145 | -------------------------------------------------------------------------------- /internal/storage/dump.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "maps" 7 | "os" 8 | ) 9 | 10 | // Dump store just prints in JSON content of request. 11 | type Dump struct{} 12 | 13 | func (sd *Dump) Store(_ context.Context, table string, fields map[string]any) (map[string]any, error) { 14 | enc := json.NewEncoder(os.Stdout) 15 | enc.SetIndent("", " ") 16 | return maps.Clone(fields), enc.Encode(dumpItem{ 17 | Table: table, 18 | Fields: fields, 19 | }) 20 | } 21 | 22 | type dumpItem struct { 23 | Table string 24 | Fields map[string]any 25 | } 26 | -------------------------------------------------------------------------------- /internal/storage/file.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "encoding/json" 7 | "fmt" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/oklog/ulid/v2" 12 | ) 13 | 14 | func NewFileStore(rootDir string) *FileStore { 15 | return &FileStore{directory: rootDir} 16 | } 17 | 18 | // FileStore stores each submission as single file in JSON with ULID + .json as name under directory, equal to table name. 19 | // It DOES NOT escape table name AT ALL. 20 | // Result set contains all source fields plus ID (string), equal to filename without extension. 21 | type FileStore struct { 22 | directory string 23 | } 24 | 25 | func (fs *FileStore) Store(_ context.Context, table string, fields map[string]any) (map[string]any, error) { 26 | id, err := ulid.New(ulid.Now(), rand.Reader) 27 | if err != nil { 28 | return nil, fmt.Errorf("generate ULID: %w", err) 29 | } 30 | dir := filepath.Join(fs.directory, table) 31 | if err := os.MkdirAll(dir, 0750); err != nil { 32 | return nil, fmt.Errorf("create base dir %q: %w", dir, err) 33 | } 34 | sid := id.String() 35 | p := filepath.Join(dir, sid+".json") 36 | 37 | var data = make(map[string]any, len(fields)+1) 38 | for k, v := range fields { 39 | data[k] = v 40 | } 41 | data["ID"] = sid 42 | 43 | document, err := json.MarshalIndent(data, "", " ") 44 | if err != nil { 45 | return nil, fmt.Errorf("serialize document: %w", err) 46 | } 47 | 48 | return data, atomicWrite(p, document) 49 | } 50 | 51 | func atomicWrite(file string, content []byte) error { 52 | d := filepath.Dir(file) 53 | n := filepath.Base(file) 54 | f, err := os.CreateTemp(d, n+".tmp.*") 55 | if err != nil { 56 | return fmt.Errorf("create temp file: %w", err) 57 | } 58 | defer os.Remove(f.Name()) 59 | defer f.Close() 60 | 61 | if _, err := f.Write(content); err != nil { 62 | return fmt.Errorf("write temp file: %w", err) 63 | } 64 | if err := f.Close(); err != nil { 65 | return fmt.Errorf("close temp file: %w", err) 66 | } 67 | 68 | return os.Rename(f.Name(), file) 69 | } 70 | -------------------------------------------------------------------------------- /internal/storage/utils.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "io" 6 | ) 7 | 8 | type ClosableStorage interface { 9 | io.Closer 10 | Store(ctx context.Context, table string, fields map[string]any) (map[string]any, error) 11 | } 12 | 13 | func NopCloser(storage interface { 14 | Store(ctx context.Context, table string, fields map[string]any) (map[string]any, error) 15 | }) ClosableStorage { 16 | return &fakeCloser{wrap: storage} 17 | } 18 | 19 | type fakeCloser struct { 20 | wrap interface { 21 | Store(ctx context.Context, table string, fields map[string]any) (map[string]any, error) 22 | } 23 | } 24 | 25 | func (fc fakeCloser) Close() error { 26 | return nil 27 | } 28 | 29 | func (fc fakeCloser) Store(ctx context.Context, table string, fields map[string]any) (map[string]any, error) { 30 | return fc.wrap.Store(ctx, table, fields) 31 | } 32 | -------------------------------------------------------------------------------- /internal/utils/strings.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func Quote(value string, q rune) string { 8 | var out strings.Builder 9 | QuoteBuilder(&out, value, q) 10 | return out.String() 11 | } 12 | 13 | func QuoteBuilder(out interface{ WriteRune(rune) (int, error) }, value string, q rune) { 14 | const escape = '\\' 15 | _, _ = out.WriteRune(q) 16 | for _, c := range value { 17 | if c == q || c == escape { 18 | _, _ = out.WriteRune(escape) 19 | } 20 | _, _ = out.WriteRune(c) 21 | } 22 | _, _ = out.WriteRune(q) 23 | } 24 | -------------------------------------------------------------------------------- /internal/utils/strings_test.go: -------------------------------------------------------------------------------- 1 | package utils_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/reddec/web-form/internal/utils" 7 | ) 8 | 9 | func TestQuote(t *testing.T) { 10 | cases := [][2]string{ 11 | {"foo bar", "`foo bar`"}, 12 | {"foo` bar", "`foo\\` bar`"}, 13 | {"foo`` bar", "`foo\\`\\` bar`"}, 14 | {"foo\\` bar", "`foo\\\\\\` bar`"}, 15 | } 16 | 17 | for _, c := range cases { 18 | enc := utils.Quote(c[0], '`') 19 | if enc != c[1] { 20 | t.Errorf("%s != %s", enc, c[1]) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /internal/utils/templates.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "html/template" 6 | "time" 7 | 8 | "github.com/Masterminds/sprig/v3" 9 | "github.com/yuin/goldmark" 10 | "github.com/yuin/goldmark/extension" 11 | "github.com/yuin/goldmark/renderer/html" 12 | ) 13 | 14 | func TemplateFuncs() template.FuncMap { 15 | // TODO: maybe cache 16 | md := goldmark.New( 17 | goldmark.WithExtensions(extension.GFM), 18 | goldmark.WithRendererOptions( 19 | html.WithHardWraps(), 20 | html.WithXHTML(), 21 | ), 22 | ) 23 | 24 | funcs := sprig.HtmlFuncMap() 25 | funcs["markdown"] = func(value string) (template.HTML, error) { 26 | var buffer bytes.Buffer 27 | err := md.Convert([]byte(value), &buffer) 28 | return template.HTML(buffer.String()), err //nolint:gosec 29 | } 30 | funcs["html"] = func(value string) template.HTML { 31 | return template.HTML(value) //nolint:gosec 32 | } 33 | funcs["timezone"] = func() string { 34 | return time.Local.String() 35 | } 36 | return funcs 37 | } 38 | -------------------------------------------------------------------------------- /internal/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "gopkg.in/yaml.v3" 5 | ) 6 | 7 | func Uniq[T comparable](values []T) []T { 8 | var s = make(map[T]struct{}, len(values)) 9 | var out = make([]T, 0, len(values)) 10 | for _, v := range values { 11 | if _, ok := s[v]; !ok { 12 | s[v] = struct{}{} 13 | out = append(out, v) 14 | } 15 | } 16 | return out 17 | } 18 | 19 | type Set[T comparable] map[T]struct{} 20 | 21 | func NewSet[T comparable](values ...T) Set[T] { 22 | var s = make(Set[T], len(values)) 23 | for _, v := range values { 24 | s[v] = struct{}{} 25 | } 26 | return s 27 | } 28 | 29 | func (s Set[T]) Has(values ...T) bool { 30 | for _, v := range values { 31 | if _, ok := s[v]; !ok { 32 | return false 33 | } 34 | } 35 | return true 36 | } 37 | 38 | func (s Set[T]) Add(values ...T) { 39 | for _, v := range values { 40 | s[v] = struct{}{} 41 | } 42 | } 43 | 44 | func (s *Set[T]) UnmarshalYAML(value *yaml.Node) error { 45 | var data []T 46 | if err := value.Decode(&data); err != nil { 47 | return err 48 | } 49 | *s = NewSet(data...) 50 | return nil 51 | } 52 | -------------------------------------------------------------------------------- /internal/web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/rand" 7 | "encoding/hex" 8 | "fmt" 9 | "html/template" 10 | "io" 11 | "log/slog" 12 | "net/http" 13 | "net/url" 14 | "strings" 15 | "time" 16 | 17 | "github.com/reddec/web-form/internal/schema" 18 | "github.com/reddec/web-form/internal/utils" 19 | ) 20 | 21 | const ( 22 | FormXSRF = "_xsrf" 23 | CookieXSRF = "_xsrf" 24 | ) 25 | 26 | type Captcha interface { 27 | Embed() template.HTML 28 | Validate(form *http.Request) bool 29 | } 30 | 31 | func NewRequest(writer http.ResponseWriter, request *http.Request) *Request { 32 | remoteIP := GetClientIP(request) 33 | 34 | var params = []any{ 35 | "path", request.URL.Path, 36 | "method", request.Method, 37 | "remote_ip", remoteIP, 38 | } 39 | creds := schema.CredentialsFromContext(request.Context()) 40 | if creds != nil { 41 | params = append(params, "user", creds.User) 42 | } 43 | 44 | return &Request{ 45 | logger: slog.With(params...), 46 | writer: writer, 47 | request: request, 48 | creds: creds, 49 | } 50 | } 51 | 52 | type Request struct { 53 | messages []FlashMessage 54 | xsrf string 55 | writer http.ResponseWriter 56 | request *http.Request 57 | logger *slog.Logger 58 | state map[string]any 59 | session map[string]string 60 | creds *schema.Credentials 61 | captchas []Captcha 62 | } 63 | 64 | func (r *Request) VerifyCaptcha() bool { 65 | if r.request.Method != http.MethodPost { 66 | return true 67 | } 68 | for _, captcha := range r.captchas { 69 | if !captcha.Validate(r.request) { 70 | return false 71 | } 72 | } 73 | return true 74 | } 75 | 76 | func (r *Request) VerifyXSRF() bool { 77 | return r.request.Method != http.MethodPost || verifyXSRF(r.request) 78 | } 79 | 80 | func (r *Request) WithCaptcha(captcha ...Captcha) *Request { 81 | r.captchas = captcha 82 | return r 83 | } 84 | 85 | func (r *Request) Request() *http.Request { 86 | return r.request 87 | } 88 | 89 | func (r *Request) Context() context.Context { 90 | return r.request.Context() 91 | } 92 | 93 | func (r *Request) Logger() *slog.Logger { 94 | return r.logger 95 | } 96 | 97 | func (r *Request) Credentials() *schema.Credentials { 98 | return r.creds 99 | } 100 | 101 | // Clear session. 102 | func (r *Request) Clear() { 103 | r.session = nil 104 | } 105 | 106 | // Session values. Visible to clients, but available only via POST. 107 | func (r *Request) Session() map[string]string { 108 | if r.session == nil { 109 | r.session = r.parseSession() 110 | } 111 | return r.session 112 | } 113 | 114 | // Pop value from session. 115 | func (r *Request) Pop(sessionKey string) string { 116 | v := r.session[sessionKey] 117 | delete(r.session, sessionKey) 118 | return v 119 | } 120 | 121 | // Push value to session. 122 | func (r *Request) Push(sessionKey string, value string) *Request { 123 | r.Session()[sessionKey] = value 124 | return r 125 | } 126 | 127 | func (r *Request) Set(key string, state any) *Request { 128 | if r.state == nil { 129 | r.state = make(map[string]any) 130 | } 131 | r.state[key] = state 132 | return r 133 | } 134 | 135 | func (r *Request) State() map[string]any { 136 | return r.state 137 | } 138 | 139 | // Error flash message. 140 | func (r *Request) Error(message any) *Request { 141 | r.Flash("", message, FlashError) 142 | return r 143 | } 144 | 145 | // Info flash message. 146 | func (r *Request) Info(message any) *Request { 147 | r.Flash("", message, FlashInfo) 148 | return r 149 | } 150 | 151 | func (r *Request) Flash(name string, message any, flashType FlashType) *Request { 152 | r.messages = append(r.messages, FlashMessage{ 153 | Text: fmt.Sprint(message), 154 | Type: flashType, 155 | Name: name, 156 | }) 157 | return r 158 | } 159 | 160 | func (r *Request) Messages(names ...string) []FlashMessage { 161 | if len(names) == 0 { 162 | return r.messages 163 | } 164 | idx := utils.NewSet(names...) 165 | var ans []FlashMessage 166 | for _, f := range r.messages { 167 | if idx.Has(f.Name) { 168 | ans = append(ans, f) 169 | } 170 | } 171 | return ans 172 | } 173 | 174 | func (r *Request) EmbedXSRF() template.HTML { 175 | if r.xsrf == "" { 176 | r.xsrf = XSRF(r.writer) 177 | } 178 | return template.HTML(``) //nolint:gosec 179 | } 180 | 181 | func (r *Request) EmbedSession() template.HTML { 182 | var out string 183 | for k, v := range r.session { 184 | out += `` 185 | } 186 | return template.HTML(out) //nolint:gosec 187 | } 188 | 189 | func (r *Request) EmbedCaptcha() template.HTML { 190 | var out string 191 | for _, v := range r.captchas { 192 | out += string(v.Embed()) 193 | } 194 | return template.HTML(out) //nolint:gosec 195 | } 196 | 197 | func (r *Request) Render(code int, view *template.Template) { 198 | var buffer bytes.Buffer 199 | err := view.Execute(&buffer, r) 200 | if err != nil { 201 | slog.Error("failed render", "error", err, "path", r.request.URL.Path, "method", r.request.Method) 202 | r.writer.WriteHeader(http.StatusInternalServerError) 203 | return 204 | } 205 | r.writer.Header().Set("Content-Type", "text/html") 206 | r.writer.WriteHeader(code) 207 | _, _ = r.writer.Write(buffer.Bytes()) 208 | } 209 | 210 | func (r *Request) parseSession() map[string]string { 211 | _ = r.request.PostFormValue("") // let Go parse form properly 212 | 213 | var session = make(map[string]string) 214 | for k := range r.request.PostForm { 215 | if strings.HasPrefix(k, "__") { 216 | session[k[2:]] = r.request.PostForm.Get(k) 217 | } 218 | } 219 | 220 | return session 221 | } 222 | 223 | type FlashType string 224 | 225 | const ( 226 | FlashError FlashType = "danger" 227 | FlashInfo FlashType = "info" 228 | ) 229 | 230 | type FlashMessage struct { 231 | Name string 232 | Text string 233 | Type FlashType 234 | } 235 | 236 | // XSRF protection token. Returned token should be submitted as _xsrf form value. Panics if crypto generator is not available. 237 | func XSRF(writer http.ResponseWriter) string { 238 | var token [32]byte 239 | _, err := io.ReadFull(rand.Reader, token[:]) 240 | if err != nil { 241 | panic(err) 242 | } 243 | 244 | t := hex.EncodeToString(token[:]) 245 | http.SetCookie(writer, &http.Cookie{ 246 | Name: CookieXSRF, 247 | Value: t, 248 | Path: "/", // we have to set cookie on root due to iOS limitations 249 | HttpOnly: true, 250 | SameSite: http.SameSiteStrictMode, 251 | Expires: time.Now().Add(time.Hour), 252 | }) 253 | return t 254 | } 255 | 256 | // verify XSRF token from cookie and form. 257 | func verifyXSRF(req *http.Request) bool { 258 | cookie, err := req.Cookie(CookieXSRF) 259 | if err != nil { 260 | return false 261 | } 262 | formValue := req.FormValue(FormXSRF) 263 | return cookie.Value == formValue && formValue != "" 264 | } 265 | 266 | func GetClientIP(r *http.Request) string { 267 | if xForwardedFor := r.Header.Get("X-Forwarded-For"); xForwardedFor != "" { 268 | parts := strings.Split(xForwardedFor, ",") 269 | for _, part := range parts { 270 | return strings.TrimSpace(part) 271 | } 272 | } 273 | return r.RemoteAddr 274 | } 275 | --------------------------------------------------------------------------------