diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index a42c631..1ff9fbf 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -28,8 +28,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - uses: actions/setup-go@v5 with: @@ -86,8 +87,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: Build run: go build ./... @@ -120,8 +122,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: Vet (dev tags) run: go vet -tags "test,dev" ./... @@ -154,8 +157,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: Vet (prod tags) run: go vet -tags "test,!dev" ./... @@ -188,8 +192,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: golangci-lint run: | @@ -224,8 +229,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: Staticcheck (dev tags) run: | @@ -260,8 +266,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: Staticcheck (prod tags) run: | @@ -296,8 +303,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: gosec (dev tags) run: | @@ -332,8 +340,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: gosec (prod tags) run: | @@ -368,8 +377,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: go mod tidy check run: | @@ -381,6 +391,7 @@ jobs: working-directory: backend test: + timeout-minutes: 30 name: Tests (${{ matrix.label }}) needs: [secrets-scan, go-vet-dev, go-vet-prod, go-lint, go-staticcheck-dev, go-staticcheck-prod, go-gosec-dev, go-gosec-prod, go-mod-tidy, vulns] runs-on: ubuntu-latest @@ -431,8 +442,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: Install psql client run: apk add --no-cache postgresql-client @@ -449,7 +461,7 @@ jobs: - name: Run tests working-directory: backend - run: go test -tags "${{ matrix.gotags }}" -count=1 -timeout 1800s ${{ matrix.verbose }} ${{ matrix.coverflags }} ./... + run: go test -tags "${{ matrix.gotags }}" -count=1 -timeout 120s ${{ matrix.verbose }} ${{ matrix.coverflags }} ./... env: GO_TESTING: "1" DAV_SKIP_INIT: "1" @@ -473,7 +485,7 @@ jobs: run: | if [ -f coverage.out ]; then COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//') - if (( $(echo "$COVERAGE < 60" | bc -l) )); then + if awk "BEGIN {exit !($COVERAGE < 60)}"; then echo "FAIL: Coverage $COVERAGE% is below 60% minimum" exit 1 fi @@ -489,6 +501,7 @@ jobs: fi race: + timeout-minutes: 30 name: Race (${{ matrix.label }}) needs: [secrets-scan, go-vet-dev, go-vet-prod, go-lint, go-staticcheck-dev, go-staticcheck-prod, go-gosec-dev, go-gosec-prod, go-mod-tidy, vulns] runs-on: ubuntu-latest @@ -535,8 +548,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: Install psql + build-base run: apk add --no-cache postgresql-client build-base @@ -553,7 +567,7 @@ jobs: - name: Run race detector working-directory: backend - run: go test -tags "${{ matrix.gotags }}" -race -count=1 -timeout 1800s ./... + run: go test -tags "${{ matrix.gotags }}" -race -count=1 -timeout 480s ./... env: GO_TESTING: "1" DAV_SKIP_INIT: "1" @@ -588,8 +602,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: Go vulnerability scan working-directory: backend @@ -608,8 +623,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: Cache npm dependencies uses: actions/cache@v4 @@ -698,8 +714,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: Restore npm dependencies uses: actions/cache@v4 @@ -750,8 +767,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: Restore npm dependencies uses: actions/cache@v4 @@ -781,8 +799,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: Restore npm dependencies uses: actions/cache@v4 @@ -811,8 +830,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: Cache npm dependencies uses: actions/cache@v4 @@ -842,8 +862,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: Restore npm dependencies uses: actions/cache@v4 @@ -885,8 +906,9 @@ jobs: - name: Fix node toolcache path for Post-step cleanup run: | - mkdir -p /opt/hostedtoolcache/node/22.23.1/x64/bin - ln -sf /usr/local/bin/node /opt/hostedtoolcache/node/22.23.1/x64/bin/node + NODE_MAJOR=$(node -e "console.log(process.version.slice(1).split('.')[0])") + mkdir -p /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin + ln -sf $(which node) /opt/hostedtoolcache/node/${NODE_MAJOR}.0.0/x64/bin/node - name: Restore npm dependencies uses: actions/cache@v4 diff --git a/backend/.golangci.yml b/backend/.golangci.yml new file mode 100644 index 0000000..351c37b --- /dev/null +++ b/backend/.golangci.yml @@ -0,0 +1,38 @@ +linters: + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + - gosec + - gocritic + disable: + - exhaustruct + - nlreturn + - wsl + - wrapcheck + +linters-settings: + errcheck: + exclude-functions: + - encoding/json.Encoder.Encode + - io.WriteString + - (io.Closer).Close + +issues: + exclude-rules: + - path: _test\.go + linters: + - errcheck + - gosec + - path: handlers/testutils/ + linters: + - gosec + - path: internal/square/square_dev.go + linters: + - errcheck + +run: + timeout: 5m diff --git a/backend/handlers/auth/local.go b/backend/handlers/auth/local.go index a88b2fc..c465e02 100644 --- a/backend/handlers/auth/local.go +++ b/backend/handlers/auth/local.go @@ -86,7 +86,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) { // Must accept terms if !req.AgreedToPolicy { - http.Error(w, "must agree to terms", http.StatusBadRequest) + mw.RespondError(w, http.StatusBadRequest, "must agree to terms") return } @@ -105,11 +105,11 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) { // Password must not exceed bcrypt's 72-byte limit if len(req.Password) > 72 { - http.Error(w, "password must be 72 characters or less", http.StatusBadRequest) + mw.RespondError(w, http.StatusBadRequest, "password must be 72 characters or less") return } if len(req.Password) < 6 { - http.Error(w, "password must be at least 6 characters", http.StatusBadRequest) + mw.RespondError(w, http.StatusBadRequest, "password must be at least 6 characters") return } // Server-side password strength check using the same @zxcvbn-ts/core as the frontend @@ -353,13 +353,13 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) { loginStateMu.Lock() if t, ok := loginInProgress[userID]; ok && time.Since(t) < 30*time.Second { loginStateMu.Unlock() - http.Error(w, "login already in progress", http.StatusConflict) // 409 + mw.RespondError(w, http.StatusConflict, "login already in progress") return } // Cap the map size - drop new request if at capacity if len(loginInProgress) >= maxLoginInProgress { loginStateMu.Unlock() - http.Error(w, "server busy, try again later", http.StatusTooManyRequests) + mw.RespondError(w, http.StatusTooManyRequests, "server busy, try again later") return } loginInProgress[userID] = clock.Now() @@ -429,7 +429,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) { tx, err := db.Conn.Begin(r.Context()) if err != nil { log.Printf("Failed to begin transaction: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) + mw.RespondError(w, http.StatusInternalServerError, "internal server error") return } defer func() { @@ -497,7 +497,7 @@ func RefreshTokenHandler(w http.ResponseWriter, r *http.Request) { // Generate new token newToken, jti, err := auth.GenerateToken(userID, currentRole) if err != nil { - http.Error(w, "could not generate token", http.StatusInternalServerError) + mw.RespondError(w, http.StatusInternalServerError, "could not generate token") return } @@ -518,7 +518,7 @@ func LogoutHandler(w http.ResponseWriter, r *http.Request) { // Revoke the JTI — match the access token lifetime (1 hour) if err := auth.RevokeJTI(r.Context(), jti, clock.Now().Add(1*time.Hour)); err != nil { slog.Error("logout: failed to revoke JTI", "err", err) - http.Error(w, "Failed to revoke token. Please try again.", http.StatusInternalServerError) + mw.RespondError(w, http.StatusInternalServerError, "failed to revoke token. please try again.") return } diff --git a/backend/mw/response.go b/backend/mw/response.go index 9b9397e..3d7ece1 100644 --- a/backend/mw/response.go +++ b/backend/mw/response.go @@ -14,3 +14,11 @@ func RespondJSON(w http.ResponseWriter, status int, data any) { _ = json.NewEncoder(w).Encode(data) } +// RespondError sends a JSON error response with the given status code and message. +// It preserves the application/json Content-Type set by middleware. +func RespondError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + diff --git a/frontend/package.json b/frontend/package.json index c1266ae..cee962b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,7 @@ "build": "vite build", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --fail-on-warnings", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "format": "prettier --write .", "lint": "prettier --check . && eslint .",