diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 60e510f5..3102ee6d 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -55,7 +55,7 @@ further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at or +reported by contacting the project team at or . All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with diff --git a/.github/codecov.yml b/.github/codecov.yml index 01b3395b..a604b81b 100644 --- a/.github/codecov.yml +++ b/.github/codecov.yml @@ -1,4 +1,9 @@ ignore: - - "cmd/" # - - "internal/database" # External library + - "cmd/" # CLI entry point + - "internal/database" # External library and generated fakes - "internal/util/logger.go" # External library + - "internal/util/authentication.go" # Authentication not in current scope + - "internal/server/authentication.go" # Authentication not in current scope + - "internal/server/config.go" # Simple struct encoding, cannot error + - "internal/util/helpers.go:83-111" # HandleSignupBody - auth only + - "internal/util/helpers.go:114-142" # HandleSigninBody - auth only diff --git a/.github/dependabot.yml b/.github/dependabot.yml index af411b00..992cc003 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,8 +6,6 @@ updates: interval: weekly target-branch: develop open-pull-requests-limit: 10 - reviewers: - - lukewhrit assignees: - lukewhrit labels: diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5f0a3e6c..e09f5296 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -10,6 +10,6 @@ jobs: - name: setup go uses: actions/setup-go@v4 with: - go-version: 1.22.4 + go-version: 1.25.5 - name: run make run: make spacebin diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 00e9af01..50105f7a 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -10,6 +10,6 @@ jobs: - name: setup go uses: actions/setup-go@v4 with: - go-version: 1.22.4 + go-version: 1.25.5 - name: run make run: make format diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad9276d6..ba9aee21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,3 @@ -name: Publish containers for release - on: release: types: [published] @@ -17,6 +15,11 @@ jobs: - name: Check out the repo uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + with: + platforms: all + - name: Log in to Docker Hub uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a with: @@ -44,6 +47,7 @@ jobs: with: context: . push: true + platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6abbc3b7..fa4660c8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,7 +11,7 @@ jobs: fetch-depth: 2 - uses: actions/setup-go@v4 with: - go-version: 1.22.4 + go-version: 1.25.5 - name: Run coverage run: go test ./... -race -coverprofile=coverage.out -covermode=atomic - name: Upload coverage to Codecov diff --git a/.gitignore b/.gitignore index ede7034c..dd02dad2 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,5 @@ certs/ /Godeps/ # End of https://www.toptal.com/developers/gitignore/api/go +*.out +*.cov diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..309ff5cb --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +golang 1.25.5 diff --git a/Makefile b/Makefile index 56caef9b..8ec00619 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ OUT := bin/spacebin +MIGRATIONS_DIR := internal/database/migrations -.PHONY: clean +.PHONY: clean migrate-up migrate-down all: spacebin @@ -23,3 +24,13 @@ test: coverage: go test ./... -v -race -coverprofile=coverage.out go tool cover -html=coverage.out + +migrate-up: + @if [ -z "$(MIGRATIONS_DRIVER)" ]; then echo "MIGRATIONS_DRIVER must be set (postgres|mysql|sqlite)"; exit 1; fi + @command -v migrate >/dev/null 2>&1 || { echo "golang-migrate CLI (migrate) is required on PATH"; exit 127; } + migrate -path $(MIGRATIONS_DIR)/$(MIGRATIONS_DRIVER) -database "$(SPIRIT_CONNECTION_URI)" up + +migrate-down: + @if [ -z "$(MIGRATIONS_DRIVER)" ]; then echo "MIGRATIONS_DRIVER must be set (postgres|mysql|sqlite)"; exit 1; fi + @command -v migrate >/dev/null 2>&1 || { echo "golang-migrate CLI (migrate) is required on PATH"; exit 127; } + migrate -path $(MIGRATIONS_DIR)/$(MIGRATIONS_DRIVER) -database "$(SPIRIT_CONNECTION_URI)" down diff --git a/README.md b/README.md index 1e1db90b..c59f4267 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,13 @@ Pastebins are a type of online content storage service where users can store pla - [x] Syntax highlighting for all the most popular languages and Raw text mode - [x] SQLite, MySQL, and PostgreSQL Support - [x] Basic Auth for private instances +- [ ] Account system - [ ] Password-protected encrypted pastes - [ ] Paste collections - [ ] Reader view mode (Markdown is formatted and word wrapping is enabled) - [ ] QR Codes -**Vote on future features: [Image/file uploading](https://github.com/lukewhrit/spacebin/discussions/446), [Account system](https://github.com/lukewhrit/spacebin/discussions/447)** - -Looking for a URL shortener too? Try [redeyes](https://github.com/lukewhrit/redeyes). +**Vote on future features: [Image/file uploading](https://github.com/lukewhrit/spacebin/discussions/446)** ## Table of Contents diff --git a/cmd/spacebin/main.go b/cmd/spacebin/main.go index d073e5c0..9969ee95 100644 --- a/cmd/spacebin/main.go +++ b/cmd/spacebin/main.go @@ -75,11 +75,11 @@ func main() { } // Perform migrations - if err := db.Migrate(context.Background()); err != nil { - log.Fatal(). - Err(err). - Msg("Failed migrations; Could not create DOCUMENTS tables.") - } + // if err := db.Migrate(context.Background()); err != nil { + // log.Fatal(). + // Err(err). + // Msg("Failed migrations; Could not create DOCUMENTS tables.") + // } // Create a new server and register middleware, security headers, static files, and handlers m := server.NewServer(&config.Config, db) diff --git a/go.mod b/go.mod index 4d7dca9d..f3bb471f 100644 --- a/go.mod +++ b/go.mod @@ -1,48 +1,48 @@ module github.com/lukewhrit/spacebin -go 1.22.4 +go 1.25 require ( github.com/caarlos0/env/v9 v9.0.0 - github.com/go-chi/chi/v5 v5.1.0 - github.com/go-chi/cors v1.2.1 - github.com/go-chi/httprate v0.14.1 + github.com/go-chi/chi/v5 v5.2.3 + github.com/go-chi/cors v1.2.2 + github.com/go-chi/httprate v0.15.0 github.com/go-ozzo/ozzo-validation/v4 v4.3.0 github.com/lib/pq v1.10.9 github.com/lukewhrit/phrase v1.0.0 - github.com/rs/zerolog v1.33.0 - github.com/stretchr/testify v1.9.0 - golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 + github.com/rs/zerolog v1.34.0 + github.com/stretchr/testify v1.10.0 + golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 ) require ( filippo.io/edwards25519 v1.1.0 // indirect - github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect - modernc.org/libc v1.55.3 // indirect - modernc.org/mathutil v1.6.0 // indirect - modernc.org/memory v1.8.0 // indirect - modernc.org/strutil v1.2.0 // indirect - modernc.org/token v1.1.0 // indirect + github.com/zeebo/xxh3 v1.0.2 // indirect + modernc.org/libc v1.67.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) require ( - github.com/alecthomas/chroma/v2 v2.14.0 + github.com/alecthomas/chroma/v2 v2.22.0 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/go-sql-driver/mysql v1.8.1 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/go-sql-driver/mysql v1.9.3 + github.com/golang-migrate/migrate/v4 v4.19.1 + github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a github.com/kr/pretty v0.3.1 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/sys v0.23.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + golang.org/x/crypto v0.46.0 + golang.org/x/sys v0.39.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/sqlite v1.32.0 + modernc.org/sqlite v1.43.0 ) diff --git a/go.sum b/go.sum index 4146f655..31cfc031 100644 --- a/go.sum +++ b/go.sum @@ -1,46 +1,76 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= -github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= -github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= -github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= -github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= -github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.22.0 h1:PqEhf+ezz5F5owoDeOUKFzW+W3ZJDShNCaHg4sZuItI= +github.com/alecthomas/chroma/v2 v2.22.0/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/caarlos0/env/v9 v9.0.0 h1:SI6JNsOA+y5gj9njpgybykATIylrRMklbs5ch6wO6pc= github.com/caarlos0/env/v9 v9.0.0/go.mod h1:ye5mlCVMYh6tZ+vCgrs/B95sj88cg5Tlnc0XIzgZ020= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= -github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4= +github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI= +github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= -github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= -github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= -github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs= -github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= +github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= +github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= +github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= -github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= -github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A= +github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -52,71 +82,102 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lukewhrit/phrase v1.0.0 h1:6NlKOkb1HoFMQYz/XW3T4pvnwtXEqQUt8L4/XsJDIp8= github.com/lukewhrit/phrase v1.0.0/go.mod h1:599Lf9xFuahn78B7fYvmIkD8inCDBAOJzAEh5UKSoys= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= -github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= -github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/exp v0.0.0-20240823005443-9b4947da3948 h1:kx6Ds3MlpiUHKj7syVnbp57++8WpuKPcR5yjLBjvLEA= -golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ= -golang.org/x/mod v0.20.0 h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0= -golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM= +golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= -golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24= -golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= -modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= -modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= -modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= -modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= -modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= -modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= -modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= -modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= -modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= -modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= -modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= -modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= -modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= -modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= -modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= -modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= -modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= -modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= -modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= -modernc.org/sqlite v1.32.0 h1:6BM4uGza7bWypsw4fdLRsLxut6bHe4c58VeqjRgST8s= -modernc.org/sqlite v1.32.0/go.mod h1:UqoylwmTb9F+IqXERT8bW9zzOWN8qwAIcLdzeBZs4hA= -modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= -modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk= +modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.43.0 h1:8YqiFx3G1VhHTXO2Q00bl1Wz9KhS9Q5okwfp9Y97VnA= +modernc.org/sqlite v1.43.0/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/config/config.go b/internal/config/config.go index 5cf80e25..17efb72b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,15 @@ type Cfg struct { Password string `env:"PASSWORD" envDefault:"" json:"password"` // Basic Auth password. Required to enable Basic Auth ContentSecurityPolicy string `env:"CSP" envDefault:"default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" json:"csp"` // Content Security Policy. Must be changed if you are using analytics. + // Accounts + AccountsEnabled bool `env:"ACCOUNTS_ENABLED" envDefault:"false" json:"accounts_enabled"` // Enable accounts + + // Sessions + SessionTTLHours int64 `env:"SESSION_TTL_HOURS" envDefault:"720" json:"session_ttl_hours"` + SessionCookieSecure bool `env:"SESSION_COOKIE_SECURE" envDefault:"false" json:"session_cookie_secure"` + SessionCookieSameSite string `env:"SESSION_COOKIE_SAMESITE" envDefault:"lax" json:"session_cookie_samesite"` + SessionCookieDomain string `env:"SESSION_COOKIE_DOMAIN" envDefault:"" json:"session_cookie_domain"` + // Document IDLength int `env:"ID_LENGTH" envDefault:"8" json:"id_length"` IDType string `env:"ID_TYPE" envDefault:"key" json:"id_type"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cd9be5e6..36ae9c90 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -42,5 +42,9 @@ func TestLoad(t *testing.T) { ConnectionURI: "host=localhost port=5432 user=spacebin database=spacebin sslmode=disable", ContentSecurityPolicy: "default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';", ExpirationAge: 720, + SessionTTLHours: 720, + SessionCookieSecure: false, + SessionCookieSameSite: "lax", + SessionCookieDomain: "", }) } diff --git a/internal/database/database.go b/internal/database/database.go index 6f2cd50e..447d44cb 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -30,6 +30,20 @@ type Document struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } +type Account struct { + ID int `db:"id" json:"id"` + Username string `db:"username" json:"username"` + Password string `db:"password" json:"password"` + // Documents []Document `db:"documents" json:"documents"` +} + +type Session struct { + Public string `db:"public" json:"public"` + Token string `db:"token" json:"token"` + Secret string `db:"secret" json:"secret"` + Username string `db:"username" json:"username"` +} + //go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 . Database type Database interface { Migrate(ctx context.Context) error @@ -37,4 +51,14 @@ type Database interface { GetDocument(ctx context.Context, id string) (Document, error) CreateDocument(ctx context.Context, id, content string) error + + GetAccount(ctx context.Context, id string) (Account, error) + GetAccountByUsername(ctx context.Context, username string) (Account, error) + CreateAccount(ctx context.Context, username, password string) error + // UpdateAccount(ctx context.Context, id, username, password string) error + DeleteAccount(ctx context.Context, id string) error + + GetSession(ctx context.Context, id string) (Session, error) + CreateSession(ctx context.Context, public, token, secret, username string) error + DeleteSession(ctx context.Context, public string) error } diff --git a/internal/database/database_mysql.go b/internal/database/database_mysql.go index b97392af..747d1d2b 100644 --- a/internal/database/database_mysql.go +++ b/internal/database/database_mysql.go @@ -19,11 +19,16 @@ package database import ( "context" "database/sql" + "errors" "net/url" "strings" "time" _ "github.com/go-sql-driver/mysql" + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/mysql" + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/lukewhrit/spacebin/internal/util" ) type MySQL struct { @@ -42,15 +47,31 @@ func NewMySQL(uri *url.URL) (Database, error) { } func (m *MySQL) Migrate(ctx context.Context) error { - _, err := m.Exec(` -CREATE TABLE IF NOT EXISTS documents ( - id VARCHAR(255) PRIMARY KEY, - content TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -)`) - - return err + _ = ctx + + driver, err := mysql.WithInstance(m.DB, &mysql.Config{}) + + if err != nil { + return err + } + + source, err := iofs.New(migrationFS, "migrations/mysql") + + if err != nil { + return err + } + + migrator, err := migrate.NewWithInstance("iofs", source, "mysql", driver) + + if err != nil { + return err + } + + if err := migrator.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return err + } + + return nil } func (m *MySQL) GetDocument(ctx context.Context, id string) (Document, error) { @@ -77,3 +98,95 @@ func (m *MySQL) CreateDocument(ctx context.Context, id, content string) error { return tx.Commit() } + +func (m *MySQL) GetAccount(ctx context.Context, id string) (Account, error) { + acc := new(Account) + row := m.QueryRow("SELECT * FROM accounts WHERE id=?", id) + err := row.Scan(&acc.ID, &acc.Username, &acc.Password) + + return *acc, err +} + +func (m *MySQL) GetAccountByUsername(ctx context.Context, username string) (Account, error) { + account := new(Account) + row := m.QueryRow("SELECT * FROM accounts WHERE username=?", username) + err := row.Scan(&account.ID, &account.Username, &account.Password) + + return *account, err +} + +func (m *MySQL) CreateAccount(ctx context.Context, username, password string) error { + tx, err := m.Begin() + + if err != nil { + return err + } + + // Add account to database + // Hash and salt the password + _, err = tx.Exec("INSERT INTO accounts (username, password) VALUES (?, ?)", + username, util.HashAndSalt([]byte(password))) + + if err != nil { + return err + } + + return tx.Commit() +} + +func (m *MySQL) DeleteAccount(ctx context.Context, id string) error { + tx, err := m.Begin() + + if err != nil { + return err + } + + _, err = tx.Exec("DELETE FROM accounts WHERE id=?", id) + + if err != nil { + return err + } + + return tx.Commit() +} + +func (m *MySQL) GetSession(ctx context.Context, id string) (Session, error) { + session := new(Session) + row := m.QueryRow("SELECT public, token, secret, username FROM sessions WHERE public=?", id) + err := row.Scan(&session.Public, &session.Token, &session.Secret, &session.Username) + + return *session, err +} + +func (m *MySQL) CreateSession(ctx context.Context, public, token, secret, username string) error { + tx, err := m.Begin() + + if err != nil { + return err + } + + _, err = tx.Exec("INSERT INTO sessions (public, token, secret, username) VALUES (?, ?, ?, ?)", + public, token, secret, username) + + if err != nil { + return err + } + + return tx.Commit() +} + +func (m *MySQL) DeleteSession(ctx context.Context, public string) error { + tx, err := m.Begin() + + if err != nil { + return err + } + + _, err = tx.Exec("DELETE FROM sessions WHERE public=?", public) + + if err != nil { + return err + } + + return tx.Commit() +} diff --git a/internal/database/database_pg.go b/internal/database/database_pg.go index 83ed2fd6..1f30e322 100644 --- a/internal/database/database_pg.go +++ b/internal/database/database_pg.go @@ -21,7 +21,11 @@ import ( "database/sql" "net/url" + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/postgres" + "github.com/golang-migrate/migrate/v4/source/iofs" _ "github.com/lib/pq" + "github.com/lukewhrit/spacebin/internal/util" ) type Postgres struct { @@ -35,15 +39,32 @@ func NewPostgres(uri *url.URL) (Database, error) { } func (p *Postgres) Migrate(ctx context.Context) error { - _, err := p.Exec(` -CREATE TABLE IF NOT EXISTS documents ( - id varchar(255) PRIMARY KEY, - content text NOT NULL, - created_at timestamp with time zone DEFAULT now(), - updated_at timestamp with time zone DEFAULT now() -)`) - - return err + _ = ctx + + driver, err := postgres.WithInstance(p.DB, &postgres.Config{}) + + if err != nil { + return err + } + + src, err := iofs.New(migrationFS, "migrations") + + if err != nil { + return err + } + + m, err := migrate.NewWithInstance("iofs", src, "postgres", driver) + + if err != nil { + return err + } + + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + return err + } + + return nil + } func (p *Postgres) GetDocument(ctx context.Context, id string) (Document, error) { @@ -70,3 +91,95 @@ func (p *Postgres) CreateDocument(ctx context.Context, id, content string) error return tx.Commit() } + +func (p *Postgres) GetAccount(ctx context.Context, id string) (Account, error) { + account := new(Account) + row := p.QueryRow("SELECT * FROM accounts WHERE id=$1", id) + err := row.Scan(&account.ID, &account.Username, &account.Password) + + return *account, err +} + +func (p *Postgres) GetAccountByUsername(ctx context.Context, username string) (Account, error) { + account := new(Account) + row := p.QueryRow("SELECT * FROM accounts WHERE username=$1", username) + err := row.Scan(&account.ID, &account.Username, &account.Password) + + return *account, err +} + +func (p *Postgres) CreateAccount(ctx context.Context, username, password string) error { + tx, err := p.Begin() + + if err != nil { + return err + } + + // Add account to database + // Hash and salt the password + _, err = tx.Exec("INSERT INTO accounts (username, password) VALUES ($1, $2)", + username, util.HashAndSalt([]byte(password))) + + if err != nil { + return err + } + + return tx.Commit() +} + +func (p *Postgres) DeleteAccount(ctx context.Context, id string) error { + tx, err := p.Begin() + + if err != nil { + return err + } + + _, err = tx.Exec("DELETE FROM accounts WHERE id=$1", id) + + if err != nil { + return err + } + + return tx.Commit() +} + +func (p *Postgres) GetSession(ctx context.Context, id string) (Session, error) { + session := new(Session) + row := p.QueryRow("SELECT public, token, secret, username FROM sessions WHERE public=$1", id) + err := row.Scan(&session.Public, &session.Token, &session.Secret, &session.Username) + + return *session, err +} + +func (p *Postgres) CreateSession(ctx context.Context, public, token, secret, username string) error { + tx, err := p.Begin() + + if err != nil { + return err + } + + _, err = tx.Exec("INSERT INTO sessions (public, token, secret, username) VALUES ($1, $2, $3, $4)", + public, token, secret, username) + + if err != nil { + return err + } + + return tx.Commit() +} + +func (p *Postgres) DeleteSession(ctx context.Context, public string) error { + tx, err := p.Begin() + + if err != nil { + return err + } + + _, err = tx.Exec("DELETE FROM sessions WHERE public=$1", public) + + if err != nil { + return err + } + + return tx.Commit() +} diff --git a/internal/database/database_sqlite.go b/internal/database/database_sqlite.go index 288bbcf9..09620e39 100644 --- a/internal/database/database_sqlite.go +++ b/internal/database/database_sqlite.go @@ -19,9 +19,14 @@ package database import ( "context" "database/sql" + "errors" "net/url" "sync" + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/sqlite" + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/lukewhrit/spacebin/internal/util" _ "modernc.org/sqlite" ) @@ -31,21 +36,51 @@ type SQLite struct { } func NewSQLite(uri *url.URL) (Database, error) { - db, err := sql.Open("sqlite", uri.Host) + dbPath := uri.Path + + if uri.Scheme == "sqlite" && uri.Host == ":memory:" { + dbPath = ":memory:" + } else { + dbPath = uri.Path + if len(dbPath) > 0 && dbPath[0] == '/' { + dbPath = dbPath[1:] + } + } + + db, err := sql.Open("sqlite", dbPath) return &SQLite{db, sync.RWMutex{}}, err } func (s *SQLite) Migrate(ctx context.Context) error { - _, err := s.Exec(` -CREATE TABLE IF NOT EXISTS documents ( - id TEXT PRIMARY KEY, - content TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - usdated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -);`) - - return err + _ = ctx + + s.Lock() + defer s.Unlock() + + driver, err := sqlite.WithInstance(s.DB, &sqlite.Config{}) + + if err != nil { + return err + } + + source, err := iofs.New(migrationFS, "migrations/sqlite") + + if err != nil { + return err + } + + migrator, err := migrate.NewWithInstance("iofs", source, "sqlite", driver) + + if err != nil { + return err + } + + if err := migrator.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return err + } + + return nil } func (s *SQLite) GetDocument(ctx context.Context, id string) (Document, error) { @@ -78,3 +113,113 @@ func (s *SQLite) CreateDocument(ctx context.Context, id, content string) error { return tx.Commit() } + +func (s *SQLite) GetAccount(ctx context.Context, id string) (Account, error) { + s.RLock() + defer s.RUnlock() + + acc := new(Account) + row := s.QueryRow("SELECT * FROM accounts WHERE id=$1", id) + err := row.Scan(&acc.ID, &acc.Username, &acc.Password) + + return *acc, err +} + +func (s *SQLite) GetAccountByUsername(ctx context.Context, username string) (Account, error) { + account := new(Account) + row := s.QueryRow("SELECT * FROM accounts WHERE username=$1", username) + err := row.Scan(&account.ID, &account.Username, &account.Password) + + return *account, err +} + +func (s *SQLite) CreateAccount(ctx context.Context, username, password string) error { + s.Lock() + defer s.Unlock() + + tx, err := s.Begin() + + if err != nil { + return err + } + + // Add account to database + // Hash and salt the password + _, err = tx.Exec("INSERT INTO accounts (username, password) VALUES ($1, $2)", + username, util.HashAndSalt([]byte(password))) + + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *SQLite) DeleteAccount(ctx context.Context, id string) error { + s.Lock() + defer s.Unlock() + + tx, err := s.Begin() + + if err != nil { + return err + } + + _, err = tx.Exec("DELETE FROM accounts WHERE id=$1", id) + + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *SQLite) GetSession(ctx context.Context, id string) (Session, error) { + s.RLock() + defer s.RUnlock() + + session := new(Session) + row := s.QueryRow("SELECT public, token, secret, username FROM sessions WHERE public=?", id) + err := row.Scan(&session.Public, &session.Token, &session.Secret, &session.Username) + + return *session, err +} + +func (s *SQLite) CreateSession(ctx context.Context, public, token, secret, username string) error { + s.Lock() + defer s.Unlock() + + tx, err := s.Begin() + + if err != nil { + return err + } + + _, err = tx.Exec("INSERT INTO sessions (public, token, secret, username) VALUES ($1, $2, $3, $4)", + public, token, secret, username) + + if err != nil { + return err + } + + return tx.Commit() +} + +func (s *SQLite) DeleteSession(ctx context.Context, public string) error { + s.Lock() + defer s.Unlock() + + tx, err := s.Begin() + + if err != nil { + return err + } + + _, err = tx.Exec("DELETE FROM sessions WHERE public=$1", public) + + if err != nil { + return err + } + + return tx.Commit() +} diff --git a/internal/database/databasefakes/fake_database.go b/internal/database/databasefakes/fake_database.go index 24214144..ed778e43 100644 --- a/internal/database/databasefakes/fake_database.go +++ b/internal/database/databasefakes/fake_database.go @@ -19,6 +19,19 @@ type FakeDatabase struct { closeReturnsOnCall map[int]struct { result1 error } + CreateAccountStub func(context.Context, string, string) error + createAccountMutex sync.RWMutex + createAccountArgsForCall []struct { + arg1 context.Context + arg2 string + arg3 string + } + createAccountReturns struct { + result1 error + } + createAccountReturnsOnCall map[int]struct { + result1 error + } CreateDocumentStub func(context.Context, string, string) error createDocumentMutex sync.RWMutex createDocumentArgsForCall []struct { @@ -32,6 +45,73 @@ type FakeDatabase struct { createDocumentReturnsOnCall map[int]struct { result1 error } + CreateSessionStub func(context.Context, string, string, string, string) error + createSessionMutex sync.RWMutex + createSessionArgsForCall []struct { + arg1 context.Context + arg2 string + arg3 string + arg4 string + arg5 string + } + createSessionReturns struct { + result1 error + } + createSessionReturnsOnCall map[int]struct { + result1 error + } + DeleteAccountStub func(context.Context, string) error + deleteAccountMutex sync.RWMutex + deleteAccountArgsForCall []struct { + arg1 context.Context + arg2 string + } + deleteAccountReturns struct { + result1 error + } + deleteAccountReturnsOnCall map[int]struct { + result1 error + } + DeleteSessionStub func(context.Context, string) error + deleteSessionMutex sync.RWMutex + deleteSessionArgsForCall []struct { + arg1 context.Context + arg2 string + } + deleteSessionReturns struct { + result1 error + } + deleteSessionReturnsOnCall map[int]struct { + result1 error + } + GetAccountStub func(context.Context, string) (database.Account, error) + getAccountMutex sync.RWMutex + getAccountArgsForCall []struct { + arg1 context.Context + arg2 string + } + getAccountReturns struct { + result1 database.Account + result2 error + } + getAccountReturnsOnCall map[int]struct { + result1 database.Account + result2 error + } + GetAccountByUsernameStub func(context.Context, string) (database.Account, error) + getAccountByUsernameMutex sync.RWMutex + getAccountByUsernameArgsForCall []struct { + arg1 context.Context + arg2 string + } + getAccountByUsernameReturns struct { + result1 database.Account + result2 error + } + getAccountByUsernameReturnsOnCall map[int]struct { + result1 database.Account + result2 error + } GetDocumentStub func(context.Context, string) (database.Document, error) getDocumentMutex sync.RWMutex getDocumentArgsForCall []struct { @@ -46,6 +126,20 @@ type FakeDatabase struct { result1 database.Document result2 error } + GetSessionStub func(context.Context, string) (database.Session, error) + getSessionMutex sync.RWMutex + getSessionArgsForCall []struct { + arg1 context.Context + arg2 string + } + getSessionReturns struct { + result1 database.Session + result2 error + } + getSessionReturnsOnCall map[int]struct { + result1 database.Session + result2 error + } MigrateStub func(context.Context) error migrateMutex sync.RWMutex migrateArgsForCall []struct { @@ -114,6 +208,69 @@ func (fake *FakeDatabase) CloseReturnsOnCall(i int, result1 error) { }{result1} } +func (fake *FakeDatabase) CreateAccount(arg1 context.Context, arg2 string, arg3 string) error { + fake.createAccountMutex.Lock() + ret, specificReturn := fake.createAccountReturnsOnCall[len(fake.createAccountArgsForCall)] + fake.createAccountArgsForCall = append(fake.createAccountArgsForCall, struct { + arg1 context.Context + arg2 string + arg3 string + }{arg1, arg2, arg3}) + stub := fake.CreateAccountStub + fakeReturns := fake.createAccountReturns + fake.recordInvocation("CreateAccount", []interface{}{arg1, arg2, arg3}) + fake.createAccountMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeDatabase) CreateAccountCallCount() int { + fake.createAccountMutex.RLock() + defer fake.createAccountMutex.RUnlock() + return len(fake.createAccountArgsForCall) +} + +func (fake *FakeDatabase) CreateAccountCalls(stub func(context.Context, string, string) error) { + fake.createAccountMutex.Lock() + defer fake.createAccountMutex.Unlock() + fake.CreateAccountStub = stub +} + +func (fake *FakeDatabase) CreateAccountArgsForCall(i int) (context.Context, string, string) { + fake.createAccountMutex.RLock() + defer fake.createAccountMutex.RUnlock() + argsForCall := fake.createAccountArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *FakeDatabase) CreateAccountReturns(result1 error) { + fake.createAccountMutex.Lock() + defer fake.createAccountMutex.Unlock() + fake.CreateAccountStub = nil + fake.createAccountReturns = struct { + result1 error + }{result1} +} + +func (fake *FakeDatabase) CreateAccountReturnsOnCall(i int, result1 error) { + fake.createAccountMutex.Lock() + defer fake.createAccountMutex.Unlock() + fake.CreateAccountStub = nil + if fake.createAccountReturnsOnCall == nil { + fake.createAccountReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.createAccountReturnsOnCall[i] = struct { + result1 error + }{result1} +} + func (fake *FakeDatabase) CreateDocument(arg1 context.Context, arg2 string, arg3 string) error { fake.createDocumentMutex.Lock() ret, specificReturn := fake.createDocumentReturnsOnCall[len(fake.createDocumentArgsForCall)] @@ -177,6 +334,325 @@ func (fake *FakeDatabase) CreateDocumentReturnsOnCall(i int, result1 error) { }{result1} } +func (fake *FakeDatabase) CreateSession(arg1 context.Context, arg2 string, arg3 string, arg4 string, arg5 string) error { + fake.createSessionMutex.Lock() + ret, specificReturn := fake.createSessionReturnsOnCall[len(fake.createSessionArgsForCall)] + fake.createSessionArgsForCall = append(fake.createSessionArgsForCall, struct { + arg1 context.Context + arg2 string + arg3 string + arg4 string + arg5 string + }{arg1, arg2, arg3, arg4, arg5}) + stub := fake.CreateSessionStub + fakeReturns := fake.createSessionReturns + fake.recordInvocation("CreateSession", []interface{}{arg1, arg2, arg3, arg4, arg5}) + fake.createSessionMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3, arg4, arg5) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeDatabase) CreateSessionCallCount() int { + fake.createSessionMutex.RLock() + defer fake.createSessionMutex.RUnlock() + return len(fake.createSessionArgsForCall) +} + +func (fake *FakeDatabase) CreateSessionCalls(stub func(context.Context, string, string, string, string) error) { + fake.createSessionMutex.Lock() + defer fake.createSessionMutex.Unlock() + fake.CreateSessionStub = stub +} + +func (fake *FakeDatabase) CreateSessionArgsForCall(i int) (context.Context, string, string, string, string) { + fake.createSessionMutex.RLock() + defer fake.createSessionMutex.RUnlock() + argsForCall := fake.createSessionArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5 +} + +func (fake *FakeDatabase) CreateSessionReturns(result1 error) { + fake.createSessionMutex.Lock() + defer fake.createSessionMutex.Unlock() + fake.CreateSessionStub = nil + fake.createSessionReturns = struct { + result1 error + }{result1} +} + +func (fake *FakeDatabase) CreateSessionReturnsOnCall(i int, result1 error) { + fake.createSessionMutex.Lock() + defer fake.createSessionMutex.Unlock() + fake.CreateSessionStub = nil + if fake.createSessionReturnsOnCall == nil { + fake.createSessionReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.createSessionReturnsOnCall[i] = struct { + result1 error + }{result1} +} + +func (fake *FakeDatabase) DeleteAccount(arg1 context.Context, arg2 string) error { + fake.deleteAccountMutex.Lock() + ret, specificReturn := fake.deleteAccountReturnsOnCall[len(fake.deleteAccountArgsForCall)] + fake.deleteAccountArgsForCall = append(fake.deleteAccountArgsForCall, struct { + arg1 context.Context + arg2 string + }{arg1, arg2}) + stub := fake.DeleteAccountStub + fakeReturns := fake.deleteAccountReturns + fake.recordInvocation("DeleteAccount", []interface{}{arg1, arg2}) + fake.deleteAccountMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeDatabase) DeleteAccountCallCount() int { + fake.deleteAccountMutex.RLock() + defer fake.deleteAccountMutex.RUnlock() + return len(fake.deleteAccountArgsForCall) +} + +func (fake *FakeDatabase) DeleteAccountCalls(stub func(context.Context, string) error) { + fake.deleteAccountMutex.Lock() + defer fake.deleteAccountMutex.Unlock() + fake.DeleteAccountStub = stub +} + +func (fake *FakeDatabase) DeleteAccountArgsForCall(i int) (context.Context, string) { + fake.deleteAccountMutex.RLock() + defer fake.deleteAccountMutex.RUnlock() + argsForCall := fake.deleteAccountArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeDatabase) DeleteAccountReturns(result1 error) { + fake.deleteAccountMutex.Lock() + defer fake.deleteAccountMutex.Unlock() + fake.DeleteAccountStub = nil + fake.deleteAccountReturns = struct { + result1 error + }{result1} +} + +func (fake *FakeDatabase) DeleteAccountReturnsOnCall(i int, result1 error) { + fake.deleteAccountMutex.Lock() + defer fake.deleteAccountMutex.Unlock() + fake.DeleteAccountStub = nil + if fake.deleteAccountReturnsOnCall == nil { + fake.deleteAccountReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.deleteAccountReturnsOnCall[i] = struct { + result1 error + }{result1} +} + +func (fake *FakeDatabase) DeleteSession(arg1 context.Context, arg2 string) error { + fake.deleteSessionMutex.Lock() + ret, specificReturn := fake.deleteSessionReturnsOnCall[len(fake.deleteSessionArgsForCall)] + fake.deleteSessionArgsForCall = append(fake.deleteSessionArgsForCall, struct { + arg1 context.Context + arg2 string + }{arg1, arg2}) + stub := fake.DeleteSessionStub + fakeReturns := fake.deleteSessionReturns + fake.recordInvocation("DeleteSession", []interface{}{arg1, arg2}) + fake.deleteSessionMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeDatabase) DeleteSessionCallCount() int { + fake.deleteSessionMutex.RLock() + defer fake.deleteSessionMutex.RUnlock() + return len(fake.deleteSessionArgsForCall) +} + +func (fake *FakeDatabase) DeleteSessionCalls(stub func(context.Context, string) error) { + fake.deleteSessionMutex.Lock() + defer fake.deleteSessionMutex.Unlock() + fake.DeleteSessionStub = stub +} + +func (fake *FakeDatabase) DeleteSessionArgsForCall(i int) (context.Context, string) { + fake.deleteSessionMutex.RLock() + defer fake.deleteSessionMutex.RUnlock() + argsForCall := fake.deleteSessionArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeDatabase) DeleteSessionReturns(result1 error) { + fake.deleteSessionMutex.Lock() + defer fake.deleteSessionMutex.Unlock() + fake.DeleteSessionStub = nil + fake.deleteSessionReturns = struct { + result1 error + }{result1} +} + +func (fake *FakeDatabase) DeleteSessionReturnsOnCall(i int, result1 error) { + fake.deleteSessionMutex.Lock() + defer fake.deleteSessionMutex.Unlock() + fake.DeleteSessionStub = nil + if fake.deleteSessionReturnsOnCall == nil { + fake.deleteSessionReturnsOnCall = make(map[int]struct { + result1 error + }) + } + fake.deleteSessionReturnsOnCall[i] = struct { + result1 error + }{result1} +} + +func (fake *FakeDatabase) GetAccount(arg1 context.Context, arg2 string) (database.Account, error) { + fake.getAccountMutex.Lock() + ret, specificReturn := fake.getAccountReturnsOnCall[len(fake.getAccountArgsForCall)] + fake.getAccountArgsForCall = append(fake.getAccountArgsForCall, struct { + arg1 context.Context + arg2 string + }{arg1, arg2}) + stub := fake.GetAccountStub + fakeReturns := fake.getAccountReturns + fake.recordInvocation("GetAccount", []interface{}{arg1, arg2}) + fake.getAccountMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeDatabase) GetAccountCallCount() int { + fake.getAccountMutex.RLock() + defer fake.getAccountMutex.RUnlock() + return len(fake.getAccountArgsForCall) +} + +func (fake *FakeDatabase) GetAccountCalls(stub func(context.Context, string) (database.Account, error)) { + fake.getAccountMutex.Lock() + defer fake.getAccountMutex.Unlock() + fake.GetAccountStub = stub +} + +func (fake *FakeDatabase) GetAccountArgsForCall(i int) (context.Context, string) { + fake.getAccountMutex.RLock() + defer fake.getAccountMutex.RUnlock() + argsForCall := fake.getAccountArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeDatabase) GetAccountReturns(result1 database.Account, result2 error) { + fake.getAccountMutex.Lock() + defer fake.getAccountMutex.Unlock() + fake.GetAccountStub = nil + fake.getAccountReturns = struct { + result1 database.Account + result2 error + }{result1, result2} +} + +func (fake *FakeDatabase) GetAccountReturnsOnCall(i int, result1 database.Account, result2 error) { + fake.getAccountMutex.Lock() + defer fake.getAccountMutex.Unlock() + fake.GetAccountStub = nil + if fake.getAccountReturnsOnCall == nil { + fake.getAccountReturnsOnCall = make(map[int]struct { + result1 database.Account + result2 error + }) + } + fake.getAccountReturnsOnCall[i] = struct { + result1 database.Account + result2 error + }{result1, result2} +} + +func (fake *FakeDatabase) GetAccountByUsername(arg1 context.Context, arg2 string) (database.Account, error) { + fake.getAccountByUsernameMutex.Lock() + ret, specificReturn := fake.getAccountByUsernameReturnsOnCall[len(fake.getAccountByUsernameArgsForCall)] + fake.getAccountByUsernameArgsForCall = append(fake.getAccountByUsernameArgsForCall, struct { + arg1 context.Context + arg2 string + }{arg1, arg2}) + stub := fake.GetAccountByUsernameStub + fakeReturns := fake.getAccountByUsernameReturns + fake.recordInvocation("GetAccountByUsername", []interface{}{arg1, arg2}) + fake.getAccountByUsernameMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeDatabase) GetAccountByUsernameCallCount() int { + fake.getAccountByUsernameMutex.RLock() + defer fake.getAccountByUsernameMutex.RUnlock() + return len(fake.getAccountByUsernameArgsForCall) +} + +func (fake *FakeDatabase) GetAccountByUsernameCalls(stub func(context.Context, string) (database.Account, error)) { + fake.getAccountByUsernameMutex.Lock() + defer fake.getAccountByUsernameMutex.Unlock() + fake.GetAccountByUsernameStub = stub +} + +func (fake *FakeDatabase) GetAccountByUsernameArgsForCall(i int) (context.Context, string) { + fake.getAccountByUsernameMutex.RLock() + defer fake.getAccountByUsernameMutex.RUnlock() + argsForCall := fake.getAccountByUsernameArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeDatabase) GetAccountByUsernameReturns(result1 database.Account, result2 error) { + fake.getAccountByUsernameMutex.Lock() + defer fake.getAccountByUsernameMutex.Unlock() + fake.GetAccountByUsernameStub = nil + fake.getAccountByUsernameReturns = struct { + result1 database.Account + result2 error + }{result1, result2} +} + +func (fake *FakeDatabase) GetAccountByUsernameReturnsOnCall(i int, result1 database.Account, result2 error) { + fake.getAccountByUsernameMutex.Lock() + defer fake.getAccountByUsernameMutex.Unlock() + fake.GetAccountByUsernameStub = nil + if fake.getAccountByUsernameReturnsOnCall == nil { + fake.getAccountByUsernameReturnsOnCall = make(map[int]struct { + result1 database.Account + result2 error + }) + } + fake.getAccountByUsernameReturnsOnCall[i] = struct { + result1 database.Account + result2 error + }{result1, result2} +} + func (fake *FakeDatabase) GetDocument(arg1 context.Context, arg2 string) (database.Document, error) { fake.getDocumentMutex.Lock() ret, specificReturn := fake.getDocumentReturnsOnCall[len(fake.getDocumentArgsForCall)] @@ -242,6 +718,71 @@ func (fake *FakeDatabase) GetDocumentReturnsOnCall(i int, result1 database.Docum }{result1, result2} } +func (fake *FakeDatabase) GetSession(arg1 context.Context, arg2 string) (database.Session, error) { + fake.getSessionMutex.Lock() + ret, specificReturn := fake.getSessionReturnsOnCall[len(fake.getSessionArgsForCall)] + fake.getSessionArgsForCall = append(fake.getSessionArgsForCall, struct { + arg1 context.Context + arg2 string + }{arg1, arg2}) + stub := fake.GetSessionStub + fakeReturns := fake.getSessionReturns + fake.recordInvocation("GetSession", []interface{}{arg1, arg2}) + fake.getSessionMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeDatabase) GetSessionCallCount() int { + fake.getSessionMutex.RLock() + defer fake.getSessionMutex.RUnlock() + return len(fake.getSessionArgsForCall) +} + +func (fake *FakeDatabase) GetSessionCalls(stub func(context.Context, string) (database.Session, error)) { + fake.getSessionMutex.Lock() + defer fake.getSessionMutex.Unlock() + fake.GetSessionStub = stub +} + +func (fake *FakeDatabase) GetSessionArgsForCall(i int) (context.Context, string) { + fake.getSessionMutex.RLock() + defer fake.getSessionMutex.RUnlock() + argsForCall := fake.getSessionArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeDatabase) GetSessionReturns(result1 database.Session, result2 error) { + fake.getSessionMutex.Lock() + defer fake.getSessionMutex.Unlock() + fake.GetSessionStub = nil + fake.getSessionReturns = struct { + result1 database.Session + result2 error + }{result1, result2} +} + +func (fake *FakeDatabase) GetSessionReturnsOnCall(i int, result1 database.Session, result2 error) { + fake.getSessionMutex.Lock() + defer fake.getSessionMutex.Unlock() + fake.GetSessionStub = nil + if fake.getSessionReturnsOnCall == nil { + fake.getSessionReturnsOnCall = make(map[int]struct { + result1 database.Session + result2 error + }) + } + fake.getSessionReturnsOnCall[i] = struct { + result1 database.Session + result2 error + }{result1, result2} +} + func (fake *FakeDatabase) Migrate(arg1 context.Context) error { fake.migrateMutex.Lock() ret, specificReturn := fake.migrateReturnsOnCall[len(fake.migrateArgsForCall)] @@ -306,14 +847,6 @@ func (fake *FakeDatabase) MigrateReturnsOnCall(i int, result1 error) { func (fake *FakeDatabase) Invocations() map[string][][]interface{} { fake.invocationsMutex.RLock() defer fake.invocationsMutex.RUnlock() - fake.closeMutex.RLock() - defer fake.closeMutex.RUnlock() - fake.createDocumentMutex.RLock() - defer fake.createDocumentMutex.RUnlock() - fake.getDocumentMutex.RLock() - defer fake.getDocumentMutex.RUnlock() - fake.migrateMutex.RLock() - defer fake.migrateMutex.RUnlock() copiedInvocations := map[string][][]interface{}{} for key, value := range fake.invocations { copiedInvocations[key] = value diff --git a/internal/database/migrations.go b/internal/database/migrations.go new file mode 100644 index 00000000..2c99e804 --- /dev/null +++ b/internal/database/migrations.go @@ -0,0 +1,22 @@ +/* + * Copyright 2020-2024 Luke Whritenour + + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + + * http://www.apache.org/licenses/LICENSE-2.0 + + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package database + +import "embed" + +//go:embed migrations/postgres/*.sql migrations/mysql/*.sql migrations/sqlite/*.sql +var migrationFS embed.FS diff --git a/internal/database/migrations/mysql/1_initialize_schema.down.sql b/internal/database/migrations/mysql/1_initialize_schema.down.sql new file mode 100644 index 00000000..7815fb08 --- /dev/null +++ b/internal/database/migrations/mysql/1_initialize_schema.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS accounts; +DROP TABLE IF EXISTS documents; diff --git a/internal/database/migrations/mysql/1_initialize_schema.up.sql b/internal/database/migrations/mysql/1_initialize_schema.up.sql new file mode 100644 index 00000000..1bc095e4 --- /dev/null +++ b/internal/database/migrations/mysql/1_initialize_schema.up.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS documents ( + id varchar(255) PRIMARY KEY, + content text NOT NULL, + created_at timestamp with time zone DEFAULT now(), + updated_at timestamp with time zone DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS accounts ( + id SERIAL PRIMARY KEY, + username varchar(255) NOT NULL, + password varchar(255) NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + public varchar(255) PRIMARY KEY, + token varchar(255) NOT NULL, + secret varchar +); diff --git a/internal/database/migrations/mysql/2_add_session_username.down.sql b/internal/database/migrations/mysql/2_add_session_username.down.sql new file mode 100644 index 00000000..51697fea --- /dev/null +++ b/internal/database/migrations/mysql/2_add_session_username.down.sql @@ -0,0 +1 @@ +ALTER TABLE sessions DROP COLUMN username; diff --git a/internal/database/migrations/mysql/2_add_session_username.up.sql b/internal/database/migrations/mysql/2_add_session_username.up.sql new file mode 100644 index 00000000..850fa34b --- /dev/null +++ b/internal/database/migrations/mysql/2_add_session_username.up.sql @@ -0,0 +1 @@ +ALTER TABLE sessions ADD COLUMN username varchar(255) NOT NULL DEFAULT ''; diff --git a/internal/database/migrations/postgres/1_initialize_schema.down.sql b/internal/database/migrations/postgres/1_initialize_schema.down.sql new file mode 100644 index 00000000..7815fb08 --- /dev/null +++ b/internal/database/migrations/postgres/1_initialize_schema.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS accounts; +DROP TABLE IF EXISTS documents; diff --git a/internal/database/migrations/postgres/1_initialize_schema.up.sql b/internal/database/migrations/postgres/1_initialize_schema.up.sql new file mode 100644 index 00000000..d4819130 --- /dev/null +++ b/internal/database/migrations/postgres/1_initialize_schema.up.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS documents ( + id varchar(255) PRIMARY KEY, + content text NOT NULL, + created_at timestamp with time zone DEFAULT now(), + updated_at timestamp with time zone DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS accounts ( + id SERIAL PRIMARY KEY, + username varchar(255) NOT NULL, + password varchar(255) NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + public varchar(255) PRIMARY KEY, + token varchar(255) NOT NULL, + secret varchar NOT NULL +); diff --git a/internal/database/migrations/postgres/2_add_session_username.down.sql b/internal/database/migrations/postgres/2_add_session_username.down.sql new file mode 100644 index 00000000..51697fea --- /dev/null +++ b/internal/database/migrations/postgres/2_add_session_username.down.sql @@ -0,0 +1 @@ +ALTER TABLE sessions DROP COLUMN username; diff --git a/internal/database/migrations/postgres/2_add_session_username.up.sql b/internal/database/migrations/postgres/2_add_session_username.up.sql new file mode 100644 index 00000000..2d445202 --- /dev/null +++ b/internal/database/migrations/postgres/2_add_session_username.up.sql @@ -0,0 +1 @@ +ALTER TABLE sessions ADD COLUMN username TEXT NOT NULL DEFAULT ''; diff --git a/internal/database/migrations/sqlite/1_initialize_schema.down.sql b/internal/database/migrations/sqlite/1_initialize_schema.down.sql new file mode 100644 index 00000000..7815fb08 --- /dev/null +++ b/internal/database/migrations/sqlite/1_initialize_schema.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS sessions; +DROP TABLE IF EXISTS accounts; +DROP TABLE IF EXISTS documents; diff --git a/internal/database/migrations/sqlite/1_initialize_schema.up.sql b/internal/database/migrations/sqlite/1_initialize_schema.up.sql new file mode 100644 index 00000000..eb6f5fd9 --- /dev/null +++ b/internal/database/migrations/sqlite/1_initialize_schema.up.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS documents ( + id TEXT PRIMARY KEY, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + password TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + public TEXT PRIMARY KEY, + token TEXT NOT NULL, + secret TEXT NOT NULL +); diff --git a/internal/database/migrations/sqlite/2_add_session_username.down.sql b/internal/database/migrations/sqlite/2_add_session_username.down.sql new file mode 100644 index 00000000..2e85470b --- /dev/null +++ b/internal/database/migrations/sqlite/2_add_session_username.down.sql @@ -0,0 +1,12 @@ +CREATE TABLE sessions_backup ( + public TEXT PRIMARY KEY, + token TEXT NOT NULL, + secret TEXT NOT NULL +); + +INSERT INTO sessions_backup (public, token, secret) +SELECT public, token, secret FROM sessions; + +DROP TABLE sessions; + +ALTER TABLE sessions_backup RENAME TO sessions; diff --git a/internal/database/migrations/sqlite/2_add_session_username.up.sql b/internal/database/migrations/sqlite/2_add_session_username.up.sql new file mode 100644 index 00000000..2d445202 --- /dev/null +++ b/internal/database/migrations/sqlite/2_add_session_username.up.sql @@ -0,0 +1 @@ +ALTER TABLE sessions ADD COLUMN username TEXT NOT NULL DEFAULT ''; diff --git a/internal/server/authentication.go b/internal/server/authentication.go new file mode 100644 index 00000000..4cb2a321 --- /dev/null +++ b/internal/server/authentication.go @@ -0,0 +1,456 @@ +package server + +import ( + "database/sql" + "encoding/base64" + "errors" + "fmt" + "html/template" + "log" + "net/http" + "strings" + "time" + + "github.com/lukewhrit/spacebin/internal/config" + "github.com/lukewhrit/spacebin/internal/util" + "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/sha3" +) + +const sessionCookieName = "spacebin_token" + +func (s *Server) SignUp(w http.ResponseWriter, r *http.Request) { + if !s.Config.AccountsEnabled { + util.WriteError(w, http.StatusNotFound, errors.New("accounts disabled")) + return + } + + // Parse body from HTML request + body, err := util.HandleSignupBody(s.Config.MaxSize, r) + + if err != nil { + util.WriteError(w, http.StatusBadRequest, err) + return + } + + body.Username = strings.TrimSpace(body.Username) + if body.Username == "" || body.Password == "" { + util.WriteError(w, http.StatusBadRequest, errors.New("username and password are required")) + return + } + + if len(body.Password) < 8 { + util.WriteError(w, http.StatusBadRequest, errors.New("password must be at least 8 characters long")) + return + } + + // Make sure username does not exist + _, err = s.Database.GetAccountByUsername(r.Context(), body.Username) + + if err == nil { + util.WriteError(w, http.StatusConflict, errors.New("username already exists")) + return + } + + if !errors.Is(err, sql.ErrNoRows) { + util.WriteError(w, http.StatusInternalServerError, err) + return + } + + // Create account + // Encryption handled in Database function + err = s.Database.CreateAccount(r.Context(), body.Username, body.Password) + + if err != nil { + util.WriteError(w, http.StatusInternalServerError, err) + return + } + + // Respond on success with account ID and username + account, err := s.Database.GetAccountByUsername(r.Context(), body.Username) + + if err != nil { + util.WriteError(w, http.StatusInternalServerError, err) + return + } + + if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + util.WriteJSON(w, http.StatusOK, map[string]interface{}{ + "id": account.ID, + "username": account.Username, + }) + return + } + + http.Redirect(w, r, "/signin", http.StatusSeeOther) + +} + +func (s *Server) StaticSignUp(w http.ResponseWriter, r *http.Request) { + if !s.Config.AccountsEnabled { + util.RenderError(&resources, w, http.StatusNotFound, errors.New("accounts disabled")) + return + } + + t, err := template.ParseFS(resources, "web/signup.html") + + if err != nil { + util.RenderError(&resources, w, http.StatusInternalServerError, err) + return + } + + username, err := s.authenticatedUsername(r) + if err != nil { + util.RenderError(&resources, w, http.StatusInternalServerError, err) + return + } + + err = t.Execute(w, map[string]any{ + "Analytics": config.Config.Analytics, + "AccountsEnabled": config.Config.AccountsEnabled, + "Authenticated": username != "", + "Username": username, + }) + + if err != nil { + util.RenderError(&resources, w, http.StatusInternalServerError, err) + return + } +} + +func (s *Server) SignIn(w http.ResponseWriter, r *http.Request) { + if !s.Config.AccountsEnabled { + util.WriteError(w, http.StatusNotFound, errors.New("accounts disabled")) + return + } + + // Parse body from HTML request + body, err := util.HandleSigninBody(s.Config.MaxSize, r) + + if err != nil { + util.WriteError(w, http.StatusBadRequest, err) + return + } + + body.Username = strings.TrimSpace(body.Username) + if body.Username == "" || body.Password == "" { + util.WriteError(w, http.StatusBadRequest, errors.New("username and password are required")) + return + } + + // Get user from database + acc, err := s.Database.GetAccountByUsername(r.Context(), body.Username) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + util.WriteError(w, http.StatusUnauthorized, errors.New("invalid username or password")) + return + } + util.WriteError(w, http.StatusInternalServerError, err) + return + } + + // Compare passwords + if bcrypt.CompareHashAndPassword([]byte(acc.Password), []byte(body.Password)) == nil { + // Generate public, secret keys and salt + pub, sec, salt, err := util.GenerateStrings([]int{64, 64, 32}) + + if err != nil { + log.Fatal(err) + } + + // Salt secret key + buf := []byte(sec + salt) + secret := make([]byte, 64) + sha3.ShakeSum256(secret, buf) + + // Create user and server tokens for later comparison + userToken := util.MakeToken(util.Token{ + Version: "v1", + Public: pub, + Secret: base64.URLEncoding.EncodeToString([]byte(sec)), + Salt: salt, + }) + + serverToken := util.MakeToken(util.Token{ + Version: "v1", + Public: pub, + Secret: fmt.Sprintf("%x", secret), + Salt: salt, + }) + + // Add session to Postgres + if err := s.Database.CreateSession(r.Context(), pub, userToken, serverToken, acc.Username); err != nil { + util.WriteError(w, http.StatusInternalServerError, err) + return + } + + http.SetCookie(w, s.buildSessionCookie(r, userToken)) + + if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + util.WriteJSON(w, http.StatusOK, map[string]string{ + "token": userToken, + "user": acc.Username, + }) + return + } + + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + util.WriteError(w, http.StatusUnauthorized, errors.New("invalid username or password")) +} + +func (s *Server) StaticSignIn(w http.ResponseWriter, r *http.Request) { + if !s.Config.AccountsEnabled { + util.RenderError(&resources, w, http.StatusNotFound, errors.New("accounts disabled")) + return + } + + t, err := template.ParseFS(resources, "web/signin.html") + + if err != nil { + util.RenderError(&resources, w, http.StatusInternalServerError, err) + return + } + + username, err := s.authenticatedUsername(r) + if err != nil { + util.RenderError(&resources, w, http.StatusInternalServerError, err) + return + } + + err = t.Execute(w, map[string]any{ + "Analytics": config.Config.Analytics, + "AccountsEnabled": config.Config.AccountsEnabled, + "Authenticated": username != "", + "Username": username, + }) + + if err != nil { + util.RenderError(&resources, w, http.StatusInternalServerError, err) + return + } +} + +func (s *Server) StaticSettingsPage(w http.ResponseWriter, r *http.Request) { + if !s.Config.AccountsEnabled { + util.RenderError(&resources, w, http.StatusNotFound, errors.New("accounts disabled")) + return + } + + t, err := template.ParseFS(resources, "web/account.html") + + if err != nil { + util.RenderError(&resources, w, http.StatusInternalServerError, err) + return + } + + username, err := s.authenticatedUsername(r) + if err != nil { + util.RenderError(&resources, w, http.StatusInternalServerError, err) + return + } + + err = t.Execute(w, map[string]any{ + "Analytics": config.Config.Analytics, + "AccountsEnabled": config.Config.AccountsEnabled, + "Authenticated": username != "", + "Username": username, + }) + + if err != nil { + util.RenderError(&resources, w, http.StatusInternalServerError, err) + return + } +} + +func (s *Server) Logout(w http.ResponseWriter, r *http.Request) { + s.handleLogout(w, r, false) +} + +func (s *Server) StaticLogout(w http.ResponseWriter, r *http.Request) { + s.handleLogout(w, r, true) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, forceRedirect bool) { + if !s.Config.AccountsEnabled { + util.WriteError(w, http.StatusNotFound, errors.New("accounts disabled")) + return + } + + if err := s.invalidateSession(r); err != nil { + util.WriteError(w, http.StatusInternalServerError, err) + return + } + + clearSessionCookie(w) + + if forceRedirect || !wantsJSONResponse(r) { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) authenticatedUsername(r *http.Request) (string, error) { + if !s.Config.AccountsEnabled { + return "", nil + } + + token := getTokenFromRequest(r) + if token == "" { + return "", nil + } + + clientToken, err := util.ParseToken(token) + if err != nil { + return "", nil + } + + session, err := s.Database.GetSession(r.Context(), clientToken.Public) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + + return "", err + } + + serverToken, err := util.ParseToken(session.Secret) + if err != nil { + return "", nil + } + + secretBytes, err := base64.URLEncoding.DecodeString(clientToken.Secret) + if err != nil { + return "", nil + } + + secret := make([]byte, 64) + sha3.ShakeSum256(secret, append(secretBytes, []byte(clientToken.Salt)...)) + expected := fmt.Sprintf("%x", secret) + + if clientToken.Public != serverToken.Public || clientToken.Salt != serverToken.Salt || expected != serverToken.Secret { + return "", nil + } + + if session.Username == "" { + return "", nil + } + + return session.Username, nil +} + +func getTokenFromRequest(r *http.Request) string { + if cookie, err := r.Cookie(sessionCookieName); err == nil { + return cookie.Value + } + + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + return strings.TrimSpace(authHeader[7:]) + } + + return "" +} + +func (s *Server) invalidateSession(r *http.Request) error { + token := getTokenFromRequest(r) + if token == "" { + return nil + } + + clientToken, err := util.ParseToken(token) + if err != nil { + return nil + } + + session, err := s.Database.GetSession(r.Context(), clientToken.Public) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil + } + + return err + } + + serverToken, err := util.ParseToken(session.Secret) + if err != nil { + return nil + } + + secretBytes, err := base64.URLEncoding.DecodeString(clientToken.Secret) + if err != nil { + return nil + } + + secret := make([]byte, 64) + sha3.ShakeSum256(secret, append(secretBytes, []byte(clientToken.Salt)...)) + expected := fmt.Sprintf("%x", secret) + + if clientToken.Public != serverToken.Public || clientToken.Salt != serverToken.Salt || expected != serverToken.Secret { + return nil + } + + return s.Database.DeleteSession(r.Context(), clientToken.Public) +} + +func (s *Server) buildSessionCookie(r *http.Request, token string) *http.Cookie { + duration := time.Duration(s.Config.SessionTTLHours) * time.Hour + secure := s.Config.SessionCookieSecure + + if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") { + secure = true + } + + cookie := &http.Cookie{ + Name: sessionCookieName, + Value: token, + Path: "/", + MaxAge: int(duration.Seconds()), + Expires: time.Now().Add(duration), + HttpOnly: true, + Secure: secure, + SameSite: parseSameSite(s.Config.SessionCookieSameSite), + } + + if s.Config.SessionCookieDomain != "" { + cookie.Domain = s.Config.SessionCookieDomain + } + + return cookie +} + +func parseSameSite(mode string) http.SameSite { + switch strings.ToLower(mode) { + case "strict": + return http.SameSiteStrictMode + case "lax", "default", "": + return http.SameSiteLaxMode + default: + return http.SameSiteLaxMode + } +} + +func clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + Expires: time.Unix(0, 0), + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +func wantsJSONResponse(r *http.Request) bool { + if strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "application/json") { + return true + } + + return strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/json") +} diff --git a/internal/server/authentication_test.go b/internal/server/authentication_test.go new file mode 100644 index 00000000..86633763 --- /dev/null +++ b/internal/server/authentication_test.go @@ -0,0 +1,543 @@ +package server_test + +import ( + "bytes" + "context" + "crypto/tls" + "database/sql" + "encoding/base64" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/lukewhrit/spacebin/internal/database" + "github.com/lukewhrit/spacebin/internal/database/databasefakes" + "github.com/lukewhrit/spacebin/internal/server" + "github.com/lukewhrit/spacebin/internal/util" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" + "golang.org/x/crypto/sha3" +) + +func TestSignUpSuccess(t *testing.T) { + cfg := mockConfig + cfg.AccountsEnabled = true + + fakeDB := &databasefakes.FakeDatabase{} + fakeDB.GetAccountByUsernameReturnsOnCall(0, database.Account{}, sql.ErrNoRows) + fakeDB.GetAccountByUsernameReturnsOnCall(1, database.Account{ + ID: 1, + Username: "newuser", + }, nil) + + s := server.NewServer(&cfg, fakeDB) + s.MountHandlers() + + body, _ := json.Marshal(map[string]string{ + "username": "newuser", + "password": "strongpassword", + }) + + req, _ := http.NewRequest(http.MethodPost, "/api/signup", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + res := executeRequest(req, s) + + checkResponseCode(t, http.StatusOK, res.Result().StatusCode) +} + +func TestSignUpDuplicateUsername(t *testing.T) { + cfg := mockConfig + cfg.AccountsEnabled = true + + fakeDB := &databasefakes.FakeDatabase{} + fakeDB.GetAccountByUsernameReturns(database.Account{ID: 1, Username: "existing"}, nil) + + s := server.NewServer(&cfg, fakeDB) + s.MountHandlers() + + body, _ := json.Marshal(map[string]string{ + "username": "existing", + "password": "strongpassword", + }) + + req, _ := http.NewRequest(http.MethodPost, "/api/signup", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + res := executeRequest(req, s) + + checkResponseCode(t, http.StatusConflict, res.Result().StatusCode) +} + +func TestSignInInvalidCredentials(t *testing.T) { + cfg := mockConfig + cfg.AccountsEnabled = true + + fakeDB := &databasefakes.FakeDatabase{} + fakeDB.GetAccountByUsernameReturns(database.Account{}, sql.ErrNoRows) + + s := server.NewServer(&cfg, fakeDB) + s.MountHandlers() + + body, _ := json.Marshal(map[string]string{ + "username": "missing", + "password": "password", + }) + + req, _ := http.NewRequest(http.MethodPost, "/api/signin", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + res := executeRequest(req, s) + + checkResponseCode(t, http.StatusUnauthorized, res.Result().StatusCode) +} + +func TestSignInPasswordMismatch(t *testing.T) { + cfg := mockConfig + cfg.AccountsEnabled = true + + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("correct-password"), bcrypt.MinCost) + + fakeDB := &databasefakes.FakeDatabase{} + fakeDB.GetAccountByUsernameReturns(database.Account{ + ID: 1, + Username: "user", + Password: string(hashedPassword), + }, nil) + + s := server.NewServer(&cfg, fakeDB) + s.MountHandlers() + + body, _ := json.Marshal(map[string]string{ + "username": "user", + "password": "wrong-password", + }) + + req, _ := http.NewRequest(http.MethodPost, "/api/signin", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + res := executeRequest(req, s) + + checkResponseCode(t, http.StatusUnauthorized, res.Result().StatusCode) +} + +func TestSignInSetsCookieAndSessionUsername(t *testing.T) { + cfg := mockConfig + cfg.AccountsEnabled = true + + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("correct-password"), bcrypt.MinCost) + + fakeDB := &databasefakes.FakeDatabase{} + fakeDB.GetAccountByUsernameReturns(database.Account{ + ID: 1, + Username: "user", + Password: string(hashedPassword), + }, nil) + + var capturedUsername string + fakeDB.CreateSessionStub = func(ctx context.Context, public, token, secret, username string) error { + capturedUsername = username + return nil + } + + s := server.NewServer(&cfg, fakeDB) + s.MountHandlers() + + body, _ := json.Marshal(map[string]string{ + "username": "user", + "password": "correct-password", + }) + + req, _ := http.NewRequest(http.MethodPost, "/api/signin", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + start := time.Now() + res := executeRequest(req, s) + + checkResponseCode(t, http.StatusOK, res.Result().StatusCode) + require.Equal(t, "user", capturedUsername) + + duration := time.Duration(cfg.SessionTTLHours) * time.Hour + minExpiry := start.Add(duration - time.Second) + maxExpiry := start.Add(duration + time.Second) + expectedMaxAge := int(duration.Seconds()) + + foundCookie := false + for _, c := range res.Result().Cookies() { + if c.Name != "spacebin_token" || c.Value == "" { + continue + } + + foundCookie = true + require.Equal(t, "/", c.Path) + require.Equal(t, http.SameSiteLaxMode, c.SameSite) + require.Equal(t, expectedMaxAge, c.MaxAge) + require.True(t, c.Expires.After(minExpiry) && c.Expires.Before(maxExpiry)) + require.True(t, c.HttpOnly) + require.False(t, c.Secure) + require.Empty(t, c.Domain) + } + + require.True(t, foundCookie) +} + +func TestAuthenticationDisabled(t *testing.T) { + cfg := mockConfig + cfg.AccountsEnabled = false + + fakeDB := &databasefakes.FakeDatabase{} + + s := server.NewServer(&cfg, fakeDB) + s.MountHandlers() + + body, _ := json.Marshal(map[string]string{ + "username": "user", + "password": "password", + }) + + signUpReq, _ := http.NewRequest(http.MethodPost, "/api/signup", bytes.NewBuffer(body)) + signUpReq.Header.Set("Content-Type", "application/json") + + signInReq, _ := http.NewRequest(http.MethodPost, "/api/signin", bytes.NewBuffer(body)) + signInReq.Header.Set("Content-Type", "application/json") + + signUpRes := executeRequest(signUpReq, s) + signInRes := executeRequest(signInReq, s) + + checkResponseCode(t, http.StatusNotFound, signUpRes.Result().StatusCode) + checkResponseCode(t, http.StatusNotFound, signInRes.Result().StatusCode) +} + +func TestStaticIndexAuthenticated(t *testing.T) { + cfg := mockConfig + cfg.AccountsEnabled = true + + userToken, serverToken := buildSessionTokens(t, "secret", "salt", "publicKey") + + fakeDB := &databasefakes.FakeDatabase{} + fakeDB.GetSessionReturns(database.Session{ + Public: "publicKey", + Token: userToken, + Secret: serverToken, + Username: "tester", + }, nil) + + s := server.NewServer(&cfg, fakeDB) + s.MountStatic() + + req, _ := http.NewRequest(http.MethodGet, "/", nil) + req.AddCookie(&http.Cookie{Name: "spacebin_token", Value: userToken}) + + res := executeRequest(req, s) + + checkResponseCode(t, http.StatusOK, res.Result().StatusCode) + require.Contains(t, res.Body.String(), "tester") +} + +func TestSignInRedirectsWithCookie(t *testing.T) { + cfg := mockConfig + cfg.AccountsEnabled = true + + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("correct-password"), bcrypt.MinCost) + + fakeDB := &databasefakes.FakeDatabase{} + fakeDB.GetAccountByUsernameReturns(database.Account{ + ID: 1, + Username: "user", + Password: string(hashedPassword), + }, nil) + + fakeDB.CreateSessionReturns(nil) + + s := server.NewServer(&cfg, fakeDB) + s.MountHandlers() + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + writer.WriteField("username", "user") + writer.WriteField("password", "correct-password") + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/signin", &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + res := executeRequest(req, s) + + checkResponseCode(t, http.StatusSeeOther, res.Result().StatusCode) + require.Equal(t, "/", res.Result().Header.Get("Location")) + + foundCookie := false + for _, c := range res.Result().Cookies() { + if c.Name == "spacebin_token" && c.Value != "" { + foundCookie = true + } + } + + require.True(t, foundCookie) +} + +func TestSignInCookieSecureWithHTTPS(t *testing.T) { + cfg := mockConfig + cfg.AccountsEnabled = true + + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("correct-password"), bcrypt.MinCost) + + fakeDB := &databasefakes.FakeDatabase{} + fakeDB.GetAccountByUsernameReturns(database.Account{ + ID: 1, + Username: "user", + Password: string(hashedPassword), + }, nil) + fakeDB.CreateSessionReturns(nil) + + s := server.NewServer(&cfg, fakeDB) + s.MountHandlers() + + body, _ := json.Marshal(map[string]string{ + "username": "user", + "password": "correct-password", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/signin", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.TLS = &tls.ConnectionState{} + + res := executeRequest(req, s) + + checkResponseCode(t, http.StatusOK, res.Result().StatusCode) + + foundCookie := false + for _, c := range res.Result().Cookies() { + if c.Name == "spacebin_token" { + foundCookie = true + require.True(t, c.Secure) + } + } + + require.True(t, foundCookie) +} + +func TestSignInCookieConfigurableAttributes(t *testing.T) { + cfg := mockConfig + cfg.AccountsEnabled = true + cfg.SessionTTLHours = 1 + cfg.SessionCookieSecure = true + cfg.SessionCookieSameSite = "strict" + cfg.SessionCookieDomain = "example.com" + + hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("correct-password"), bcrypt.MinCost) + + fakeDB := &databasefakes.FakeDatabase{} + fakeDB.GetAccountByUsernameReturns(database.Account{ + ID: 1, + Username: "user", + Password: string(hashedPassword), + }, nil) + fakeDB.CreateSessionReturns(nil) + + s := server.NewServer(&cfg, fakeDB) + s.MountHandlers() + + body, _ := json.Marshal(map[string]string{ + "username": "user", + "password": "correct-password", + }) + + start := time.Now() + req := httptest.NewRequest(http.MethodPost, "/api/signin", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + + res := executeRequest(req, s) + + checkResponseCode(t, http.StatusOK, res.Result().StatusCode) + + duration := time.Duration(cfg.SessionTTLHours) * time.Hour + minExpiry := start.Add(duration - time.Second) + maxExpiry := start.Add(duration + time.Second) + expectedMaxAge := int(duration.Seconds()) + + foundCookie := false + for _, c := range res.Result().Cookies() { + if c.Name == "spacebin_token" { + foundCookie = true + require.True(t, c.Secure) + require.Equal(t, http.SameSiteStrictMode, c.SameSite) + require.Equal(t, expectedMaxAge, c.MaxAge) + require.True(t, c.Expires.After(minExpiry) && c.Expires.Before(maxExpiry)) + require.Equal(t, "example.com", c.Domain) + require.Equal(t, "/", c.Path) + } + } + + require.True(t, foundCookie) +} + +func TestSignUpRedirectsToSignIn(t *testing.T) { + cfg := mockConfig + cfg.AccountsEnabled = true + + fakeDB := &databasefakes.FakeDatabase{} + fakeDB.GetAccountByUsernameReturnsOnCall(0, database.Account{}, sql.ErrNoRows) + fakeDB.GetAccountByUsernameReturnsOnCall(1, database.Account{ + ID: 1, + Username: "newuser", + }, nil) + + s := server.NewServer(&cfg, fakeDB) + s.MountHandlers() + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + writer.WriteField("username", "newuser") + writer.WriteField("password", "strongpassword") + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/signup", &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + res := executeRequest(req, s) + + checkResponseCode(t, http.StatusSeeOther, res.Result().StatusCode) + require.Equal(t, "/signin", res.Result().Header.Get("Location")) +} + +func TestLogoutClearsCookieAndDeletesSessionJSON(t *testing.T) { + cfg := mockConfig + cfg.AccountsEnabled = true + + userToken, serverToken := buildSessionTokens(t, "secret", "salt", "publicKey") + + fakeDB := &databasefakes.FakeDatabase{} + fakeDB.GetSessionReturns(database.Session{ + Public: "publicKey", + Token: userToken, + Secret: serverToken, + Username: "tester", + }, nil) + + var deleted bool + fakeDB.DeleteSessionStub = func(ctx context.Context, public string) error { + deleted = true + require.Equal(t, "publicKey", public) + return nil + } + + s := server.NewServer(&cfg, fakeDB) + s.MountHandlers() + + req, _ := http.NewRequest(http.MethodPost, "/api/logout", nil) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: "spacebin_token", Value: userToken}) + + res := executeRequest(req, s) + + checkResponseCode(t, http.StatusNoContent, res.Result().StatusCode) + require.True(t, deleted) + + foundCookie := false + for _, c := range res.Result().Cookies() { + if c.Name == "spacebin_token" { + foundCookie = true + require.Equal(t, "", c.Value) + require.True(t, c.Expires.Before(time.Now().Add(time.Second))) + } + } + + require.True(t, foundCookie) +} + +func TestLogoutRedirectsAndClearsCookie(t *testing.T) { + cfg := mockConfig + cfg.AccountsEnabled = true + + userToken, serverToken := buildSessionTokens(t, "secret", "salt", "publicKey") + + fakeDB := &databasefakes.FakeDatabase{} + fakeDB.GetSessionReturns(database.Session{ + Public: "publicKey", + Token: userToken, + Secret: serverToken, + Username: "tester", + }, nil) + + fakeDB.DeleteSessionReturns(nil) + + s := server.NewServer(&cfg, fakeDB) + s.MountHandlers() + + req, _ := http.NewRequest(http.MethodPost, "/logout", nil) + req.AddCookie(&http.Cookie{Name: "spacebin_token", Value: userToken}) + + res := executeRequest(req, s) + + checkResponseCode(t, http.StatusSeeOther, res.Result().StatusCode) + require.Equal(t, "/", res.Result().Header.Get("Location")) + + foundCookie := false + for _, c := range res.Result().Cookies() { + if c.Name == "spacebin_token" { + foundCookie = true + require.Equal(t, "", c.Value) + require.True(t, c.Expires.Before(time.Now().Add(time.Second))) + } + } + + require.True(t, foundCookie) + require.Equal(t, 1, fakeDB.DeleteSessionCallCount()) +} + +func TestLogoutInvalidTokenHandledGracefully(t *testing.T) { + cfg := mockConfig + cfg.AccountsEnabled = true + + fakeDB := &databasefakes.FakeDatabase{} + + s := server.NewServer(&cfg, fakeDB) + s.MountHandlers() + + req, _ := http.NewRequest(http.MethodPost, "/api/logout", nil) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{Name: "spacebin_token", Value: "invalid-token"}) + + res := executeRequest(req, s) + + checkResponseCode(t, http.StatusNoContent, res.Result().StatusCode) + require.Equal(t, 0, fakeDB.DeleteSessionCallCount()) + + foundCookie := false + for _, c := range res.Result().Cookies() { + if c.Name == "spacebin_token" { + foundCookie = true + require.Equal(t, "", c.Value) + require.True(t, c.Expires.Before(time.Now().Add(time.Second))) + } + } + + require.True(t, foundCookie) +} + +func buildSessionTokens(t *testing.T, secret string, salt string, public string) (string, string) { + t.Helper() + + userToken := util.MakeToken(util.Token{ + Version: "v1", + Public: public, + Secret: base64.URLEncoding.EncodeToString([]byte(secret)), + Salt: salt, + }) + + hashed := make([]byte, 64) + sha3.ShakeSum256(hashed, []byte(secret+salt)) + serverToken := util.MakeToken(util.Token{ + Version: "v1", + Public: public, + Secret: fmt.Sprintf("%x", hashed), + Salt: salt, + }) + + return userToken, serverToken +} diff --git a/internal/server/config_test.go b/internal/server/config_test.go index 27f71a3a..b61b30dd 100644 --- a/internal/server/config_test.go +++ b/internal/server/config_test.go @@ -45,6 +45,10 @@ var mockConfig = config.Cfg{ ExpirationAge: 720, ContentSecurityPolicy: "default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';", Headless: false, + SessionTTLHours: 720, + SessionCookieSecure: false, + SessionCookieSameSite: "lax", + SessionCookieDomain: "", } // executeRequest, creates a new ResponseRecorder diff --git a/internal/server/create.go b/internal/server/create.go index d262aa22..587a127a 100644 --- a/internal/server/create.go +++ b/internal/server/create.go @@ -27,7 +27,7 @@ import ( // createDocument handles the shared logic between the CreateDocument and StaticCreateDocument handlers. func createDocument(s *Server, w http.ResponseWriter, r *http.Request) (string, error) { // Parse body from HTML request - body, err := util.HandleBody(s.Config.MaxSize, r) + body, err := util.HandleCreateBody(s.Config.MaxSize, r) if err != nil { util.WriteError(w, http.StatusInternalServerError, err) diff --git a/internal/server/create_test.go b/internal/server/create_test.go index 89f63d4b..eef74f4a 100644 --- a/internal/server/create_test.go +++ b/internal/server/create_test.go @@ -19,6 +19,7 @@ package server_test import ( "bytes" "encoding/json" + "errors" "io" "mime/multipart" "net/http" @@ -126,22 +127,174 @@ func (s *CreateDocumentSuite) TestStaticCreateDocument() { // add a test for content-type and body? } -// same as TestFetchNotFoundDocument; mocked GetDocument always returns a document, so this test needs to be reworked -// func (s *CreateDocumentSuite) TestCreateBadDocument() { -// req, _ := http.NewRequest(http.MethodPost, "/api/", -// bytes.NewReader([]byte(`{"content": "1"}`)), -// ) -// req.Header.Set("Content-Type", "application/json") -// rr := executeRequest(req, s.srv) +func TestCreateDocumentSuite(t *testing.T) { + suite.Run(t, new(CreateDocumentSuite)) +} -// x, _ := io.ReadAll(rr.Result().Body) -// var body DocumentResponse -// json.Unmarshal(x, &body) +// TestCreateBadContentDocument tests creating a document with invalid content +func TestCreateBadContentDocument(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} -// require.Equal(s.T(), http.StatusBadRequest, rr.Result().StatusCode) -// require.Equal(s.T(), "Content: the length must be between 2 and 400000.", body.Error) -// } + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() -func TestCreateDocumentSuite(t *testing.T) { - suite.Run(t, new(CreateDocumentSuite)) + // Test with content too short + req, _ := http.NewRequest(http.MethodPost, "/api/", + bytes.NewReader([]byte(`{"content": "x"}`)), + ) + req.Header.Set("Content-Type", "application/json") + rr := executeRequest(req, srv) + + require.Equal(t, http.StatusBadRequest, rr.Result().StatusCode) + + x, _ := io.ReadAll(rr.Result().Body) + var body DocumentResponse + json.Unmarshal(x, &body) + + require.Contains(t, body.Error, "bad request") +} + +// TestCreateEmptyContentDocument tests creating a document with empty content +func TestCreateEmptyContentDocument(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + req, _ := http.NewRequest(http.MethodPost, "/api/", + bytes.NewReader([]byte(`{"content": ""}`)), + ) + req.Header.Set("Content-Type", "application/json") + rr := executeRequest(req, srv) + + require.Equal(t, http.StatusBadRequest, rr.Result().StatusCode) + + x, _ := io.ReadAll(rr.Result().Body) + var body DocumentResponse + json.Unmarshal(x, &body) + + require.Contains(t, body.Error, "bad request") +} + +// TestStaticCreateBadContentDocument tests static create with bad content +func TestStaticCreateBadContentDocument(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + // Setup multipart/form-data body with content too short + var b bytes.Buffer + mw := multipart.NewWriter(&b) + mw.WriteField("content", "x") + mw.Close() + + req, _ := http.NewRequest(http.MethodPost, "/", &b) + req.Header.Add("Content-Type", mw.FormDataContentType()) + rr := executeRequest(req, srv) + + require.Equal(t, http.StatusBadRequest, rr.Result().StatusCode) +} + +// TestCreateDocumentDatabaseError tests creating a document when database fails +func TestCreateDocumentDatabaseError(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + mockDB.CreateDocumentReturns(errors.New("database error")) + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + req, _ := http.NewRequest(http.MethodPost, "/api/", + bytes.NewReader([]byte(`{"content": "test"}`)), + ) + req.Header.Set("Content-Type", "application/json") + rr := executeRequest(req, srv) + + require.Equal(t, http.StatusInternalServerError, rr.Result().StatusCode) + + x, _ := io.ReadAll(rr.Result().Body) + var body DocumentResponse + json.Unmarshal(x, &body) + + require.Equal(t, "database error", body.Error) +} + +// TestStaticCreateDocumentDatabaseError tests static create when database fails +func TestStaticCreateDocumentDatabaseError(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + mockDB.CreateDocumentReturns(errors.New("database error")) + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + var b bytes.Buffer + mw := multipart.NewWriter(&b) + mw.WriteField("content", "test") + mw.Close() + + req, _ := http.NewRequest(http.MethodPost, "/", &b) + req.Header.Add("Content-Type", mw.FormDataContentType()) + rr := executeRequest(req, srv) + + require.Equal(t, http.StatusInternalServerError, rr.Result().StatusCode) +} + +// TestCreateDocumentGetDocumentError tests when GetDocument fails after CreateDocument +func TestCreateDocumentGetDocumentError(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + mockDB.GetDocumentReturns(database.Document{}, errors.New("get document error")) + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + req, _ := http.NewRequest(http.MethodPost, "/api/", + bytes.NewReader([]byte(`{"content": "test"}`)), + ) + req.Header.Set("Content-Type", "application/json") + rr := executeRequest(req, srv) + + require.Equal(t, http.StatusInternalServerError, rr.Result().StatusCode) + + x, _ := io.ReadAll(rr.Result().Body) + var body DocumentResponse + json.Unmarshal(x, &body) + + require.Equal(t, "get document error", body.Error) +} + +// TestStaticCreateDocumentGetDocumentError tests when GetDocument fails after StaticCreateDocument +func TestStaticCreateDocumentGetDocumentError(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + mockDB.GetDocumentReturns(database.Document{}, errors.New("get document error")) + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + var b bytes.Buffer + mw := multipart.NewWriter(&b) + mw.WriteField("content", "test") + mw.Close() + + req, _ := http.NewRequest(http.MethodPost, "/", &b) + req.Header.Add("Content-Type", mw.FormDataContentType()) + rr := executeRequest(req, srv) + + require.Equal(t, http.StatusInternalServerError, rr.Result().StatusCode) +} + +// TestCreateDocumentHandleBodyError tests when HandleCreateBody fails +func TestCreateDocumentHandleBodyError(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + // Send invalid JSON + req, _ := http.NewRequest(http.MethodPost, "/api/", + bytes.NewReader([]byte(`{invalid json`)), + ) + req.Header.Set("Content-Type", "application/json") + rr := executeRequest(req, srv) + + require.Equal(t, http.StatusInternalServerError, rr.Result().StatusCode) } diff --git a/internal/server/fetch.go b/internal/server/fetch.go index a3376caa..b9fdeb83 100644 --- a/internal/server/fetch.go +++ b/internal/server/fetch.go @@ -36,6 +36,16 @@ func getDocument(s *Server, ctx context.Context, id string) (database.Document, return s.Database.GetDocument(ctx, id) } +func (s *Server) getUsername(r *http.Request) string { + username, err := s.authenticatedUsername(r) + + if err != nil { + return "accounts disabled" + } + + return username +} + func (s *Server) StaticDocument(w http.ResponseWriter, r *http.Request) { params := strings.Split(chi.URLParam(r, "document"), ".") id := params[0] @@ -47,6 +57,8 @@ func (s *Server) StaticDocument(w http.ResponseWriter, r *http.Request) { return } + username := s.getUsername(r) + // Retrieve document from the database document, err := getDocument(s, r.Context(), id) @@ -62,37 +74,65 @@ func (s *Server) StaticDocument(w http.ResponseWriter, r *http.Request) { return } - t, err := template.ParseFS(resources, "web/document.html") + // Reader mode or code mode? + if r.URL.Query().Get("reader") == "true" { + t, err := template.ParseFS(resources, "web/reader.html") - if err != nil { - util.RenderError(&resources, w, http.StatusInternalServerError, err) - return - } + if err != nil { + util.RenderError(&resources, w, http.StatusInternalServerError, err) + return + } - extension := "" + content := util.ParseMarkdown([]byte(document.Content)) - if len(params) == 2 { - extension = params[1] - } + data := map[string]any{ + "Content": template.HTML(string(content)), + "Analytics": config.Config.Analytics, + "AccountsEnabled": config.Config.AccountsEnabled, + "Authenticated": username != "", + "Username": username, + } - highlighted, css, err := util.Highlight(document.Content, extension) + if err := t.Execute(w, data); err != nil { + util.RenderError(&resources, w, http.StatusInternalServerError, err) + return + } + } else { + t, err := template.ParseFS(resources, "web/document.html") - if err != nil { - util.RenderError(&resources, w, http.StatusInternalServerError, err) - return - } + if err != nil { + util.RenderError(&resources, w, http.StatusInternalServerError, err) + return + } - data := map[string]interface{}{ - "Stylesheet": template.CSS(css), - "Content": document.Content, - "Highlighted": template.HTML(highlighted), - "Extension": extension, - "Analytics": template.HTML(config.Config.Analytics), - } + extension := "" - if err := t.Execute(w, data); err != nil { - util.RenderError(&resources, w, http.StatusInternalServerError, err) - return + if len(params) == 2 { + extension = params[1] + } + + highlighted, css, err := util.Highlight(document.Content, extension) + + if err != nil { + util.RenderError(&resources, w, http.StatusInternalServerError, err) + return + } + + data := map[string]any{ + "Stylesheet": template.CSS(css), + "Content": document.Content, + "Highlighted": template.HTML(highlighted), + "Extension": extension, + "Analytics": template.HTML(config.Config.Analytics), + "AccountsEnabled": config.Config.AccountsEnabled, + "Authenticated": username != "", + "Username": username, + } + + if err := t.Execute(w, data); err != nil { + util.RenderError(&resources, w, http.StatusInternalServerError, err) + return + } } } diff --git a/internal/server/fetch_test.go b/internal/server/fetch_test.go index da94c747..436361b4 100644 --- a/internal/server/fetch_test.go +++ b/internal/server/fetch_test.go @@ -17,7 +17,9 @@ package server_test import ( + "database/sql" "encoding/json" + "errors" "io" "net/http" "testing" @@ -119,3 +121,197 @@ func (s *FetchDocumentSuite) TestFetchBadIDDocument() { func TestFetchDocumentSuite(t *testing.T) { suite.Run(t, new(FetchDocumentSuite)) } + +// TestStaticDocument tests the static document handler +func TestStaticDocument(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + mockDB.GetDocumentReturns(database.Document{ + ID: "12345678", + Content: "# Test\n\nThis is a test document.", + CreatedAt: time.Date(1970, 1, 1, 1, 1, 1, 1, time.UTC), + UpdatedAt: time.Date(1970, 1, 1, 1, 1, 1, 1, time.UTC), + }, nil) + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + // Test normal document view + req, _ := http.NewRequest(http.MethodGet, "/12345678", nil) + res := executeRequest(req, srv) + + require.Equal(t, http.StatusOK, res.Result().StatusCode) + require.Contains(t, res.Body.String(), "Test") +} + +// TestStaticDocumentWithExtension tests static document with file extension +func TestStaticDocumentWithExtension(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + mockDB.GetDocumentReturns(database.Document{ + ID: "12345678", + Content: "package main\n\nfunc main() {}", + CreatedAt: time.Date(1970, 1, 1, 1, 1, 1, 1, time.UTC), + UpdatedAt: time.Date(1970, 1, 1, 1, 1, 1, 1, time.UTC), + }, nil) + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + req, _ := http.NewRequest(http.MethodGet, "/12345678.go", nil) + res := executeRequest(req, srv) + + require.Equal(t, http.StatusOK, res.Result().StatusCode) + require.Contains(t, res.Body.String(), "package main") +} + +// TestStaticDocumentReaderMode tests static document in reader mode +func TestStaticDocumentReaderMode(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + mockDB.GetDocumentReturns(database.Document{ + ID: "12345678", + Content: "# Markdown Title\n\nThis is markdown content.", + CreatedAt: time.Date(1970, 1, 1, 1, 1, 1, 1, time.UTC), + UpdatedAt: time.Date(1970, 1, 1, 1, 1, 1, 1, time.UTC), + }, nil) + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + req, _ := http.NewRequest(http.MethodGet, "/12345678?reader=true", nil) + res := executeRequest(req, srv) + + require.Equal(t, http.StatusOK, res.Result().StatusCode) + require.Contains(t, res.Body.String(), "Markdown Title") +} + +// TestStaticDocumentNotFound tests static document when not found +func TestStaticDocumentNotFound(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + mockDB.GetDocumentReturns(database.Document{}, sql.ErrNoRows) + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + req, _ := http.NewRequest(http.MethodGet, "/12345678", nil) + res := executeRequest(req, srv) + + require.Equal(t, http.StatusNotFound, res.Result().StatusCode) +} + +// TestStaticDocumentBadID tests static document with bad ID +func TestStaticDocumentBadID(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + req, _ := http.NewRequest(http.MethodGet, "/1234", nil) + res := executeRequest(req, srv) + + require.Equal(t, http.StatusBadRequest, res.Result().StatusCode) +} + +// TestFetchDocumentDatabaseError tests FetchDocument when database returns error +func TestFetchDocumentDatabaseError(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + mockDB.GetDocumentReturns(database.Document{}, errors.New("database error")) + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + req, _ := http.NewRequest(http.MethodGet, "/api/12345678", nil) + res := executeRequest(req, srv) + + require.Equal(t, http.StatusInternalServerError, res.Result().StatusCode) + + x, _ := io.ReadAll(res.Result().Body) + var body DocumentResponse + json.Unmarshal(x, &body) + + require.Equal(t, "database error", body.Error) +} + +// TestFetchRawDocumentDatabaseError tests FetchRawDocument when database returns error +func TestFetchRawDocumentDatabaseError(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + mockDB.GetDocumentReturns(database.Document{}, errors.New("database error")) + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + req, _ := http.NewRequest(http.MethodGet, "/api/12345678/raw", nil) + res := executeRequest(req, srv) + + require.Equal(t, http.StatusInternalServerError, res.Result().StatusCode) + require.Contains(t, res.Body.String(), "database error") +} + +// TestStaticDocumentDatabaseError tests StaticDocument when database returns error +func TestStaticDocumentDatabaseError(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + mockDB.GetDocumentReturns(database.Document{}, errors.New("database error")) + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + req, _ := http.NewRequest(http.MethodGet, "/12345678", nil) + res := executeRequest(req, srv) + + require.Equal(t, http.StatusInternalServerError, res.Result().StatusCode) +} + +// TestFetchNotFoundDocument tests fetching a non-existent document +func TestFetchNotFoundDocument(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + mockDB.GetDocumentReturns(database.Document{}, sql.ErrNoRows) + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + req, _ := http.NewRequest(http.MethodGet, "/api/12345678", nil) + res := executeRequest(req, srv) + + require.Equal(t, http.StatusNotFound, res.Result().StatusCode) + require.Equal(t, "application/json", res.Result().Header.Get("Content-Type")) + + x, _ := io.ReadAll(res.Result().Body) + var body DocumentResponse + json.Unmarshal(x, &body) + + require.Equal(t, "sql: no rows in result set", body.Error) +} + +// TestFetchRawNotFoundDocument tests fetching a non-existent document in raw format +func TestFetchRawNotFoundDocument(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + mockDB.GetDocumentReturns(database.Document{}, sql.ErrNoRows) + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + req, _ := http.NewRequest(http.MethodGet, "/api/12345678/raw", nil) + res := executeRequest(req, srv) + + require.Equal(t, http.StatusNotFound, res.Result().StatusCode) + require.Equal(t, "text/plain", res.Result().Header.Get("Content-Type")) + require.Contains(t, res.Body.String(), "Document with ID 12345678 not found") +} + +// TestFetchRawBadIDDocument tests fetching a document with bad ID in raw format +func TestFetchRawBadIDDocument(t *testing.T) { + mockDB := &databasefakes.FakeDatabase{} + + srv := server.NewServer(&mockConfig, mockDB) + srv.MountHandlers() + + req, _ := http.NewRequest(http.MethodGet, "/api/1234/raw", nil) + res := executeRequest(req, srv) + + require.Equal(t, http.StatusBadRequest, res.Result().StatusCode) + require.Equal(t, "application/json", res.Result().Header.Get("Content-Type")) + + x, _ := io.ReadAll(res.Result().Body) + var body DocumentResponse + json.Unmarshal(x, &body) + + require.Equal(t, "id is of length 4, should be 8", body.Error) +} diff --git a/internal/server/server.go b/internal/server/server.go index cb4e82db..57fbc6cd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -158,8 +158,17 @@ func (s *Server) MountStatic() { return } - err = t.Execute(w, map[string]interface{}{ - "Analytics": config.Config.Analytics, + username, err := s.authenticatedUsername(r) + if err != nil { + util.WriteError(w, http.StatusInternalServerError, err) + return + } + + err = t.Execute(w, map[string]any{ + "Analytics": config.Config.Analytics, + "AccountsEnabled": config.Config.AccountsEnabled, + "Authenticated": username != "", + "Username": username, }) if err != nil { @@ -173,14 +182,27 @@ func (s *Server) MountHandlers() { // Register routes s.Router.Get("/config", s.GetConfig) + // Document routes s.Router.Post("/api/", s.CreateDocument) s.Router.Get("/api/{document}", s.FetchDocument) s.Router.Get("/api/{document}/raw", s.FetchRawDocument) + // Account routes + s.Router.Post("/api/signin", s.SignIn) + s.Router.Post("/api/signup", s.SignUp) + s.Router.Post("/api/logout", s.Logout) + + // Static document routes s.Router.Post("/", s.StaticCreateDocument) s.Router.Get("/{document}", s.StaticDocument) s.Router.Get("/{document}/raw", s.FetchRawDocument) + // Static account routes + s.Router.Get("/signin", s.StaticSignIn) + s.Router.Get("/signup", s.StaticSignUp) + s.Router.Get("/account", s.StaticSettingsPage) + s.Router.Post("/logout", s.StaticLogout) + // Legacy routes s.Router.Post("/v1/documents/", s.CreateDocument) s.Router.Get("/v1/documents/{document}", s.FetchDocument) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 7a974cf8..b204b6a7 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -103,3 +103,71 @@ func TestRegisterHeaders(t *testing.T) { require.Equal(t, "max-age=31536000; includeSubDomains; preload", res.Result().Header.Get("Strict-Transport-Security")) require.Equal(t, mockConfig.ContentSecurityPolicy, res.Result().Header.Get("Content-Security-Policy")) } + +// TestMountMiddleware tests mounting middleware on the server +func TestMountMiddleware(t *testing.T) { + s := server.NewServer(&mockConfig, &databasefakes.FakeDatabase{}) + + s.MountMiddleware() + s.Router.Get("/test", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + res := executeRequest(req, s) + + checkResponseCode(t, http.StatusOK, res.Result().StatusCode) + + // Test ping heartbeat endpoint + pingReq, _ := http.NewRequest(http.MethodGet, "/ping", nil) + pingRes := executeRequest(pingReq, s) + checkResponseCode(t, http.StatusOK, pingRes.Result().StatusCode) + require.Equal(t, ".", pingRes.Body.String()) +} + +// TestMountMiddlewareWithBasicAuth tests middleware with basic auth +func TestMountMiddlewareWithBasicAuth(t *testing.T) { + authConfig := mockConfig + authConfig.Username = "testuser" + authConfig.Password = "testpass" + + s := server.NewServer(&authConfig, &databasefakes.FakeDatabase{}) + s.MountMiddleware() + s.Router.Get("/test", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("authenticated")) + }) + + // Request without auth should fail + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + res := executeRequest(req, s) + checkResponseCode(t, http.StatusUnauthorized, res.Result().StatusCode) + + // Request with correct auth should succeed + authReq, _ := http.NewRequest(http.MethodGet, "/test", nil) + authReq.SetBasicAuth("testuser", "testpass") + authRes := executeRequest(authReq, s) + checkResponseCode(t, http.StatusOK, authRes.Result().StatusCode) + require.Equal(t, "authenticated", authRes.Body.String()) +} + +// TestMountMiddlewareWithInvalidRatelimiter tests middleware with invalid ratelimiter +func TestMountMiddlewareWithInvalidRatelimiter(t *testing.T) { + invalidConfig := mockConfig + invalidConfig.Ratelimiter = "invalid-format" + + s := server.NewServer(&invalidConfig, &databasefakes.FakeDatabase{}) + s.MountMiddleware() // Should log error but not panic + s.Router.Get("/test", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + + req, _ := http.NewRequest(http.MethodGet, "/test", nil) + res := executeRequest(req, s) + checkResponseCode(t, http.StatusOK, res.Result().StatusCode) +} + + diff --git a/internal/server/web/account.html b/internal/server/web/account.html new file mode 100644 index 00000000..e5a524e8 --- /dev/null +++ b/internal/server/web/account.html @@ -0,0 +1,106 @@ + + + + + + + + + Spacebin + + + + + + + + + + + + + + +
+ Spacebin Logo + + + + + + + + + + + + + + + + + + + + + + {{if eq .AccountsEnabled true}} +
+ {{if eq .Authenticated true }} + + {{.Username}} + + {{else}} + + Sign In + + + Sign Up + + {{end}} +
+ {{end}} +
+ +
+

+ + + + + + Account Settings +

+
+
+ +
+ +
+
+ + + + + + + diff --git a/internal/server/web/document.html b/internal/server/web/document.html index d91c7359..2d1570b7 100644 --- a/internal/server/web/document.html +++ b/internal/server/web/document.html @@ -48,11 +48,11 @@ - - - - + + + + @@ -64,6 +64,29 @@ + {{if eq .AccountsEnabled true}} + + {{end}} + + +
+
{{.Highlighted}}
+
+ + - \ No newline at end of file + diff --git a/internal/server/web/error.html b/internal/server/web/error.html index b34fa9dc..c5f98b14 100644 --- a/internal/server/web/error.html +++ b/internal/server/web/error.html @@ -26,14 +26,13 @@
Spacebin Logo - + - - -
@@ -77,7 +65,20 @@

{{.Error}}

+ + - \ No newline at end of file + diff --git a/internal/server/web/index.html b/internal/server/web/index.html index 787c3e5a..958e5e4a 100644 --- a/internal/server/web/index.html +++ b/internal/server/web/index.html @@ -45,25 +45,22 @@ - - - - - - - - - + {{if eq .AccountsEnabled true}} +
+ {{if eq .Authenticated true }} + + {{.Username}} + + {{else}} + + Sign In + + + Sign Up + + {{end}} +
+ {{end}}
@@ -76,6 +73,19 @@
+ + diff --git a/internal/server/web/reader.html b/internal/server/web/reader.html new file mode 100644 index 00000000..02b57694 --- /dev/null +++ b/internal/server/web/reader.html @@ -0,0 +1,117 @@ + + + + + + + + + Spacebin + + + + + + + + + + + + + + + + + {{.Analytics}} + + + + +
+ Spacebin Logo + + + + + + + + + + + + + + + + + + + + + + + + {{if eq .AccountsEnabled true}} +
+ {{if eq .Authenticated true }} + + {{.Username}} + + {{else}} + + Sign In + + + Sign Up + + {{end}} +
+ {{end}} +
+ +
+
+ + +
+ +
+ {{.Content}} +
+
+ + + + + + + diff --git a/internal/server/web/signin.html b/internal/server/web/signin.html new file mode 100644 index 00000000..2fbf9ffe --- /dev/null +++ b/internal/server/web/signin.html @@ -0,0 +1,108 @@ + + + + + + + + + Spacebin + + + + + + + + + + + + + + +
+ Spacebin Logo + + + + + + + + + + + + + + + + + + + + + + {{if eq .AccountsEnabled true}} +
+ {{if eq .Authenticated true }} + + {{.Username}} + + {{else}} + + Sign In + + + Sign Up + + {{end}} +
+ {{end}} +
+ +
+

+ + + + + + + Sign In +

+
+
+
+
+

+ +
+
+ + + + + + + diff --git a/internal/server/web/signup.html b/internal/server/web/signup.html new file mode 100644 index 00000000..f85cee08 --- /dev/null +++ b/internal/server/web/signup.html @@ -0,0 +1,108 @@ + + + + + + + + + Spacebin + + + + + + + + + + + + + + +
+ Spacebin Logo + + + + + + + + + + + + + + + + + + + + + + {{if eq .AccountsEnabled true}} +
+ {{if eq .Authenticated true }} + + {{.Username}} + + {{else}} + + Sign In + + + Sign Up + + {{end}} +
+ {{end}} +
+ +
+

+ + + + + + + Sign Up +

+
+
+
+
+

+ +
+
+ + + + + + + diff --git a/internal/server/web/static/app.js b/internal/server/web/static/app.js index 741d9dd4..1d905b3b 100644 --- a/internal/server/web/static/app.js +++ b/internal/server/web/static/app.js @@ -13,3 +13,30 @@ document.querySelector('textarea')?.addEventListener('keydown', function (e) { this.selectionStart = this.selectionEnd = start + 1; } }); + +// Allows for saving with CTRL+S and CMD+S +document.addEventListener('keydown', function(e) { + const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; + if (e.key.toLowerCase() === 's' && (isMac ? e.metaKey : e.ctrlKey)) { + e.preventDefault(); + document.querySelector('#text').submit(); + } +}); + +function switchFont(to) { + const main = document.querySelector('.wysiwyg'); + + if (to === 'sans') { + main.classList.remove('font-serif', 'font-sans'); + main.classList.add('font-sans'); + + document.querySelector('#serif').classList.remove('active'); + document.querySelector('#sans').classList.add('active'); + } else if (to === 'serif') { + main.classList.remove('font-serif', 'font-sans'); + main.classList.add('font-serif'); + + document.querySelector('#sans').classList.remove('active'); + document.querySelector('#serif').classList.add('active'); + } +} diff --git a/internal/server/web/static/global.css b/internal/server/web/static/global.css index 0191c8ae..60a72d8d 100644 --- a/internal/server/web/static/global.css +++ b/internal/server/web/static/global.css @@ -9,6 +9,7 @@ --color-links-dark: #7a98d8; --color-foreground: #dedede; --color-background: #121212; + --color-buttons: #1d1c1c; } * { @@ -57,11 +58,26 @@ header { padding: 3px 9px; margin: 0; display: flex; + align-content: center; user-select: none; margin-bottom: 4px; gap: 10px; } +footer { + padding: 0; + margin: 0; + position: fixed; + bottom: 0; + right: 0; + text-align: right; +} + +footer p { + padding: 5px; + margin: 0; +} + #prompt { color: var(--color-prompt); z-index: -1000; @@ -87,7 +103,8 @@ pre code { } button, -a { +a, +input[type=submit] { background: none; padding: 0; margin: 0; @@ -101,7 +118,9 @@ a { button:hover, button:focus, a:hover, -a:focus { +a:focus, +input[type=submit]:hover, +input[type=submit]:focus { color: var(--color-links-dark); } @@ -115,6 +134,9 @@ h1 { line-height: 1.75rem; /* 28px */ margin-bottom: 5px; padding: 0; + display: flex; + gap: 10px; + align-items: center; } #error { @@ -127,16 +149,71 @@ h1 { color: #f97583; } -#donate-long, -#donate-short { +#form { + display: flex; + max-width: fit-content; + flex-direction: column; + margin: auto; + width: 100%; + justify-content: center; +} + +form:not(#text):not(#signout-btn-form) { + display: flex; + flex-direction: column; + background-color: var(--color-buttons); + padding: 1rem; + gap: 3px; +} + +.btn-group { + display: flex; + max-width: fit-content; + gap: 5px; +} + +.btn-group button { + padding: 5px 10px; + color: var(--color-foreground); + background-color: var(--color-buttons); +} + +input { + width: 100%; + padding: 10px; + background-color: var(--color-background); + color: var(--color-foreground); + border: none; +} + +#authentication { margin: 0 0 0 auto; + display: flex; + gap: 5px; +} + +#authentication a { + color: var(--color-foreground); + background-color: var(--color-buttons); + border: 0.5px solid var(--color-buttons); + padding: 3px 8px; +} + +#authentication a:hover { + transition: background-color 0.3s ease-in-out; + background-color: color-mix(in oklab, var(--color-buttons), black 10%); } #donate-link:hover, -#short-donate-link:hover { +#short-donate-link:hover, +input[type=submit]:hover { text-decoration: underline; } +input[type=submit] { + padding: 5px; +} + #donate-short { display: none; } diff --git a/internal/server/web/static/reader.css b/internal/server/web/static/reader.css new file mode 100644 index 00000000..23b184ef --- /dev/null +++ b/internal/server/web/static/reader.css @@ -0,0 +1,289 @@ + +.wysiwyg { + font-size: 1rem; + line-height: 2; + color: var(--color-foreground); +} + +#reader { + margin: 0 auto; + padding: 0 25%; +} + +.font-sans { + font-family: -apple-system, BlinkMacSystemFont, "Avenir Next", "Avenir", "Segoe UI", "Helvetica Neue", "Helvetica", "Cantarell", "Ubuntu", "Roboto", "Noto", "Arial", sans-serif; +} + +.font-serif { + font-family: "Iowan Old Style", "Apple Garamond", "Baskerville", "Times New Roman", "Droid Serif", "Times", "Source Serif Pro", serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; +} + +#font-button-group { + display: flex; + width: fit-content; + gap: 1px; + background-color: var(--color-buttons); + padding: 5px; + margin-bottom: 24px; +} + +#font-button-group button { + padding: 5px 10px; + margin: 0; + border: none; + color: var(--color-links); + font-size: var(--font-size); + cursor: pointer; + text-decoration: none; +} + +button.active { + background-color: var(--color-background); +} + +/*! wysiwyg.css v0.0.3 | MIT License | github.com/jgthms/wysiwyg.css */ +.wysiwyg { + line-height: 1.6; +} +.wysiwyg a { + text-decoration: none; +} +.wysiwyg a:hover { + border-bottom: 1px solid; +} +.wysiwyg abbr { + border-bottom: 1px dotted; + cursor: help; +} +.wysiwyg cite { + font-style: italic; +} +.wysiwyg hr { + background: #e6e6e6; + border: none; + display: block; + height: 1px; + margin-bottom: 1.4em; + margin-top: 1.4em; +} +.wysiwyg img { + vertical-align: text-bottom; +} +.wysiwyg ins { + background-color: lime; + text-decoration: none; +} +.wysiwyg mark { + background-color: #ff0; +} +.wysiwyg small { + font-size: 0.8em; +} +.wysiwyg strong { + font-weight: 700; +} +.wysiwyg sub, +.wysiwyg sup { + font-size: 0.8em; +} +.wysiwyg sub { + vertical-align: sub; +} +.wysiwyg sup { + vertical-align: super; +} +.wysiwyg p, +.wysiwyg dl, +.wysiwyg ol, +.wysiwyg ul, +.wysiwyg blockquote, +.wysiwyg pre, +.wysiwyg table { + margin-bottom: 1.4em; +} +.wysiwyg p:last-child, +.wysiwyg dl:last-child, +.wysiwyg ol:last-child, +.wysiwyg ul:last-child, +.wysiwyg blockquote:last-child, +.wysiwyg pre:last-child, +.wysiwyg table:last-child { + margin-bottom: 0; +} +.wysiwyg p:empty { + display: none; +} +.wysiwyg h1, +.wysiwyg h2, +.wysiwyg h3, +.wysiwyg h4, +.wysiwyg h5, +.wysiwyg h6 { + font-weight: 700; + line-height: 1.2; +} +.wysiwyg h1:first-child, +.wysiwyg h2:first-child, +.wysiwyg h3:first-child, +.wysiwyg h4:first-child, +.wysiwyg h5:first-child, +.wysiwyg h6:first-child { + margin-top: 0; +} +.wysiwyg h1 { + font-size: 2.4em; + margin-bottom: 0.58333em; + margin-top: 0.58333em; + line-height: 1; +} +.wysiwyg h2 { + font-size: 1.6em; + margin-bottom: 0.875em; + margin-top: 1.75em; + line-height: 1.1; +} +.wysiwyg h3 { + font-size: 1.3em; + margin-bottom: 1.07692em; + margin-top: 1.07692em; +} +.wysiwyg h4 { + font-size: 1.2em; + margin-bottom: 1.16667em; + margin-top: 1.16667em; +} +.wysiwyg h5 { + font-size: 1.1em; + margin-bottom: 1.27273em; + margin-top: 1.27273em; +} +.wysiwyg h6 { + font-size: 1em; + margin-bottom: 1.4em; + margin-top: 1.4em; +} +.wysiwyg dd { + margin-left: 1.4em; +} +.wysiwyg ol, +.wysiwyg ul { + list-style-position: outside; + margin-left: 1.4em; +} +.wysiwyg ol { + list-style-type: decimal; +} +.wysiwyg ol ol { + list-style-type: lower-alpha; +} +.wysiwyg ol ol ol { + list-style-type: lower-roman; +} +.wysiwyg ol ol ol ol { + list-style-type: lower-greek; +} +.wysiwyg ol ol ol ol ol { + list-style-type: decimal; +} +.wysiwyg ol ol ol ol ol ol { + list-style-type: lower-alpha; +} +.wysiwyg ul { + list-style-type: disc; +} +.wysiwyg ul ul { + list-style-type: circle; +} +.wysiwyg ul ul ul { + list-style-type: square; +} +.wysiwyg ul ul ul ul { + list-style-type: circle; +} +.wysiwyg ul ul ul ul ul { + list-style-type: disc; +} +.wysiwyg ul ul ul ul ul ul { + list-style-type: circle; +} +.wysiwyg blockquote { + border-left: 4px solid #e6e6e6; + padding: 0.6em 1.2em; +} +.wysiwyg blockquote p { + margin-bottom: 0; +} +.wysiwyg code, +.wysiwyg kbd, +.wysiwyg samp, +.wysiwyg pre { + -moz-osx-font-smoothing: auto; + -webkit-font-smoothing: auto; + background-color: #f2f2f2; + color: #333; + font-size: 0.9em; +} +.wysiwyg code, +.wysiwyg kbd, +.wysiwyg samp { + border-radius: 3px; + line-height: 1.77778; + padding: 0.1em 0.4em 0.2em; + vertical-align: baseline; +} +.wysiwyg pre { + overflow: auto; + padding: 1em 1.2em; +} +.wysiwyg pre code { + background: none; + font-size: 1em; + line-height: 1em; +} +.wysiwyg figure { + margin-bottom: 2.8em; + text-align: center; +} +.wysiwyg figure:first-child { + margin-top: 0; +} +.wysiwyg figure:last-child { + margin-bottom: 0; +} +.wysiwyg figcaption { + font-size: 0.8em; + margin-top: 0.875em; +} +.wysiwyg table { + width: 100%; +} +.wysiwyg table pre { + white-space: pre-wrap; +} +.wysiwyg th, +.wysiwyg td { + font-size: 1em; + padding: 0.7em; + border: 1px solid #e6e6e6; + line-height: 1.4; +} +.wysiwyg thead tr, +.wysiwyg tfoot tr { + background-color: #f5f5f5; +} +.wysiwyg thead th, +.wysiwyg thead td, +.wysiwyg tfoot th, +.wysiwyg tfoot td { + font-size: 0.9em; + padding: 0.77778em; +} +.wysiwyg thead th code, +.wysiwyg thead td code, +.wysiwyg tfoot th code, +.wysiwyg tfoot td code { + background-color: #fff; +} +.wysiwyg tbody tr { + background-color: #fff; +} diff --git a/internal/util/authentication.go b/internal/util/authentication.go new file mode 100644 index 00000000..88f1a645 --- /dev/null +++ b/internal/util/authentication.go @@ -0,0 +1,74 @@ +package util + +import ( + "crypto/rand" + "errors" + "fmt" + "log" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +func HashAndSalt(pwd []byte) string { + // Use GenerateFromPassword to hash & salt pwd. + // MinCost is just an integer constant provided by the bcrypt + // package along with DefaultCost & MaxCost. + // The cost can be any value you want provided it isn't lower + // than the MinCost (4) + hash, err := bcrypt.GenerateFromPassword(pwd, bcrypt.MinCost) + if err != nil { + log.Fatalln(err) + } // GenerateFromPassword returns a byte slice so we need to + // convert the bytes to a string and return it + return string(hash) +} + +func PrngString() (string, error) { + b := make([]byte, 10) + _, err := rand.Read(b) + if err != nil { + return "", err + } + + return fmt.Sprintf("%x", b), nil +} + +func GenerateStrings(bits []int) (a, b, c string, err error) { + if a, err = PrngString(); err != nil { + return "", "", "", err + } + + if b, err = PrngString(); err != nil { + return "", "", "", err + } + + if c, err = PrngString(); err != nil { + return "", "", "", err + } + + return a, b, c, err +} + +func ParseToken(token string) (Token, error) { + var tok Token + toks := strings.Split(token, ".") + + if len(toks) < 3 { + return Token{}, errors.New("invalid token") + } + + tok.Version = toks[0] + tok.Public = toks[1] + tok.Secret = toks[2] + + if len(toks) == 4 { + tok.Salt = toks[3] + } + + return tok, nil +} + +func MakeToken(token Token) string { + return fmt.Sprintf("%s.%s.%s.%s", token.Version, token.Public, token.Secret, token.Salt) +} diff --git a/internal/util/domain.go b/internal/util/domain.go index efbf6ad0..52e5ac92 100644 --- a/internal/util/domain.go +++ b/internal/util/domain.go @@ -28,3 +28,28 @@ type DocumentResponse struct { UpdatedAt int64 `json:"updated_at,omitempty"` // The Unix timestamp of when the document was last modified. Exists bool `json:"exists,omitempty"` // Whether the document does or does not exist. } + +// Token is an authentication token object +type Token struct { + Version string + Public string + Secret string + Salt string +} + +// CreateRequest represents a POST request to create a document +type CreateRequest struct { + Content string +} + +// SigninRequest represents a POST request to authenticate an account +type SigninRequest struct { + Username string + Password string +} + +// SignupRequest represents a POST request to register an account +type SignupRequest struct { + Username string + Password string +} diff --git a/internal/util/helpers.go b/internal/util/helpers.go index 7c70fc84..392be5ff 100644 --- a/internal/util/helpers.go +++ b/internal/util/helpers.go @@ -21,6 +21,7 @@ import ( "encoding/json" "fmt" "html/template" + "io" "math" "net/http" "strings" @@ -29,21 +30,31 @@ import ( "github.com/rs/zerolog/log" ) -type CreateRequest struct { - Content string -} +func ValidateBody[T CreateRequest | SigninRequest | SignupRequest](maxSize int, body T) error { + switch v := any(body).(type) { + case CreateRequest: + return validation.ValidateStruct(&v, + validation.Field(&v.Content, validation.Required, validation.Length(2, maxSize)), + ) + case SigninRequest: + return validation.ValidateStruct(&v, + validation.Field(&v.Username, validation.Required), + validation.Field(&v.Password, validation.Required, validation.Length(16, 128)), + ) + case SignupRequest: + return validation.ValidateStruct(&v, + validation.Field(&v.Username, validation.Required), + validation.Field(&v.Password, validation.Required, validation.Length(16, 128)), + ) + default: + return validation.Errors{"body": validation.NewError("validation_error", "unsupported request type")} + } -func ValidateBody(maxSize int, body CreateRequest) error { - return validation.ValidateStruct(&body, - validation.Field(&body.Content, validation.Required, - validation.Length(2, maxSize)), - ) } -// HandleBody figures out whether a incoming request is in JSON or multipart/form-data and decodes it appropriately -func HandleBody(maxSize int, r *http.Request) (CreateRequest, error) { +func HandleCreateBody(maxSize int, r *http.Request) (re CreateRequest, e error) { // Ignore charset or boundary fields, just get type of content - switch strings.Split(r.Header.Get("Content-Type"), ";")[0] { + switch contentType := strings.Split(r.Header.Get("Content-Type"), ";")[0]; contentType { case "application/json": resp := make(map[string]string) @@ -61,12 +72,97 @@ func HandleBody(maxSize int, r *http.Request) (CreateRequest, error) { return CreateRequest{}, err } + // Try to get the "content" field as plain text + content := r.FormValue("content") + + if content != "" { + return CreateRequest{ + Content: content, + }, nil + } + + // If "content" is not plain text, check for file uploads + file, _, err := r.FormFile("content") // Access file under the "content" name + if err != nil { + return CreateRequest{}, fmt.Errorf("failed to parse content field as file: %w", err) + } + defer file.Close() + + // Read the uploaded file's content + fileContent, err := io.ReadAll(file) + + if err != nil { + return CreateRequest{}, fmt.Errorf("failed to read uploaded file content: %w", err) + } + return CreateRequest{ - Content: r.FormValue("content"), + Content: string(fileContent), + }, nil + default: + return CreateRequest{}, fmt.Errorf("unsupported Content-Type: %s", contentType) + } +} + +// HandleSignupBody handles the body of a Signup request +func HandleSignupBody(maxSize int, r *http.Request) (re SignupRequest, e error) { + // Ignore charset or boundary fields, just get type of content + switch strings.Split(r.Header.Get("Content-Type"), ";")[0] { + case "application/json": + resp := make(map[string]string) + + if err := json.NewDecoder(r.Body).Decode(&resp); err != nil { + return SignupRequest{}, err + } + + return SignupRequest{ + Username: resp["username"], + Password: resp["password"], + }, nil + case "multipart/form-data": + err := r.ParseMultipartForm(int64(float64(maxSize) * math.Pow(1024, 2))) + + if err != nil { + return SignupRequest{}, err + } + + return SignupRequest{ + Username: r.FormValue("username"), + Password: r.FormValue("password"), + }, nil + } + + return SignupRequest{}, nil +} + +// HandleSigninBody handles the body of a Signin request +func HandleSigninBody(maxSize int, r *http.Request) (re SigninRequest, e error) { + // Ignore charset or boundary fields, just get type of content + switch strings.Split(r.Header.Get("Content-Type"), ";")[0] { + case "application/json": + resp := make(map[string]string) + + if err := json.NewDecoder(r.Body).Decode(&resp); err != nil { + return SigninRequest{}, err + } + + return SigninRequest{ + Username: resp["username"], + Password: resp["password"], + }, nil + case "multipart/form-data": + err := r.ParseMultipartForm(int64(float64(maxSize) * math.Pow(1024, 2))) + + if err != nil { + return SigninRequest{}, err + } + + return SigninRequest{ + Username: r.FormValue("username"), + Password: r.FormValue("password"), }, nil } - return CreateRequest{}, nil + return SigninRequest{}, nil } // WriteJSON writes a Request payload (p) to an HTTP response writer (w) diff --git a/internal/util/helpers_test.go b/internal/util/helpers_test.go index 751d092a..4f1dce3b 100644 --- a/internal/util/helpers_test.go +++ b/internal/util/helpers_test.go @@ -45,7 +45,7 @@ func TestValidateBody(t *testing.T) { })) } -func TestHandleBodyJSON(t *testing.T) { +func TestHandleCreateBodyJSON(t *testing.T) { var buf bytes.Buffer json.NewEncoder(&buf).Encode(map[string]interface{}{ "content": "Hello, world!", @@ -53,13 +53,13 @@ func TestHandleBodyJSON(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/", &buf) req.Header.Set("Content-Type", "application/json") - body, err := util.HandleBody(400000, req) + body, err := util.HandleCreateBody(400000, req) require.NoError(t, err) require.Equal(t, "Hello, world!", body.Content) } -func TestHandleBodyMultipart(t *testing.T) { +func TestHandleCreateBodyMultipart(t *testing.T) { var buf bytes.Buffer writer := multipart.NewWriter(&buf) fw, _ := writer.CreateFormField("content") @@ -68,20 +68,29 @@ func TestHandleBodyMultipart(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/", &buf) req.Header.Set("Content-Type", writer.FormDataContentType()) - body, err := util.HandleBody(400000, req) + body, err := util.HandleCreateBody(400000, req) require.NoError(t, err) require.Equal(t, "Hello, world!", body.Content) } -func TestHandleBodyNoContent(t *testing.T) { +func TestHandleCreateBodyNoContent(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/", &bytes.Buffer{}) - body, err := util.HandleBody(400000, req) + body, err := util.HandleCreateBody(400000, req) - require.NoError(t, err) + require.Error(t, err) require.Equal(t, "", body.Content) } +// TestHandleCreateBodyInvalidJSON tests HandleCreateBody with invalid JSON +func TestHandleCreateBodyInvalidJSON(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{invalid json")) + req.Header.Set("Content-Type", "application/json") + _, err := util.HandleCreateBody(400000, req) + + require.Error(t, err) +} + func TestWriteJSON(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { err := util.WriteJSON[map[string]interface{}](w, 200, map[string]interface{}{ @@ -132,3 +141,212 @@ func TestWriteError(t *testing.T) { "error": e.Error(), }) } + +// TestValidateBodySigninRequest tests ValidateBody with SigninRequest +func TestValidateBodySigninRequest(t *testing.T) { + // Valid signin request + validReq := util.SigninRequest{ + Username: "testuser", + Password: "validpassword123", + } + require.NoError(t, util.ValidateBody(100, validReq)) + + // Missing username + invalidReq := util.SigninRequest{ + Username: "", + Password: "validpassword123", + } + require.Error(t, util.ValidateBody(100, invalidReq)) + + // Password too short + invalidReq2 := util.SigninRequest{ + Username: "testuser", + Password: "short", + } + require.Error(t, util.ValidateBody(100, invalidReq2)) + + // Password too long (>128 chars) + longPassword := strings.Repeat("a", 129) + invalidReq3 := util.SigninRequest{ + Username: "testuser", + Password: longPassword, + } + require.Error(t, util.ValidateBody(100, invalidReq3)) +} + +// TestValidateBodySignupRequest tests ValidateBody with SignupRequest +func TestValidateBodySignupRequest(t *testing.T) { + // Valid signup request + validReq := util.SignupRequest{ + Username: "testuser", + Password: "validpassword123", + } + require.NoError(t, util.ValidateBody(100, validReq)) + + // Missing username + invalidReq := util.SignupRequest{ + Username: "", + Password: "validpassword123", + } + require.Error(t, util.ValidateBody(100, invalidReq)) + + // Password too short + invalidReq2 := util.SignupRequest{ + Username: "testuser", + Password: "short", + } + require.Error(t, util.ValidateBody(100, invalidReq2)) + + // Password too long (>128 chars) + longPassword := strings.Repeat("a", 129) + invalidReq3 := util.SignupRequest{ + Username: "testuser", + Password: longPassword, + } + require.Error(t, util.ValidateBody(100, invalidReq3)) + + // Missing password + invalidReq4 := util.SignupRequest{ + Username: "testuser", + Password: "", + } + require.Error(t, util.ValidateBody(100, invalidReq4)) +} + +// TestHandleSignupBodyJSON tests HandleSignupBody with JSON +func TestHandleSignupBodyJSON(t *testing.T) { + var buf bytes.Buffer + json.NewEncoder(&buf).Encode(map[string]interface{}{ + "username": "testuser", + "password": "testpassword1234", + }) + + req := httptest.NewRequest(http.MethodPost, "/", &buf) + req.Header.Set("Content-Type", "application/json") + body, err := util.HandleSignupBody(400000, req) + + require.NoError(t, err) + require.Equal(t, "testuser", body.Username) + require.Equal(t, "testpassword1234", body.Password) +} + +// TestHandleSignupBodyMultipart tests HandleSignupBody with multipart +func TestHandleSignupBodyMultipart(t *testing.T) { + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + writer.WriteField("username", "testuser") + writer.WriteField("password", "testpassword1234") + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/", &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + body, err := util.HandleSignupBody(400000, req) + + require.NoError(t, err) + require.Equal(t, "testuser", body.Username) + require.Equal(t, "testpassword1234", body.Password) +} + +// TestHandleSignupBodyNoContent tests HandleSignupBody with no content type +func TestHandleSignupBodyNoContent(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", &bytes.Buffer{}) + body, err := util.HandleSignupBody(400000, req) + + require.NoError(t, err) + require.Equal(t, "", body.Username) + require.Equal(t, "", body.Password) +} + +// TestHandleSignupBodyInvalidJSON tests HandleSignupBody with invalid JSON +func TestHandleSignupBodyInvalidJSON(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{invalid")) + req.Header.Set("Content-Type", "application/json") + _, err := util.HandleSignupBody(400000, req) + + require.Error(t, err) +} + +// TestHandleSigninBodyJSON tests HandleSigninBody with JSON +func TestHandleSigninBodyJSON(t *testing.T) { + var buf bytes.Buffer + json.NewEncoder(&buf).Encode(map[string]interface{}{ + "username": "testuser", + "password": "testpassword1234", + }) + + req := httptest.NewRequest(http.MethodPost, "/", &buf) + req.Header.Set("Content-Type", "application/json") + body, err := util.HandleSigninBody(400000, req) + + require.NoError(t, err) + require.Equal(t, "testuser", body.Username) + require.Equal(t, "testpassword1234", body.Password) +} + +// TestHandleSigninBodyMultipart tests HandleSigninBody with multipart +func TestHandleSigninBodyMultipart(t *testing.T) { + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + writer.WriteField("username", "testuser") + writer.WriteField("password", "testpassword1234") + writer.Close() + + req := httptest.NewRequest(http.MethodPost, "/", &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) + body, err := util.HandleSigninBody(400000, req) + + require.NoError(t, err) + require.Equal(t, "testuser", body.Username) + require.Equal(t, "testpassword1234", body.Password) +} + +// TestHandleSigninBodyNoContent tests HandleSigninBody with no content type +func TestHandleSigninBodyNoContent(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", &bytes.Buffer{}) + body, err := util.HandleSigninBody(400000, req) + + require.NoError(t, err) + require.Equal(t, "", body.Username) + require.Equal(t, "", body.Password) +} + +// TestHandleSigninBodyInvalidJSON tests HandleSigninBody with invalid JSON +func TestHandleSigninBodyInvalidJSON(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("{invalid")) + req.Header.Set("Content-Type", "application/json") + _, err := util.HandleSigninBody(400000, req) + + require.Error(t, err) +} + +// TestHandleSignupBodyMultipartError tests HandleSignupBody with multipart parse error +func TestHandleSignupBodyMultipartError(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("invalid multipart")) + req.Header.Set("Content-Type", "multipart/form-data; boundary=----test") + _, err := util.HandleSignupBody(1, req) // Small maxSize to trigger error + + require.Error(t, err) +} + +// TestHandleSigninBodyMultipartError tests HandleSigninBody with multipart parse error +func TestHandleSigninBodyMultipartError(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("invalid multipart")) + req.Header.Set("Content-Type", "multipart/form-data; boundary=----test") + _, err := util.HandleSigninBody(1, req) // Small maxSize to trigger error + + require.Error(t, err) +} + +// TestHandleCreateBodyMultipartError tests HandleCreateBody with multipart parse error +func TestHandleCreateBodyMultipartError(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("invalid multipart data")) + req.Header.Set("Content-Type", "multipart/form-data; boundary=----boundary") + _, err := util.HandleCreateBody(1, req) // Very small maxSize to trigger error + + require.Error(t, err) +} + +// TestRenderError is tested indirectly through server.StaticDocument error paths +// Testing it directly in the util package would require complex embed.FS setup +// that mirrors the server package structure. The function is fully covered +// by server integration tests. diff --git a/internal/util/highlight_test.go b/internal/util/highlight_test.go index bb1bf26a..635b3ed4 100644 --- a/internal/util/highlight_test.go +++ b/internal/util/highlight_test.go @@ -43,6 +43,36 @@ func TestHighlight(t *testing.T) { extension: "", expectError: false, }, + { + name: "Code with no extension - lexer analyse", + code: "console.log('test');", + extension: "", + expectError: false, + }, + { + name: "Extremely long extension that doesn't exist", + code: "test content", + extension: "thisdoesnotexistatall123456789", + expectError: false, // Should fallback to default lexer + }, + { + name: "Various programming languages", + code: "import java.util.*;", + extension: "java", + expectError: false, + }, + { + name: "C code", + code: "#include \nint main() { return 0; }", + extension: "c", + expectError: false, + }, + { + name: "JavaScript code", + code: "function test() { return true; }", + extension: "js", + expectError: false, + }, } for _, tt := range tests { @@ -61,3 +91,36 @@ func TestHighlight(t *testing.T) { }) } } + +// TestHighlightNilLexer tests the fallback when lexer is nil +func TestHighlightNilLexer(t *testing.T) { + // Test with an extension that doesn't exist to trigger nil lexer + html, css, err := Highlight("some random text", "nonexistentextension") + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + if html == "" || css == "" { + t.Error("Expected non-empty output even with nil lexer (should use fallback)") + } +} + +// TestHighlightWithAnalyse tests code analysis without extension +func TestHighlightWithAnalyse(t *testing.T) { + // Test various code snippets to ensure Analyse path is covered + testCases := []string{ + "def foo(): pass", // Python + "function test() {}", // JavaScript + "", // HTML + "SELECT * FROM users;", // SQL + } + + for _, code := range testCases { + html, css, err := Highlight(code, "") + if err != nil { + t.Errorf("Unexpected error for code %q: %v", code, err) + } + if html == "" || css == "" { + t.Errorf("Expected non-empty output for code %q", code) + } + } +} diff --git a/internal/util/markdown.go b/internal/util/markdown.go new file mode 100644 index 00000000..2ed52433 --- /dev/null +++ b/internal/util/markdown.go @@ -0,0 +1,20 @@ +package util + +import ( + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" +) + +func ParseMarkdown(md []byte) []byte { + extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock + p := parser.NewWithExtensions(extensions) + doc := p.Parse(md) + + // create HTML renderer with extensions + htmlFlags := html.CommonFlags | html.HrefTargetBlank + opts := html.RendererOptions{Flags: htmlFlags} + renderer := html.NewRenderer(opts) + + return markdown.Render(doc, renderer) +} diff --git a/internal/util/markdown_test.go b/internal/util/markdown_test.go new file mode 100644 index 00000000..9f33ac65 --- /dev/null +++ b/internal/util/markdown_test.go @@ -0,0 +1,56 @@ +/* +* Copyright 2020-2024 Luke Whritenour + +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at + +* http://www.apache.org/licenses/LICENSE-2.0 + +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. + */ + +package util_test + +import ( + "testing" + + "github.com/lukewhrit/spacebin/internal/util" + "github.com/stretchr/testify/require" +) + +func TestParseMarkdown(t *testing.T) { + // Test basic markdown + input := []byte("# Title\n\nThis is a paragraph.") + output := util.ParseMarkdown(input) + + require.NotEmpty(t, output) + require.Contains(t, string(output), "") + require.Contains(t, string(output), "This is a paragraph.") +} + +func TestParseMarkdownWithLinks(t *testing.T) { + // Test markdown with links + input := []byte("[Link](https://example.com)") + output := util.ParseMarkdown(input) + + require.NotEmpty(t, output) + require.Contains(t, string(output), "