diff --git a/.agents/rules/go-engineering-standards.md b/.agents/rules/go-engineering-standards.md new file mode 100644 index 0000000..b8fbee8 --- /dev/null +++ b/.agents/rules/go-engineering-standards.md @@ -0,0 +1,71 @@ +# SelfCloud Go Engineering & Maintainability Standards + +All AI agents and contributors writing or modifying Go code in SelfCloud (`cmd/...`, `internal/...`, `test/...`) MUST adhere to these engineering standards. These rules are enforced automatically by `golangci-lint` via `make lint` and `make ci`. + +--- + +## 1. Complexity & Function Size Limits + +Every non-test Go function must stay within these hard limits: + +| Metric | Linter | Maximum Threshold | Required Action When Approaching Limit | +| :--- | :---: | :---: | :--- | +| **Cognitive Complexity** | `gocognit` | **20** | Extract sub-steps, loop bodies, or validation blocks into named helper functions. | +| **Cyclomatic Complexity** | `gocyclo` | **15** | Decompose `switch`/branching dispatchers into per-case handler functions. | +| **Function Length** | `funlen` | **80 lines / 50 stmts** | Split setup, I/O execution, transaction persistence, and response rendering. | +| **Nesting Depth** | `nestif` | **4 levels** | Invert conditions into early-return guard clauses (`if err != nil { return err }`). | + +### Required Structural Patterns +1. **Guard Clauses Over `else` Chains**: Never write `if cond { ... return } else { ... }`. Return or `continue` early so the happy path remains at the top indentation level. +2. **Three-Phase HTTP / WebDAV Handlers**: + - **Phase 1 (Validate)**: Parse request inputs, validate path length (`pathutil.ValidatePathLength`), and check preconditions. + - **Phase 2 (Execute)**: Perform filesystem I/O outside the DB transaction where possible, then persist metadata atomically via a focused helper (`persist*`, `save*`, `deleteNodeMetadata`). + - **Phase 3 (Respond)**: Record the audit event (`audit.Record` / `audit.RecordFailure`) and write the HTTP response. +3. **Context Structs for Recursive / Multi-Step Operations**: When recursive directory walks or reconciliation loops require more than 4 arguments, group shared dependencies (`ctx`, `user`, repositories, state maps) into a small unexported struct (e.g., `dirCopyContext`, `orphanPruner`) with small methods (`walk`, `copyChild`). +4. **No Inline Large Templates or Migrations**: Keep HTML/CSS templates and multi-statement SQL schemas in package-level `const` declarations or dedicated per-version migration helpers (`migrateV1`, `migrateV2`, etc.). + +--- + +## 2. Zero Duplication & Constant Hygiene + +1. **No Copy-Paste Blocks (`dupl` threshold: 80 tokens)**: + - In `internal/repository`, use shared query/mutation helpers (`getUserWhere`, `getTokenWhere`, `deleteTokensWhere`, `deleteSessionsWhere`) for single-row `goqu` lookups and conditional deletes. + - In `internal/api`, use shared helpers in `internal/api/helpers.go` (`writeJSON`, `writeJSONError`, `writeStatusOK`, `resolveBaseURL`). + - In `internal/middleware`, share session token validation (`resolveSessionUser`) across cookie and Bearer authentication flows. +2. **Named Constants for Repeated Literals (`goconst`: max 3 occurrences of strings $\ge 4$ chars)**: + - Use package-level column constants (`colUserID`, `colPath`, `colUpdatedAt`, `colToken` in `internal/repository/repository.go`) for repeated SQL column names. + - Define protocol and header constants (`schemeHTTP`, `schemeHTTPS`) rather than repeating magic strings. + +--- + +## 3. Go 1.26 Idioms & Clean Code Rules + +1. **Error Construction & Inspection (`errorlint`, `revive`, `gocritic`)**: + - Use `errors.New("static message")` when no formatting verbs are needed (never `fmt.Errorf("static message")`). + - Always wrap errors with `%w` (`fmt.Errorf("context: %w", err)`). + - Always inspect errors with `errors.Is(err, target)` or `errors.As(err, &target)` (never `err == sql.ErrNoRows` or direct type assertions). + - Keep error strings lowercase without trailing punctuation. +2. **String Formatting (`perfsprint`, `gocritic`)**: + - Use `%q` in `fmt.Sprintf` instead of manual escaped quotes `\"%s\"`. +3. **Octal File Permissions (`gocritic`)**: + - Always use Go 1.26 octal literal syntax `0o755`, `0o644`, `0o600` (never legacy `0755`). +4. **Unused Parameters, Receivers & Shadowing (`unparam`, `revive`, ` gocritic`)**: + - Remove unused parameters from internal functions. If a signature is fixed by an interface or `http.HandlerFunc`, rename the unused parameter to `_`. + - If a method does not access struct fields, omit the receiver variable name (`func (*Handler) helper(...)`). + - Never shadow imported package names (`storage`, `path`, `url`, `context`, `db`) with local variable names. +5. **Resource Cleanup & `defer` Safety**: + - Never call `os.Exit` inside a function that registers `defer` cleanup statements (such as `defer sqlDB.Close()`). Return an `error` to `main()` and let `main()` exit after all `defer` statements execute. + +--- + +## 4. Mandatory Verification Before Completing Any Task + +Whenever you add or modify Go code, you MUST run: +```bash +make lint && make test +``` +Or for full-stack / pre-commit verification: +```bash +make ci +``` +If any maintainability linter (`gocognit`, `gocyclo`, `funlen`, `nestif`, `dupl`, `goconst`, `gocritic`, `revive`, `errorlint`, `unparam`, `perfsprint`) reports an issue, refactor the code immediately—never add `//nolint` directives unless there is an unavoidable external interface constraint. diff --git a/.agents/skills/go-maintainability-review/SKILL.md b/.agents/skills/go-maintainability-review/SKILL.md new file mode 100644 index 0000000..9b2a72d --- /dev/null +++ b/.agents/skills/go-maintainability-review/SKILL.md @@ -0,0 +1,82 @@ +--- +name: go-maintainability-review +description: >- + Audits and improves Go code maintainability in SelfCloud by detecting copy-paste + duplication, high cyclomatic/cognitive complexity, deeply nested control flow, + and Go clean code anti-patterns. Use this skill whenever the user asks to check, + review, audit, or refactor Go code for maintainability, clean code, complexity + reduction, or deduplication. +--- + +# Go Maintainability Review & Refactoring Skill + +This skill provides a fast, deterministic workflow optimized for **Gemini 3.8 Flash** (and other models) to find and fix Go maintainability issues in SelfCloud without introducing regressions or over-abstraction. + +--- + +## Mode Selection + +Determine the operating mode from the user's prompt before starting: + +1. **Report Mode (Default)**: Used when the user asks to *check*, *find*, *review*, or *audit* Go code for maintainability improvements. Do **not** modify source files; produce a prioritized maintainability report with concrete refactoring sketches. +2. **Refactor Mode**: Used when the user explicitly asks to *fix*, *refactor*, or *apply* maintainability improvements (either across a package or for specific findings). Apply focused refactorings and verify with `make lint` and `make test`. + +--- + +## Step-by-Step Workflow + +### Phase 1: Run the Static Maintainability Scan + +Always begin by running the helper script to gather complexity, duplication, and clean-code diagnostics in a single pass: + +```bash +./.agents/skills/go-maintainability-review/scripts/scan_maintainability.sh [optional_package_path...] +``` + +- By default, the script scans `./internal/...` and `./cmd/...` (excluding `_test.go` to keep signal high) using [golangci-maintainability.yml](./resources/golangci-maintainability.yml). +- Pass `--include-tests` if the user also wants test files analyzed. +- Do **not** manually grep or read every Go file in the repository; rely on the scan output to pinpoint exact file and line hotspots. + +### Phase 2: Targeted Code Inspection & Checklist Review + +1. Read [go-clean-code-checklist.md](./references/go-clean-code-checklist.md) to load the refactoring rules, Go 1.26 idioms, and SelfCloud invariants. +2. Select the highest-signal hotspots from the scan output across the three core categories: + - **Copy-Paste / Duplication (`dupl`, `goconst`)**: Repeated repository query blocks, duplicated handler utilities (e.g., identical base URL resolution or error responses across handlers), or repeated validation blocks. + - **Cyclomatic & Cognitive Complexity (`gocyclo > 15`, `gocognit > 20`, `nestif >= 4`, `funlen`)**: Monolithic CLI dispatchers (`cmdUser`, `cmdServe`), deep `if/else` trees in HTTP/WebDAV handlers or auth middleware (`DualAuthMiddleware`, `GoogleCallbackHandler`, `UploadHandler`, `handleCopyDir`, `ReindexUser`). + - **Go Clean Code Practices (`gocritic`, `revive`, `errorlint`, `unparam`, `perfsprint`)**: `os.Exit` after `defer` (`exitAfterDefer`), unused internal helper parameters, package import shadowing (`storage`), `if-else` chains replaceable by `switch`, and `fmt.Errorf` without format verbs. +3. Use `read_file` with targeted `StartLine` and `EndLine` ranges around the flagged functions to inspect their actual control flow and domain context. + +### Phase 3: Filter Out False Positives (Anti-Noise Gate) + +Before recommending or applying a change, filter out low-value churn: +- **Do not** merge functions that merely look similar if their domain or SQL semantics will diverge. +- **Do not** replace readable `goqu.Ex{"user_id": userID}` column names with global string constants just to silence `goconst`. (However, **do** investigate when `goconst` flags repeated URLs/schemes like `https` or `localhost:8080` across multiple handlers, which indicates a copy-pasted helper function). +- **Do not** alter standard `http.HandlerFunc` signatures (`w http.ResponseWriter, r *http.Request`) when `r` is unused; focus `unparam` fixes on internal helper functions. +- **Never** break SelfCloud invariants defined in `AGENTS.md` (audit logging order via `audit.Record(ctx, ...)`, `goqu` node update fields, symlink & path length checks). + +### Phase 4A: Output Format (Report Mode) + +Group findings into three clear sections prioritized by maintainability impact (**High → Medium → Low**). Use clickable `file://` links for all files and symbols: + +1. **Copy-Paste & Code Duplication Opportunities** + - Location(s) with line ranges (`file:///.../path.go#L10-L40`). + - Why it is duplicated and the exact helper signature to extract. +2. **Cyclomatic & Cognitive Complexity Hotspots** + - Function link + current metrics (e.g., `gocyclo: 68`, `gocognit: 125`, `lines: 146`). + - Root cause of complexity (e.g., inline subcommand switch, deeply nested `if/else`, mixed parsing and storage logic). + - Concrete decomposition plan (which helper functions or guard clauses to extract). +3. **Go Clean Code & Idiom Improvements** + - Grouped summary of actionable clean-code fixes (`exitAfterDefer`, unused helper parameters, import shadowing, early returns, modern Go idioms). + +### Phase 4B: Execution & Verification (Refactor Mode) + +When applying refactorings: +1. Refactor **one logical unit at a time** (e.g., deduplicate one repository or decompose one complex handler/CLI function). +2. Re-run the maintainability scan on the modified package to confirm complexity/duplication dropped: + ```bash + ./.agents/skills/go-maintainability-review/scripts/scan_maintainability.sh ./internal//... + ``` +3. Run the project's standard linter and test suite to verify zero regressions: + ```bash + make lint && make test + ``` diff --git a/.agents/skills/go-maintainability-review/references/go-clean-code-checklist.md b/.agents/skills/go-maintainability-review/references/go-clean-code-checklist.md new file mode 100644 index 0000000..d2aa5dd --- /dev/null +++ b/.agents/skills/go-maintainability-review/references/go-clean-code-checklist.md @@ -0,0 +1,77 @@ +# Go Maintainability & Clean Code Checklist (SelfCloud) + +Use this reference during **Phase 2 (Semantic Inspection)** and **Phase 3 (Filtering & Refactoring)** of the `go-maintainability-review` skill. + +--- + +## 1. Reducing Copy-Paste & Duplication (DRY vs. Premature Abstraction) + +### High-Value Deduplication Targets +1. **Repository Query Helpers (`internal/repository/`)**: + - **Pattern**: Multiple repository methods (`GetByUsername` vs `GetByID`, `GetTokenByAccessToken` vs `GetTokenByRefreshToken`, `DeleteByUserID` across repos) that build the exact same `Select(...)`, execute `QueryRowContext`, `Scan(...)` the same struct fields, and check `errors.Is(err, sql.ErrNoRows)`. + - **Fix**: Extract an unexported receiver helper on the repository struct, e.g.: + ```go + func (r *userRepo) getUserWhere(ctx context.Context, where goqu.Ex) (*domain.User, error) + ``` +2. **Duplicated Handler Utilities (`internal/api/`, `internal/webdav/`)**: + - **Pattern**: Repeated request-inspection helpers copy-pasted across handlers (e.g., base URL resolution checking `X-Forwarded-Proto` / `r.TLS` / `localhost:8080` in `graph_handler.go`, `oauth_handler.go`, `shares_handler.go`; or identical JSON/XML error response + audit logging blocks). + - **Fix**: Consolidate shared handler helpers into a package-level helper file (e.g., `internal/api/helpers.go` or `internal/httputil/`) when the logic is identical in purpose and behavior. +3. **Repeated Precondition / Validation Blocks**: + - **Pattern**: Copy-pasted path normalization, user extraction from context, or node existence checks within the same handler file. + - **Fix**: Extract small unexported helper methods on the handler struct. + +### When NOT to Deduplicate (False-Positive Guardrails) +- **Divergent Domain Semantics**: Do not merge two functions just because they look structurally similar if they represent independent domain concepts that will evolve separately (*"A little copying is better than a little dependency"*). +- **Trivial SQL Literals**: Do not replace clear `goqu` column literals (`goqu.Ex{"user_id": id, "path": p}`) with global string constants (`colUserID`, `colPath`) unless a complex expression is repeated verbatim. + +--- + +## 2. Reducing Cyclomatic & Cognitive Complexity + +Target functions where `gocyclo > 15`, `gocognit > 20`, `nestif >= 4`, or `funlen > 80 lines / 50 statements`. + +### Refactoring Techniques +1. **Flatten Arrow Code with Guard Clauses (Early Returns)**: + - Invert `if condition { ... 40 lines ... }` to `if !condition { return ... }` so the happy path stays at indentation level 1. + - Replace `if err != nil { ... } else { ... }` with an early return inside the `if err != nil` block (`revive: early-return`, `indent-error-flow`). +2. **Decompose Monolithic HTTP & WebDAV Handlers**: + - Split 100+ line handlers (like `UploadHandler`, `GoogleCallbackHandler`, `handleAuthorizePost`, `handlePut`, `handleCopyDir`) into cohesive phases: + 1. Parse & validate request input. + 2. Execute domain/storage/repository operations via focused helper methods. + 3. Record `[AUDIT]` event and write HTTP response. +3. **Decompose Large CLI Subcommand Dispatchers (`cmd/selfcloud/main.go`)**: + - When a CLI dispatcher like `cmdUser` or `cmdServe` exceeds 50+ cyclomatic complexity due to handling many subcommands (`create`, `list`, `delete`, `update`, `set-password`, `set-email`) inline, extract each subcommand into its own dedicated function (`cmdUserCreate`, `cmdUserList`, etc.) and keep `cmdUser` as a thin router. +4. **Decompose Auth & Middleware Pipelines (`internal/middleware/`)**: + - In complex middleware like `DualAuthMiddleware`, extract authentication strategies into dedicated helpers (e.g., `authenticateBearer(...)`, `authenticateBasic(...)`) rather than deeply nesting both flows in a single closure. +5. **Replace Long `if-else` Chains with `switch`**: + - Use tagged or tagless `switch` statements when branching on the same variable or status category (`gocritic: ifElseChain`). + +--- + +## 3. Go 1.26 & Clean Code Practices + +1. **Error Handling**: + - Always use `errors.New("...")` instead of `fmt.Errorf("...")` when there are no format verbs (`perfsprint: error-format`). + - Always wrap underlying errors with `%w` (`fmt.Errorf("failed to ...: %w", err)`) and inspect errors using `errors.Is` / `errors.As` (`errorlint`). + - Never call `os.Exit` / `log.Fatal` inside a function after registering `defer sqlDB.Close()` (`gocritic: exitAfterDefer`); instead, return an `error` to `main()` or a wrapper function so deferred cleanup runs before exiting. +2. **Unused Parameters & Receivers (`unparam`, `revive`)**: + - Remove genuinely unused parameters from internal helper methods (e.g., unused `basePath` or `sourceNode` in internal `handleMove`/`handleCopy`/`handleCopyDir` helpers, or constant-only parameters like `generateSecureToken(32)`). + - **Exception**: Keep `w http.ResponseWriter, r *http.Request` on standard `http.HandlerFunc` methods even if `r` is unused; you may rename `r` to `_` only if it improves clarity without hurting readability. +3. **Avoid Package Import Shadowing (`gocritic: importShadow`)**: + - Rename local variables or parameters named `storage` (e.g., to `store` or `blobStore`) when package `selfcloud/internal/storage` is imported in the same file. +4. **Modern Go Standard Library Idioms**: + - Prefer `strconv.Itoa(n)` over `fmt.Sprintf("%d", n)`. + - Prefer `%q` in `fmt.Sprintf` over `"\"%s\""` (`gocritic: sprintfQuotedString`). + - Prefer `0o755` / `0o644` octal literals over legacy `0755` (`gocritic: octalLiteral`). + - Prefer `http.NoBody` over `nil` in `http.NewRequestWithContext` when sending no body (`gocritic: httpNoBody`). + - Prefer `strings.Cut`, `strings.CutPrefix`, `strings.CutSuffix`, `slices.Contains`, `min()`, and `max()` over manual index slicing or verbose loops. + +--- + +## 4. SelfCloud Non-Negotiable Invariants (`AGENTS.md`) + +When proposing or applying any maintainability refactoring, **never** violate these project rules: +1. **Audit & Logging Order**: Always preserve `audit.Record(ctx, ...)` calls before HTTP responses/access logs, and keep `slog` context correlation (`request_id`, `int_request_id`). +2. **Database Node Invariants**: Any refactoring touching node updates or moves must still update all four fields: `name`, `path`, `parent_path`, and `updated_at`, using `goqu`. +3. **Security Boundaries**: Never weaken or bypass `checkSymlinkEscape`, `pathutil.ValidatePathLength` (max 700 chars), CSRF origin validation, or `TRUST_PROXY` IP resolution. +4. **WebDAV & OCS Compliance**: Preserve exact HTTP status codes, headers (`ETag`, `DAV`, `Retry-After`, `WWW-Authenticate`), and XML response structures required by ownCloud clients. diff --git a/.agents/skills/go-maintainability-review/resources/golangci-maintainability.yml b/.agents/skills/go-maintainability-review/resources/golangci-maintainability.yml new file mode 100644 index 0000000..6375c39 --- /dev/null +++ b/.agents/skills/go-maintainability-review/resources/golangci-maintainability.yml @@ -0,0 +1,86 @@ +version: "2" + +run: + timeout: 5m + tests: false + +linters: + default: none + enable: + - dupl + - errorlint + - funlen + - gocognit + - goconst + - gocritic + - gocyclo + - ineffassign + - nestif + - perfsprint + - revive + - unparam + - unused + + settings: + gocyclo: + min-complexity: 15 + gocognit: + min-complexity: 20 + dupl: + threshold: 80 + nestif: + min-complexity: 4 + funlen: + lines: 80 + statements: 50 + ignore-comments: true + goconst: + min-len: 4 + min-occurrences: 4 + errorlint: + errorf: true + asserts: true + comparison: true + perfsprint: + strconcat: false + gocritic: + enabled-tags: + - diagnostic + - style + - performance + disabled-checks: + - hugeParam + - unnamedResult + - whyNoLint + revive: + rules: + - name: bool-literal-in-expr + - name: constant-logical-expr + - name: context-as-argument + - name: context-keys-type + - name: early-return + - name: empty-block + - name: empty-lines + - name: error-naming + - name: error-return + - name: error-strings + - name: errorf + - name: identical-branches + - name: if-return + - name: increment-decrement + - name: indent-error-flow + - name: range + - name: receiver-naming + - name: superfluous-else + - name: time-equal + - name: unexported-return + - name: unnecessary-stmt + - name: unreachable-code + - name: unused-parameter + - name: unused-receiver + - name: useless-break + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + uniq-by-line: false diff --git a/.agents/skills/go-maintainability-review/scripts/scan_maintainability.sh b/.agents/skills/go-maintainability-review/scripts/scan_maintainability.sh new file mode 100755 index 0000000..977fadb --- /dev/null +++ b/.agents/skills/go-maintainability-review/scripts/scan_maintainability.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Scans Go packages for maintainability issues: +# - Code duplication & repeated magic strings (dupl, goconst) +# - Cyclomatic & cognitive complexity (gocyclo, gocognit, nestif, funlen) +# - Go clean code idioms & error handling (gocritic, revive, errorlint, unparam, perfsprint) +# +# Usage: +# ./.agents/skills/go-maintainability-review/scripts/scan_maintainability.sh [target_packages...] [--include-tests] + +set -uo pipefail + +export PATH="${HOME}/.goenv/shims:/opt/homebrew/bin:/usr/local/bin:${PATH}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SKILL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +REPO_ROOT="$(cd "${SKILL_DIR}/../../.." && pwd)" +CONFIG_FILE="${SKILL_DIR}/resources/golangci-maintainability.yml" + +INCLUDE_TESTS="false" +TARGETS=() + +for arg in "$@"; do + if [[ "$arg" == "--include-tests" ]]; then + INCLUDE_TESTS="true" + else + TARGETS+=("$arg") + fi +done + +if [[ ${#TARGETS[@]} -eq 0 ]]; then + TARGETS=("./internal/..." "./cmd/...") +fi + +cd "${REPO_ROOT}" || exit 1 + +if ! command -v golangci-lint >/dev/null 2>&1; then + echo "ERROR: golangci-lint is not installed or not in PATH." >&2 + exit 1 +fi + +echo "================================================================================" +echo "SelfCloud Go Maintainability Scan" +echo "Targets : ${TARGETS[*]}" +echo "Include Tests : ${INCLUDE_TESTS}" +echo "Config : ${CONFIG_FILE#${REPO_ROOT}/}" +echo "================================================================================" +echo "" + +# Run golangci-lint with maintainability config and normalize relative paths +RAW_OUTPUT=$(golangci-lint run \ + -c "${CONFIG_FILE}" \ + --tests="${INCLUDE_TESTS}" \ + "${TARGETS[@]}" 2>&1 || true) + +OUTPUT=$(echo "${RAW_OUTPUT}" | sed -E 's#^(\.\./)+##') + +if [[ -z "${OUTPUT}" ]]; then + echo "No maintainability issues detected by static analysis." + exit 0 +fi + +echo "### 1. Cyclomatic & Cognitive Complexity Hotspots (gocyclo, gocognit, funlen, nestif)" +echo "--------------------------------------------------------------------------------" +echo "${OUTPUT}" | grep -E '\((gocyclo|gocognit|funlen|nestif)\)$' | sort -t: -k1,1 -k2,2n || echo "None" +echo "" + +echo "### 2. Copy-Paste & Repeated Constants (dupl, goconst)" +echo "--------------------------------------------------------------------------------" +echo "${OUTPUT}" | grep -E '\((dupl|goconst)\)$' || echo "None" +echo "" + +echo "### 3. Go Clean Code, Idioms & Dead Parameters (gocritic, revive, errorlint, unparam, perfsprint, ineffassign, unused)" +echo "--------------------------------------------------------------------------------" +echo "${OUTPUT}" | grep -E '\((gocritic|revive|errorlint|unparam|perfsprint|ineffassign|unused)\)$' | sort -t: -k1,1 -k2,2n || echo "None" +echo "" + +echo "### 4. Issue Counts by Linter" +echo "--------------------------------------------------------------------------------" +echo "${OUTPUT}" | grep -E '^([0-9]+ issues:|\* [a-z0-9]+: [0-9]+)' || echo "None" diff --git a/.golangci.yml b/.golangci.yml index ca8ccc0..b54d3c6 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -5,12 +5,83 @@ run: linters: enable: + - dupl + - errcheck + - errorlint + - funlen + - gocognit + - goconst + - gocritic + - gocyclo - govet - ineffassign + - misspell + - nestif + - perfsprint + - revive - staticcheck + - unparam - unused - - misspell - - errcheck + + settings: + gocyclo: + min-complexity: 15 + gocognit: + min-complexity: 20 + dupl: + threshold: 80 + nestif: + min-complexity: 4 + funlen: + lines: 80 + statements: 50 + ignore-comments: true + goconst: + min-len: 4 + min-occurrences: 4 + errorlint: + errorf: true + asserts: true + comparison: true + perfsprint: + strconcat: false + gocritic: + enabled-tags: + - diagnostic + - style + - performance + disabled-checks: + - hugeParam + - unnamedResult + - whyNoLint + revive: + rules: + - name: bool-literal-in-expr + - name: constant-logical-expr + - name: context-as-argument + - name: context-keys-type + - name: early-return + - name: empty-block + - name: empty-lines + - name: error-naming + - name: error-return + - name: error-strings + - name: errorf + - name: identical-branches + - name: if-return + - name: increment-decrement + - name: indent-error-flow + - name: range + - name: receiver-naming + - name: superfluous-else + - name: time-equal + - name: unexported-return + - name: unnecessary-stmt + - name: unreachable-code + - name: unused-parameter + - name: unused-receiver + - name: useless-break + exclusions: presets: - std-error-handling @@ -18,7 +89,17 @@ linters: rules: - path: '_test\.go' linters: + - dupl - errcheck + - funlen + - gocognit + - goconst + - gocritic + - gocyclo + - nestif + - perfsprint + - revive + - unparam - source: "defer .*" linters: - errcheck diff --git a/AGENTS.md b/AGENTS.md index 32409ec..5a1a79d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,8 @@ It allows ownCloud desktop sync clients, mobile apps, and modern web browsers to ## 3. Directory Layout ``` -├── .golangci.yml # Linter configuration +├── .agents/ # AI Agent rules (.agents/rules/) & skills (.agents/skills/) +├── .golangci.yml # Strict Go linter & maintainability configuration ├── AGENTS.md # AI Agent Guidelines (this file) ├── Dockerfile # Multi-stage Docker build ├── Makefile # Build, test, lint, and UI targets @@ -59,7 +60,7 @@ It allows ownCloud desktop sync clients, mobile apps, and modern web browsers to ## 5. Development & Testing Commands - **CI & Pre-Commit Check**: Run `make ci` (runs Go lint, Go tests, UI lint, UI build, and backend build). -- **Linting**: Run `make lint` (Go linter) and `make ui-lint` / `make ui-build` (React typecheck & build). +- **Linting**: Run `make lint` (strict Go linter including complexity, duplication, and clean-code checks), `make lint-maintainability` (categorized maintainability report), and `make ui-lint` / `make ui-build` (React typecheck & build). - **Tests**: Run `make test` (runs unit & functional tests against MariaDB configured via `DB_DSN`). - **Build**: Run `make build` (compiles `bin/selfcloud`) and `make ui-build` (builds `web/dist`). - **Docker Targets**: @@ -77,9 +78,15 @@ It allows ownCloud desktop sync clients, mobile apps, and modern web browsers to - `./bin/selfcloud user ` (user management) - `./bin/selfcloud reindex [--user ] [--remove-orphans]` (reconciles disk & DB) -## 6. Coding Guidelines & Practices +## 6. Coding Guidelines & Engineering Standards 1. **Never guess API signatures**: Check standard library or existing domain models before modifying signatures. -2. **Logging & Audit**: Use `slog` for structured logging. Always include request context / request ID when handling HTTP calls and record audit events via `audit.Record(ctx, ...)` before emitting access logs. +2. **Logging & Audit**: Use `slog` for structured logging. Always include request context / request ID when handling HTTP calls and record audit events via `audit.Record(ctx, ...)` or `audit.RecordFailure(ctx, ...)` before emitting access logs / HTTP headers. 3. **Database**: Use `goqu` for building SQL queries to keep DB operations clean and vendor-agnostic. Always update `name`, `path`, `parent_path`, and `updated_at` on node updates/moves. Apply schema changes via versioned migrations in `internal/db/db.go`. -4. **Security**: Validate paths in storage driver to prevent directory traversal (`..`) and symlink escapes (`checkSymlinkEscape`). Enforce path length boundaries (`pathutil.ValidatePathLength`) to respect database index limits. Verify CSRF origins on mutating cookie endpoints and guard against reverse proxy header spoofing via `TRUST_PROXY`. +4. **Security**: Validate paths in storage driver to prevent directory traversal (`..`) and symlink escapes (`checkSymlinkEscape`). Enforce path length boundaries (`pathutil.ValidatePathLength`) to respect database index limits. Verify CSRF origins on mutating cookie endpoints, guard against reverse proxy header spoofing via `TRUST_PROXY`, and use `domain.CheckDummyPassword` on unknown-user login attempts. 5. **Frontend Reference**: See [`web/AGENTS.md`](web/AGENTS.md) when making changes inside the `web/` subproject. +6. **Go Maintainability & Clean Code Standards** (enforced automatically by `make lint` via `.golangci.yml`): + - **Complexity & Size Caps**: Cognitive complexity `gocognit` $\le 20$, cyclomatic complexity `gocyclo` $\le 15$, function length `funlen` $\le 80$ lines / $\le 50$ statements, nesting depth `nestif` $\le 4$. Flatten control flow with early-return guard clauses (`if err != nil { return err }`) and decompose handlers into small, single-responsibility helpers. + - **Zero Duplication & Magic Literals**: `dupl` threshold is 80 tokens; reuse shared repository helpers (`get*Where`, `delete*Where`), column constants (`colUserID`, `colPath`, `colUpdatedAt`, `colToken`), and HTTP helpers (`writeJSON`, `writeJSONError`, `writeStatusOK`, `resolveBaseURL`). Keep HTML/CSS and SQL templates in package-level `const` declarations or dedicated helpers. + - **Go 1.26 Idioms**: Use `0o755` octal literals, `errors.New` for static errors, `%w` + `errors.Is`/`errors.As` for error wrapping, `%q` in `fmt.Sprintf` instead of `\"%s\"`, omit unused receiver names (`func (*Handler) helper(...)`), and never call `os.Exit` inside functions with active `defer` statements. + +@[Go Engineering Standards](.agents/rules/go-engineering-standards.md) diff --git a/Makefile b/Makefile index 7b78920..c912f4d 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all lint test test-coverage build docker-build docker-build-backend docker-build-web docker-up docker-down docker-logs docker-clean up-db down-db clean ui-install ui-build ui-lint ui-dev ci check +.PHONY: all lint lint-maintainability test test-coverage build docker-build docker-build-backend docker-build-web docker-up docker-down docker-logs docker-clean up-db down-db clean ui-install ui-build ui-lint ui-dev ci check ENV_PATH := PATH="$(HOME)/.goenv/shims:/opt/homebrew/bin:/usr/local/bin:$(PATH)" GIT_COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "latest") @@ -17,6 +17,9 @@ ci: lint test ui-lint ui-build build lint: $(ENV_PATH) golangci-lint run ./... +lint-maintainability: + ./.agents/skills/go-maintainability-review/scripts/scan_maintainability.sh + test: $(ENV_PATH) go test -v ./... diff --git a/cmd/selfcloud/main.go b/cmd/selfcloud/main.go index 8ac47b9..8c20b89 100644 --- a/cmd/selfcloud/main.go +++ b/cmd/selfcloud/main.go @@ -42,22 +42,29 @@ func main() { if len(os.Args) >= 2 { cmd = os.Args[1] } + + var err error switch cmd { case "serve": - cmdServe() + err = cmdServe() case "migrate": - cmdMigrate() + err = cmdMigrate() case "user": - cmdUser(os.Args[2:]) + err = cmdUser(os.Args[2:]) case "reindex": - cmdReindex(os.Args[2:]) + err = cmdReindex(os.Args[2:]) case "help", "--help", "-h": printUsage() + return default: fmt.Fprintf(os.Stderr, "Unknown command: %s\n\n", cmd) printUsage() os.Exit(1) } + + if err != nil { + os.Exit(1) + } } func printUsage() { @@ -110,7 +117,7 @@ func connectDB(cfg *config.Config) (*sql.DB, error) { }) } -func cmdMigrate() { +func cmdMigrate() error { cfg := config.LoadConfig() logger := makeLogger(cfg) @@ -118,73 +125,60 @@ func cmdMigrate() { sqlDB, err := connectDB(cfg) if err != nil { logger.Error("Database connection failed", slog.Any("error", err)) - os.Exit(1) + return err } defer sqlDB.Close() if err := db.Migrate(sqlDB); err != nil { logger.Error("Migration failed", slog.Any("error", err)) - os.Exit(1) + return err } logger.Info("Database migration completed successfully") + return nil +} + +type reindexFlags struct { + targetUser string + dryRun bool + removeOrphans bool } -func cmdReindex(args []string) { - // Parse flags manually to avoid importing flag package complexity. - var targetUser string - dryRun := false - removeOrphans := false +func parseReindexFlags(args []string) reindexFlags { + var flags reindexFlags for i := 0; i < len(args); i++ { switch args[i] { case "--user": if i+1 < len(args) { i++ - targetUser = args[i] + flags.targetUser = args[i] } case "--dry-run": - dryRun = true + flags.dryRun = true case "--remove-orphans": - removeOrphans = true + flags.removeOrphans = true } } + return flags +} - cfg := config.LoadConfig() - logger := makeLogger(cfg) - - sqlDB, err := connectDB(cfg) - if err != nil { - logger.Error("Database connection failed", slog.Any("error", err)) - os.Exit(1) - } - defer sqlDB.Close() - - userRepo := repository.NewUserRepository(sqlDB) - nodeRepo := repository.NewNodeRepository(sqlDB) - ctx := context.Background() - - if dryRun { - logger.Info("Dry-run mode enabled: no changes will be written to the database") - } - - // Collect users to reindex. - var users []*domain.User +func resolveReindexUsers(ctx context.Context, userRepo repository.UserRepository, targetUser string, logger *slog.Logger) ([]*domain.User, error) { if targetUser != "" { u, err := userRepo.GetByUsername(ctx, targetUser) if err != nil || u == nil { logger.Error("User not found", slog.String("username", targetUser)) - os.Exit(1) - } - users = []*domain.User{u} - } else { - users, err = userRepo.ListAll(ctx) - if err != nil { - logger.Error("Failed to list users", slog.Any("error", err)) - os.Exit(1) + return nil, fmt.Errorf("user %q not found", targetUser) } + return []*domain.User{u}, nil } + users, err := userRepo.ListAll(ctx) + if err != nil { + logger.Error("Failed to list users", slog.Any("error", err)) + return nil, err + } + return users, nil +} - idx := indexer.New(nodeRepo, cfg.StorageDir, logger, dryRun) - +func reindexAllUsers(ctx context.Context, idx *indexer.Reindexer, users []*domain.User, removeOrphans, dryRun bool, logger *slog.Logger) error { totalCreated, totalUpdated, totalDeleted, totalErrors := 0, 0, 0, 0 for _, u := range users { logger.Info("Reindexing user", slog.String("username", u.Username)) @@ -216,8 +210,39 @@ func cmdReindex(args []string) { ) if totalErrors > 0 { - os.Exit(1) + return fmt.Errorf("reindex completed with %d errors", totalErrors) } + return nil +} + +func cmdReindex(args []string) error { + flags := parseReindexFlags(args) + + cfg := config.LoadConfig() + logger := makeLogger(cfg) + + sqlDB, err := connectDB(cfg) + if err != nil { + logger.Error("Database connection failed", slog.Any("error", err)) + return err + } + defer sqlDB.Close() + + userRepo := repository.NewUserRepository(sqlDB) + nodeRepo := repository.NewNodeRepository(sqlDB) + ctx := context.Background() + + if flags.dryRun { + logger.Info("Dry-run mode enabled: no changes will be written to the database") + } + + users, err := resolveReindexUsers(ctx, userRepo, flags.targetUser, logger) + if err != nil { + return err + } + + idx := indexer.New(nodeRepo, cfg.StorageDir, logger, flags.dryRun) + return reindexAllUsers(ctx, idx, users, flags.removeOrphans, flags.dryRun, logger) } func resolvePassword(explicitPassword, prompt string) (string, error) { @@ -243,10 +268,10 @@ func resolvePassword(explicitPassword, prompt string) (string, error) { return pass, nil } -func cmdUser(args []string) { +func cmdUser(args []string) error { if len(args) == 0 { fmt.Fprintln(os.Stderr, "Usage: selfcloud user [args...]") - os.Exit(1) + return errors.New("missing user subcommand") } cfg := config.LoadConfig() @@ -255,7 +280,7 @@ func cmdUser(args []string) { sqlDB, err := connectDB(cfg) if err != nil { logger.Error("Database connection failed", slog.Any("error", err)) - os.Exit(1) + return err } defer sqlDB.Close() @@ -265,263 +290,304 @@ func cmdUser(args []string) { switch args[0] { case "create": - updateIfExists := false - var positional []string - for _, arg := range args[1:] { - if arg == "--update-if-exists" || arg == "--upsert" { - updateIfExists = true - } else { - positional = append(positional, arg) - } - } + return cmdUserCreate(ctx, logger, transactor, args[1:]) + case "list": + return cmdUserList(ctx, logger, repos) + case "delete": + return cmdUserDelete(ctx, logger, transactor, cfg.StorageDir, args[1:]) + case "update": + return cmdUserUpdate(ctx, logger, repos, args[1:]) + case "set-password": + return cmdUserSetPassword(ctx, logger, repos, args[1:]) + case "set-email": + return cmdUserSetEmail(ctx, logger, repos, args[1:]) + default: + fmt.Fprintf(os.Stderr, "Unknown user subcommand: %s\n", args[0]) + return fmt.Errorf("unknown user subcommand: %s", args[0]) + } +} - if len(positional) < 1 { - fmt.Fprintln(os.Stderr, "Usage: selfcloud user create [password] [email] [--update-if-exists]") - os.Exit(1) - } - username := positional[0] - passArg := "" - email := "" - if len(positional) >= 2 { - passArg = positional[1] - } - if len(positional) >= 3 { - email = positional[2] - } +func upsertUser(ctx context.Context, tx *repository.Repositories, username, hash, password, email string, updateIfExists bool) (*domain.User, string, error) { + existing, err := tx.Users.GetByUsername(ctx, username) + if err != nil { + return nil, "", fmt.Errorf("failed to check existing user: %w", err) + } + if existing == nil { + user := &domain.User{Username: username, PasswordHash: hash, Email: email} + if err := tx.Users.Create(ctx, user); err != nil { + return nil, "", fmt.Errorf("failed to create user: %w", err) + } + + // Seed root directory node for new user. + rootNode := &domain.Node{ + UserID: user.ID, + Path: "/", + ParentPath: "", + Name: "", + IsDir: true, + ETag: fmt.Sprintf("root_%s", username), + FileID: fmt.Sprintf("%08doc_root", user.ID), + MTime: time.Now(), + ContentType: "httpd/unix-directory", + } + if err := tx.Nodes.Create(ctx, rootNode); err != nil { + return nil, "", fmt.Errorf("failed to initialize user root node: %w", err) + } + return user, "created", nil + } - password, err := resolvePassword(passArg, fmt.Sprintf("Enter password for user %q", username)) - if err != nil { - logger.Error("Password error", slog.Any("error", err)) - os.Exit(1) + if !updateIfExists { + return nil, "", fmt.Errorf("user %q already exists", username) + } + needsUpdate := false + if !existing.CheckPassword(password) { + existing.PasswordHash = hash + needsUpdate = true + } + if email != "" && existing.Email != email { + existing.Email = email + needsUpdate = true + } + if needsUpdate { + if err := tx.Users.Update(ctx, existing); err != nil { + return nil, "", fmt.Errorf("failed to update user: %w", err) } + return existing, "updated", nil + } + return existing, "unchanged", nil +} - hash, err := domain.HashPassword(password) - if err != nil { - logger.Error("Failed to hash password", slog.Any("error", err)) - os.Exit(1) +func cmdUserCreate(ctx context.Context, logger *slog.Logger, transactor repository.Transactor, args []string) error { + updateIfExists := false + var positional []string + for _, arg := range args { + if arg == "--update-if-exists" || arg == "--upsert" { + updateIfExists = true + } else { + positional = append(positional, arg) } + } - var createdUser *domain.User - status := "created" // "created", "updated", "unchanged" - - err = transactor.WithTx(ctx, func(tx *repository.Repositories) error { - existing, err := tx.Users.GetByUsername(ctx, username) - if err != nil { - return fmt.Errorf("failed to check existing user: %w", err) - } - if existing != nil { - if !updateIfExists { - return fmt.Errorf("user %q already exists", username) - } - needsUpdate := false - if !existing.CheckPassword(password) { - existing.PasswordHash = hash - needsUpdate = true - } - if email != "" && existing.Email != email { - existing.Email = email - needsUpdate = true - } - if needsUpdate { - if err := tx.Users.Update(ctx, existing); err != nil { - return fmt.Errorf("failed to update user: %w", err) - } - status = "updated" - } else { - status = "unchanged" - } - createdUser = existing - return nil - } + if len(positional) < 1 { + fmt.Fprintln(os.Stderr, "Usage: selfcloud user create [password] [email] [--update-if-exists]") + return errors.New("missing username") + } + username := positional[0] + passArg := "" + email := "" + if len(positional) >= 2 { + passArg = positional[1] + } + if len(positional) >= 3 { + email = positional[2] + } - user := &domain.User{Username: username, PasswordHash: hash, Email: email} - if err := tx.Users.Create(ctx, user); err != nil { - return fmt.Errorf("failed to create user: %w", err) - } - createdUser = user - - // Seed root directory node for new user. - rootNode := &domain.Node{ - UserID: user.ID, - Path: "/", - ParentPath: "", - Name: "", - IsDir: true, - ETag: fmt.Sprintf("root_%s", username), - FileID: fmt.Sprintf("%08doc_root", user.ID), - MTime: time.Now(), - ContentType: "httpd/unix-directory", - } - if err := tx.Nodes.Create(ctx, rootNode); err != nil { - return fmt.Errorf("failed to initialize user root node: %w", err) - } - return nil - }) + password, err := resolvePassword(passArg, fmt.Sprintf("Enter password for user %q", username)) + if err != nil { + logger.Error("Password error", slog.Any("error", err)) + return err + } - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - logger.Error("Failed to create user", slog.Any("error", err)) - os.Exit(1) - } + hash, err := domain.HashPassword(password) + if err != nil { + logger.Error("Failed to hash password", slog.Any("error", err)) + return err + } - switch status { - case "created": - fmt.Printf("User %q created successfully (id=%d)\n", username, createdUser.ID) - case "updated": - fmt.Printf("User %q already exists, updated successfully (id=%d)\n", username, createdUser.ID) - case "unchanged": - fmt.Printf("User %q already exists and is up to date (id=%d)\n", username, createdUser.ID) - } + var createdUser *domain.User + var status string - case "list": - users, err := repos.Users.ListAll(ctx) + err = transactor.WithTx(ctx, func(tx *repository.Repositories) error { + u, st, err := upsertUser(ctx, tx, username, hash, password, email, updateIfExists) if err != nil { - logger.Error("Failed to list users", slog.Any("error", err)) - os.Exit(1) - } - fmt.Printf("%-5s %-30s %-30s %s\n", "ID", "Username", "Email", "Created") - fmt.Printf("%-5s %-30s %-30s %s\n", "---", "--------", "-----", "-------") - for _, u := range users { - fmt.Printf("%-5d %-30s %-30s %s\n", u.ID, u.Username, u.Email, u.CreatedAt.Format(time.RFC3339)) + return err } + createdUser = u + status = st + return nil + }) - case "delete": - if len(args) < 2 { - fmt.Fprintln(os.Stderr, "Usage: selfcloud user delete ") - os.Exit(1) - } - username := args[1] - err = transactor.WithTx(ctx, func(tx *repository.Repositories) error { - u, err := tx.Users.GetByUsername(ctx, username) - if err != nil || u == nil { - return fmt.Errorf("user %q not found", username) - } - if err := tx.Nodes.DeletePrefix(ctx, u.ID, "/"); err != nil { - return fmt.Errorf("failed to clean up user files: %w", err) - } - return tx.Users.Delete(ctx, username) - }) - if err != nil { - logger.Error("Failed to delete user", slog.Any("error", err)) - os.Exit(1) - } - // Clean up user's storage directory if it exists - userStorageDir := filepath.Join(cfg.StorageDir, username) - if err := os.RemoveAll(userStorageDir); err != nil { - logger.Warn("Failed to clean up user storage directory", slog.String("dir", userStorageDir), slog.Any("error", err)) - } - fmt.Printf("User %q deleted successfully\n", username) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + logger.Error("Failed to create user", slog.Any("error", err)) + return err + } - case "update": - if len(args) < 2 { - fmt.Fprintln(os.Stderr, "Usage: selfcloud user update [--password ] [--email ]") - os.Exit(1) - } - username := args[1] - user, err := repos.Users.GetByUsername(ctx, username) - if err != nil || user == nil { - fmt.Fprintf(os.Stderr, "User %q not found\n", username) - os.Exit(1) - } + switch status { + case "created": + fmt.Printf("User %q created successfully (id=%d)\n", username, createdUser.ID) + case "updated": + fmt.Printf("User %q already exists, updated successfully (id=%d)\n", username, createdUser.ID) + case "unchanged": + fmt.Printf("User %q already exists and is up to date (id=%d)\n", username, createdUser.ID) + } + return nil +} - newPassword := "" - newEmail := "" +func cmdUserList(ctx context.Context, logger *slog.Logger, repos *repository.Repositories) error { + users, err := repos.Users.ListAll(ctx) + if err != nil { + logger.Error("Failed to list users", slog.Any("error", err)) + return err + } + fmt.Printf("%-5s %-30s %-30s %s\n", "ID", "Username", "Email", "Created") + fmt.Printf("%-5s %-30s %-30s %s\n", "---", "--------", "-----", "-------") + for _, u := range users { + fmt.Printf("%-5d %-30s %-30s %s\n", u.ID, u.Username, u.Email, u.CreatedAt.Format(time.RFC3339)) + } + return nil +} - for i := 2; i < len(args); i++ { - if args[i] == "--password" && i+1 < len(args) { - newPassword = args[i+1] - i++ - } else if args[i] == "--email" && i+1 < len(args) { - newEmail = args[i+1] - i++ - } else if i == 2 && !strings.HasPrefix(args[i], "--") { - newPassword = args[i] - } else if i == 3 && !strings.HasPrefix(args[i], "--") { - newEmail = args[i] - } +func cmdUserDelete(ctx context.Context, logger *slog.Logger, transactor repository.Transactor, storageDir string, args []string) error { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "Usage: selfcloud user delete ") + return errors.New("missing username") + } + username := args[0] + err := transactor.WithTx(ctx, func(tx *repository.Repositories) error { + u, err := tx.Users.GetByUsername(ctx, username) + if err != nil || u == nil { + return fmt.Errorf("user %q not found", username) } - - if newPassword == "" && newEmail == "" { - fmt.Fprintln(os.Stderr, "Error: Specify at least --password or --email to update") - os.Exit(1) + if err := tx.Nodes.DeletePrefix(ctx, u.ID, "/"); err != nil { + return fmt.Errorf("failed to clean up user files: %w", err) } + return tx.Users.Delete(ctx, username) + }) + if err != nil { + logger.Error("Failed to delete user", slog.Any("error", err)) + return err + } + // Clean up user's storage directory if it exists + userStorageDir := filepath.Join(storageDir, username) + if err := os.RemoveAll(userStorageDir); err != nil { + logger.Warn("Failed to clean up user storage directory", slog.String("dir", userStorageDir), slog.Any("error", err)) + } + fmt.Printf("User %q deleted successfully\n", username) + return nil +} - if newPassword != "" { - hash, err := domain.HashPassword(newPassword) - if err != nil { - logger.Error("Failed to hash password", slog.Any("error", err)) - os.Exit(1) - } - user.PasswordHash = hash - } - if newEmail != "" { - user.Email = newEmail +func parseUserUpdateFlags(args []string) (password, email string) { + for i := 0; i < len(args); i++ { + switch { + case args[i] == "--password" && i+1 < len(args): + password = args[i+1] + i++ + case args[i] == "--email" && i+1 < len(args): + email = args[i+1] + i++ + case i == 0 && !strings.HasPrefix(args[i], "--"): + password = args[i] + case i == 1 && !strings.HasPrefix(args[i], "--"): + email = args[i] } + } + return password, email +} - if err := repos.Users.Update(ctx, user); err != nil { - logger.Error("Failed to update user", slog.Any("error", err)) - os.Exit(1) - } +func cmdUserUpdate(ctx context.Context, logger *slog.Logger, repos *repository.Repositories, args []string) error { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "Usage: selfcloud user update [--password ] [--email ]") + return errors.New("missing username") + } + username := args[0] + user, err := repos.Users.GetByUsername(ctx, username) + if err != nil || user == nil { + fmt.Fprintf(os.Stderr, "User %q not found\n", username) + return fmt.Errorf("user %q not found", username) + } - fmt.Printf("User %q updated successfully\n", username) + newPassword, newEmail := parseUserUpdateFlags(args[1:]) + if newPassword == "" && newEmail == "" { + fmt.Fprintln(os.Stderr, "Error: Specify at least --password or --email to update") + return errors.New("specify at least --password or --email") + } - case "set-password": - if len(args) < 2 { - fmt.Fprintln(os.Stderr, "Usage: selfcloud user set-password [new_password]") - os.Exit(1) - } - username := args[1] - passArg := "" - if len(args) >= 3 { - passArg = args[2] - } - user, err := repos.Users.GetByUsername(ctx, username) - if err != nil || user == nil { - fmt.Fprintf(os.Stderr, "User %q not found\n", username) - os.Exit(1) - } - newPassword, err := resolvePassword(passArg, fmt.Sprintf("Enter new password for user %q", username)) - if err != nil { - logger.Error("Password error", slog.Any("error", err)) - os.Exit(1) - } + if newPassword != "" { hash, err := domain.HashPassword(newPassword) if err != nil { logger.Error("Failed to hash password", slog.Any("error", err)) - os.Exit(1) + return err } user.PasswordHash = hash - if err := repos.Users.Update(ctx, user); err != nil { - logger.Error("Failed to update user password", slog.Any("error", err)) - os.Exit(1) - } - fmt.Printf("Password for user %q updated successfully\n", username) - - case "set-email": - if len(args) < 3 { - fmt.Fprintln(os.Stderr, "Usage: selfcloud user set-email ") - os.Exit(1) - } - username, newEmail := args[1], args[2] - user, err := repos.Users.GetByUsername(ctx, username) - if err != nil || user == nil { - fmt.Fprintf(os.Stderr, "User %q not found\n", username) - os.Exit(1) - } + } + if newEmail != "" { user.Email = newEmail - if err := repos.Users.Update(ctx, user); err != nil { - logger.Error("Failed to update user email", slog.Any("error", err)) - os.Exit(1) - } - fmt.Printf("Email for user %q updated successfully to %q\n", username, newEmail) + } - default: - fmt.Fprintf(os.Stderr, "Unknown user subcommand: %s\n", args[0]) - os.Exit(1) + if err := repos.Users.Update(ctx, user); err != nil { + logger.Error("Failed to update user", slog.Any("error", err)) + return err } + + fmt.Printf("User %q updated successfully\n", username) + return nil } -func cmdServe() { +func cmdUserSetPassword(ctx context.Context, logger *slog.Logger, repos *repository.Repositories, args []string) error { + if len(args) < 1 { + fmt.Fprintln(os.Stderr, "Usage: selfcloud user set-password [new_password]") + return errors.New("missing username") + } + username := args[0] + passArg := "" + if len(args) >= 2 { + passArg = args[1] + } + user, err := repos.Users.GetByUsername(ctx, username) + if err != nil || user == nil { + fmt.Fprintf(os.Stderr, "User %q not found\n", username) + return fmt.Errorf("user %q not found", username) + } + newPassword, err := resolvePassword(passArg, fmt.Sprintf("Enter new password for user %q", username)) + if err != nil { + logger.Error("Password error", slog.Any("error", err)) + return err + } + hash, err := domain.HashPassword(newPassword) + if err != nil { + logger.Error("Failed to hash password", slog.Any("error", err)) + return err + } + user.PasswordHash = hash + if err := repos.Users.Update(ctx, user); err != nil { + logger.Error("Failed to update user password", slog.Any("error", err)) + return err + } + fmt.Printf("Password for user %q updated successfully\n", username) + return nil +} + +func cmdUserSetEmail(ctx context.Context, logger *slog.Logger, repos *repository.Repositories, args []string) error { + if len(args) < 2 { + fmt.Fprintln(os.Stderr, "Usage: selfcloud user set-email ") + return errors.New("missing arguments") + } + username, newEmail := args[0], args[1] + user, err := repos.Users.GetByUsername(ctx, username) + if err != nil || user == nil { + fmt.Fprintf(os.Stderr, "User %q not found\n", username) + return fmt.Errorf("user %q not found", username) + } + user.Email = newEmail + if err := repos.Users.Update(ctx, user); err != nil { + logger.Error("Failed to update user email", slog.Any("error", err)) + return err + } + fmt.Printf("Email for user %q updated successfully to %q\n", username, newEmail) + return nil +} + +type serverDependencies struct { + cfg *config.Config + repos *repository.Repositories + transactor repository.Transactor + storage storage.Storage + loginRateLimiter *middleware.IPRateLimiter + logger *slog.Logger +} + +func cmdServe() error { cfg := config.LoadConfig() logger := makeLogger(cfg) slog.SetDefault(logger) @@ -535,7 +601,7 @@ func cmdServe() { sqlDB, err := connectDB(cfg) if err != nil { logger.Error("Database connection failed", slog.Any("error", err)) - os.Exit(1) + return err } defer sqlDB.Close() @@ -543,7 +609,7 @@ func cmdServe() { logger.Info("Auto-migration enabled: running database migrations") if err := db.Migrate(sqlDB); err != nil { logger.Error("Auto-migration failed", slog.Any("error", err)) - os.Exit(1) + return err } logger.Info("Database auto-migration completed successfully") } @@ -554,47 +620,94 @@ func cmdServe() { localStorage, err := storage.NewLocalStorage(cfg.StorageDir) if err != nil { logger.Error("Failed to initialize storage driver", slog.Any("error", err)) - os.Exit(1) + return err + } + + middleware.ConfigureSecurity(cfg.TrustProxy, cfg.CookieSecure) + loginRateLimiter := middleware.NewIPRateLimiter(cfg.RateLimitLoginRequests, cfg.RateLimitLoginWindow) + defer loginRateLimiter.Close() + + deps := &serverDependencies{ + cfg: cfg, + repos: repos, + transactor: transactor, + storage: localStorage, + loginRateLimiter: loginRateLimiter, + logger: logger, + } + + mux := buildServerMux(deps) + + reaperCtx, cancelReaper := context.WithCancel(context.Background()) + defer cancelReaper() + go startSessionReaper(reaperCtx, repos, logger) + + rootHandler := middleware.LoggingMiddleware(logger)(middleware.MetricsMiddleware(mux)) + server := &http.Server{ + Addr: ":" + cfg.ServerPort, + Handler: rootHandler, + ReadHeaderTimeout: 30 * time.Second, + ReadTimeout: cfg.ServerReadTimeout, + WriteTimeout: cfg.ServerReadTimeout, + IdleTimeout: 120 * time.Second, } + return runHTTPServer(server, logger) +} + +func buildServerMux(deps *serverDependencies) *http.ServeMux { + cfg := deps.cfg + logger := deps.logger + ocsHandler := ocs.NewOCSHandler(cfg) - webdavHandler := webdav.NewWebDAVHandler(transactor, repos.Nodes, localStorage, logger). + webdavHandler := webdav.NewWebDAVHandler(deps.transactor, deps.repos.Nodes, deps.storage, logger). WithMaxUploadSize(cfg.MaxUploadSize). WithWWWAuthenticateHeader(cfg.WWWAuthenticateHeader()) - authHandler := api.NewAuthHandler(repos.Users, repos.Sessions, logger). - WithIdentityRepo(repos.Identities). + authHandler := api.NewAuthHandler(deps.repos.Users, deps.repos.Sessions, logger). + WithIdentityRepo(deps.repos.Identities). WithConfig(cfg) - filesHandler := api.NewFilesHandler(transactor, repos.Nodes, localStorage, logger).WithMaxUploadSize(cfg.MaxUploadSize) - sharesHandler := api.NewSharesHandler(transactor, repos.Shares, repos.Nodes, repos.Users, localStorage, logger) + filesHandler := api.NewFilesHandler(deps.transactor, deps.repos.Nodes, deps.storage, logger).WithMaxUploadSize(cfg.MaxUploadSize) + sharesHandler := api.NewSharesHandler(deps.transactor, deps.repos.Shares, deps.repos.Nodes, deps.repos.Users, deps.storage, logger) systemHandler := api.NewSystemHandler(buildVersion, buildCommit, buildDate) - ssoHandler := api.NewSSOHandler(cfg, transactor, repos.Users, repos.Sessions, repos.Nodes, repos.Identities, logger) - oauthHandler := api.NewOAuthHandler(repos.OAuth, repos.Users, repos.Sessions, logger) - graphHandler := api.NewGraphHandler(repos.Users, repos.Nodes) + ssoHandler := api.NewSSOHandler(cfg, deps.transactor, deps.repos.Users, deps.repos.Sessions, deps.repos.Nodes, deps.repos.Identities, logger) + oauthHandler := api.NewOAuthHandler(deps.repos.OAuth, deps.repos.Users, deps.repos.Sessions, logger) + graphHandler := api.NewGraphHandler(deps.repos.Users, deps.repos.Nodes) mux := http.NewServeMux() + mux.Handle("GET /metrics", buildMetricsHandler(cfg)) - metricsHandler := promhttp.Handler() - if cfg.MetricsAuthToken != "" { - metricsHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token := "" - authHeader := r.Header.Get("Authorization") - if strings.HasPrefix(authHeader, "Bearer ") { - token = strings.TrimPrefix(authHeader, "Bearer ") - } else if qToken := r.URL.Query().Get("token"); qToken != "" { - token = qToken - } - if token != cfg.MetricsAuthToken { - w.Header().Set("WWW-Authenticate", `Bearer realm="metrics"`) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - promhttp.Handler().ServeHTTP(w, r) - }) + registerDiscoveryRoutes(mux, ocsHandler, oauthHandler) + registerAPIRoutes(mux, deps, authHandler, filesHandler, sharesHandler, ssoHandler, systemHandler, oauthHandler) + registerWebDAVRoutes(mux, deps, webdavHandler, ocsHandler, graphHandler) + registerStaticRoutes(mux, cfg, logger) + + return mux +} + +func buildMetricsHandler(cfg *config.Config) http.Handler { + if cfg.MetricsAuthToken == "" { + return promhttp.Handler() } - mux.Handle("GET /metrics", metricsHandler) - mux.HandleFunc("GET /status.php", ocsHandler.StatusHandler) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := "" + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + token = strings.TrimPrefix(authHeader, "Bearer ") + } else if qToken := r.URL.Query().Get("token"); qToken != "" { + token = qToken + } + if token != cfg.MetricsAuthToken { + w.Header().Set("WWW-Authenticate", `Bearer realm="metrics"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + promhttp.Handler().ServeHTTP(w, r) + }) +} - // OpenID Connect Discovery & OAuth2 Endpoints for ownCloud Sync Clients (Desktop and Mobile) +func registerDiscoveryRoutes(mux *http.ServeMux, ocsHandler *ocs.OCSHandler, oauthHandler *api.OAuthHandler) { + mux.HandleFunc("GET /status.php", ocsHandler.StatusHandler) + mux.HandleFunc("GET /index.php/status.php", ocsHandler.StatusHandler) mux.HandleFunc("GET /.well-known/webfinger", oauthHandler.WebFingerHandler) mux.HandleFunc("GET /.well-known/openid-configuration", oauthHandler.OpenIDConfigurationHandler) mux.HandleFunc("GET /api/v1/auth/oauth2/authorize", oauthHandler.AuthorizeHandler) @@ -603,44 +716,49 @@ func cmdServe() { mux.HandleFunc("POST /index.php/apps/oauth2/authorize", oauthHandler.AuthorizeHandler) mux.HandleFunc("GET /apps/oauth2/authorize", oauthHandler.AuthorizeHandler) mux.HandleFunc("POST /apps/oauth2/authorize", oauthHandler.AuthorizeHandler) - mux.HandleFunc("GET /index.php/status.php", ocsHandler.StatusHandler) - - sessionAuthWrapper := middleware.RequireSessionAuthMiddleware(repos.Sessions, repos.Users) - middleware.ConfigureSecurity(cfg.TrustProxy, cfg.CookieSecure) - - loginRateLimiter := middleware.NewIPRateLimiter(cfg.RateLimitLoginRequests, cfg.RateLimitLoginWindow) - defer loginRateLimiter.Close() - - mux.Handle("POST /api/v1/auth/oauth2/token", loginRateLimiter.LimitMiddleware(http.HandlerFunc(oauthHandler.TokenHandler))) - mux.Handle("POST /index.php/apps/oauth2/api/v1/token", loginRateLimiter.LimitMiddleware(http.HandlerFunc(oauthHandler.TokenHandler))) - mux.Handle("POST /apps/oauth2/api/v1/token", loginRateLimiter.LimitMiddleware(http.HandlerFunc(oauthHandler.TokenHandler))) +} - // REST System Info Endpoint +func registerAPIRoutes( + mux *http.ServeMux, + deps *serverDependencies, + authHandler *api.AuthHandler, + filesHandler *api.FilesHandler, + sharesHandler *api.SharesHandler, + ssoHandler *api.SSOHandler, + systemHandler *api.SystemHandler, + oauthHandler *api.OAuthHandler, +) { + sessionAuth := middleware.RequireSessionAuthMiddleware(deps.repos.Sessions, deps.repos.Users) + + // OAuth Token Endpoints + mux.Handle("POST /api/v1/auth/oauth2/token", deps.loginRateLimiter.LimitMiddleware(http.HandlerFunc(oauthHandler.TokenHandler))) + mux.Handle("POST /index.php/apps/oauth2/api/v1/token", deps.loginRateLimiter.LimitMiddleware(http.HandlerFunc(oauthHandler.TokenHandler))) + mux.Handle("POST /apps/oauth2/api/v1/token", deps.loginRateLimiter.LimitMiddleware(http.HandlerFunc(oauthHandler.TokenHandler))) + + // System info mux.HandleFunc("GET /api/v1/system/info", systemHandler.SystemInfoHandler) - // REST Auth & SSO API Endpoints for Web UI - mux.Handle("POST /api/v1/auth/login", loginRateLimiter.LimitMiddleware(http.HandlerFunc(authHandler.LoginHandler))) - mux.Handle("POST /api/v1/auth/logout", sessionAuthWrapper(http.HandlerFunc(authHandler.LogoutHandler))) - mux.Handle("GET /api/v1/auth/me", sessionAuthWrapper(http.HandlerFunc(authHandler.MeHandler))) - mux.Handle("GET /api/v1/auth/sso/google/login", loginRateLimiter.LimitMiddleware(http.HandlerFunc(ssoHandler.GoogleLoginHandler))) - mux.Handle("GET /api/v1/auth/sso/google/callback", loginRateLimiter.LimitMiddleware(http.HandlerFunc(ssoHandler.GoogleCallbackHandler))) - - // REST Files API Endpoints for Web UI - mux.Handle("GET /api/v1/files", sessionAuthWrapper(http.HandlerFunc(filesHandler.GetFilesHandler))) - mux.Handle("POST /api/v1/files/upload", sessionAuthWrapper(http.HandlerFunc(filesHandler.UploadHandler))) - mux.Handle("POST /api/v1/files/mkdir", sessionAuthWrapper(http.HandlerFunc(filesHandler.MkdirHandler))) - mux.Handle("DELETE /api/v1/files", sessionAuthWrapper(http.HandlerFunc(filesHandler.DeleteHandler))) - mux.Handle("POST /api/v1/files/rename", sessionAuthWrapper(http.HandlerFunc(filesHandler.RenameHandler))) - mux.Handle("GET /api/v1/files/download", sessionAuthWrapper(http.HandlerFunc(filesHandler.DownloadHandler))) - mux.Handle("GET /api/v1/files/download-zip", sessionAuthWrapper(http.HandlerFunc(filesHandler.DownloadZipHandler))) - mux.Handle("POST /api/v1/files/download-zip", sessionAuthWrapper(http.HandlerFunc(filesHandler.DownloadZipHandler))) - - // REST Shares API Endpoints for Web UI - mux.Handle("POST /api/v1/shares", sessionAuthWrapper(http.HandlerFunc(sharesHandler.CreateShareHandler))) - mux.Handle("GET /api/v1/shares", sessionAuthWrapper(http.HandlerFunc(sharesHandler.ListSharesHandler))) - mux.Handle("DELETE /api/v1/shares", sessionAuthWrapper(http.HandlerFunc(sharesHandler.RevokeShareHandler))) - - // Public Unauthenticated Share Endpoints + // Auth & SSO + mux.Handle("POST /api/v1/auth/login", deps.loginRateLimiter.LimitMiddleware(http.HandlerFunc(authHandler.LoginHandler))) + mux.Handle("POST /api/v1/auth/logout", sessionAuth(http.HandlerFunc(authHandler.LogoutHandler))) + mux.Handle("GET /api/v1/auth/me", sessionAuth(http.HandlerFunc(authHandler.MeHandler))) + mux.Handle("GET /api/v1/auth/sso/google/login", deps.loginRateLimiter.LimitMiddleware(http.HandlerFunc(ssoHandler.GoogleLoginHandler))) + mux.Handle("GET /api/v1/auth/sso/google/callback", deps.loginRateLimiter.LimitMiddleware(http.HandlerFunc(ssoHandler.GoogleCallbackHandler))) + + // Files + mux.Handle("GET /api/v1/files", sessionAuth(http.HandlerFunc(filesHandler.GetFilesHandler))) + mux.Handle("POST /api/v1/files/upload", sessionAuth(http.HandlerFunc(filesHandler.UploadHandler))) + mux.Handle("POST /api/v1/files/mkdir", sessionAuth(http.HandlerFunc(filesHandler.MkdirHandler))) + mux.Handle("DELETE /api/v1/files", sessionAuth(http.HandlerFunc(filesHandler.DeleteHandler))) + mux.Handle("POST /api/v1/files/rename", sessionAuth(http.HandlerFunc(filesHandler.RenameHandler))) + mux.Handle("GET /api/v1/files/download", sessionAuth(http.HandlerFunc(filesHandler.DownloadHandler))) + mux.Handle("GET /api/v1/files/download-zip", sessionAuth(http.HandlerFunc(filesHandler.DownloadZipHandler))) + mux.Handle("POST /api/v1/files/download-zip", sessionAuth(http.HandlerFunc(filesHandler.DownloadZipHandler))) + + // Shares + mux.Handle("POST /api/v1/shares", sessionAuth(http.HandlerFunc(sharesHandler.CreateShareHandler))) + mux.Handle("GET /api/v1/shares", sessionAuth(http.HandlerFunc(sharesHandler.ListSharesHandler))) + mux.Handle("DELETE /api/v1/shares", sessionAuth(http.HandlerFunc(sharesHandler.RevokeShareHandler))) mux.HandleFunc("GET /api/v1/public/shares/download", sharesHandler.PublicShareDownloadHandler) mux.HandleFunc("GET /api/v1/public/shares", sharesHandler.PublicShareInfoHandler) mux.HandleFunc("GET /s/", func(w http.ResponseWriter, r *http.Request) { @@ -648,7 +766,7 @@ func cmdServe() { sharesHandler.PublicShareDownloadHandler(w, r) return } - if cfg.ServeStatic { + if deps.cfg.ServeStatic { uiDistDir := "./web/dist" if _, err := os.Stat(uiDistDir); err == nil { http.ServeFile(w, r, filepath.Join(uiDistDir, "index.html")) @@ -657,11 +775,18 @@ func cmdServe() { } sharesHandler.PublicShareInfoHandler(w, r) }) +} - authWrapper := middleware.DualAuthMiddleware(repos.Users, repos.Sessions, cfg, loginRateLimiter) +func registerWebDAVRoutes( + mux *http.ServeMux, + deps *serverDependencies, + webdavHandler *webdav.WebDAVHandler, + ocsHandler *ocs.OCSHandler, + graphHandler *api.GraphHandler, +) { + authWrapper := middleware.DualAuthMiddleware(deps.repos.Users, deps.repos.Sessions, deps.cfg, deps.loginRateLimiter) - // Microsoft Graph API for ownCloud Infinite Scale Spaces discovery (opt-in) - if cfg.EnableSpaces { + if deps.cfg.EnableSpaces { mux.Handle("GET /graph/v1.0/me/drives", authWrapper(http.HandlerFunc(graphHandler.MeDrivesHandler))) } @@ -676,29 +801,11 @@ func cmdServe() { mux.Handle("/dav/files/", authWrapper(webdavHandler)) mux.Handle("/webdav/", authWrapper(webdavHandler)) mux.Handle("/webdav", authWrapper(webdavHandler)) +} +func registerStaticRoutes(mux *http.ServeMux, cfg *config.Config, logger *slog.Logger) { uiDistDir := "./web/dist" - if cfg.ServeStatic { - if _, err := os.Stat(uiDistDir); err == nil { - logger.Info("Static Web UI file serving enabled", slog.String("dir", uiDistDir)) - fileServer := http.FileServer(http.Dir(uiDistDir)) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/" || r.URL.Path == "/login" || strings.HasPrefix(r.URL.Path, "/s/") { - http.ServeFile(w, r, filepath.Join(uiDistDir, "index.html")) - return - } - fileServer.ServeHTTP(w, r) - }) - } else { - logger.Warn("Static Web UI directory not found", slog.String("dir", uiDistDir)) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - logger.Warn("Static Web UI requested but dist directory is missing", slog.String("path", r.URL.Path)) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"error":"Static Web UI dist directory not found"}`)) - }) - } - } else { + if !cfg.ServeStatic { logger.Info("Static Web UI file serving disabled (SERVE_STATIC=false)") mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { logger.Debug("Static Web UI requested while SERVE_STATIC=false", slog.String("path", r.URL.Path)) @@ -706,37 +813,47 @@ func cmdServe() { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"Static Web UI file serving is disabled on this server"}`)) }) + return } - // Periodic background cleanup of expired sessions - reaperCtx, cancelReaper := context.WithCancel(context.Background()) - defer cancelReaper() - go func() { - ticker := time.NewTicker(1 * time.Hour) - defer ticker.Stop() - for { - select { - case <-reaperCtx.Done(): - return - case <-ticker.C: - if count, err := repos.Sessions.DeleteExpired(reaperCtx); err == nil && count > 0 { - logger.Debug("Cleaned up expired sessions", slog.Int64("count", count)) - } - } - } - }() + if _, err := os.Stat(uiDistDir); err != nil { + logger.Warn("Static Web UI directory not found", slog.String("dir", uiDistDir)) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + logger.Warn("Static Web UI requested but dist directory is missing", slog.String("path", r.URL.Path)) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"Static Web UI dist directory not found"}`)) + }) + return + } - rootHandler := middleware.LoggingMiddleware(logger)(middleware.MetricsMiddleware(mux)) + logger.Info("Static Web UI file serving enabled", slog.String("dir", uiDistDir)) + fileServer := http.FileServer(http.Dir(uiDistDir)) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" || r.URL.Path == "/login" || strings.HasPrefix(r.URL.Path, "/s/") { + http.ServeFile(w, r, filepath.Join(uiDistDir, "index.html")) + return + } + fileServer.ServeHTTP(w, r) + }) +} - server := &http.Server{ - Addr: ":" + cfg.ServerPort, - Handler: rootHandler, - ReadHeaderTimeout: 30 * time.Second, - ReadTimeout: cfg.ServerReadTimeout, - WriteTimeout: cfg.ServerReadTimeout, - IdleTimeout: 120 * time.Second, +func startSessionReaper(ctx context.Context, repos *repository.Repositories, logger *slog.Logger) { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if count, err := repos.Sessions.DeleteExpired(ctx); err == nil && count > 0 { + logger.Debug("Cleaned up expired sessions", slog.Int64("count", count)) + } + } } +} +func runHTTPServer(server *http.Server, logger *slog.Logger) error { stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) @@ -757,4 +874,5 @@ func cmdServe() { logger.Error("Graceful shutdown failed", slog.Any("error", err)) } logger.Info("Server stopped cleanly") + return nil } diff --git a/internal/api/auth_handler.go b/internal/api/auth_handler.go index b0e4ab8..a7b776c 100644 --- a/internal/api/auth_handler.go +++ b/internal/api/auth_handler.go @@ -154,7 +154,7 @@ func (h *AuthHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) { } audit.Record(r.Context(), "web_logout", username, fmt.Sprintf("User %q logged out", username)) - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + writeStatusOK(w) } func (h *AuthHandler) MeHandler(w http.ResponseWriter, r *http.Request) { @@ -208,13 +208,3 @@ func generateSessionToken() string { func extractCookieOrHeaderToken(r *http.Request) string { return middleware.ExtractSessionToken(r) } - -func writeJSON(w http.ResponseWriter, status int, v interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(v) -} - -func writeJSONError(w http.ResponseWriter, status int, message string) { - writeJSON(w, status, map[string]string{"error": message}) -} diff --git a/internal/api/files_handler.go b/internal/api/files_handler.go index 65553e1..41ba860 100644 --- a/internal/api/files_handler.go +++ b/internal/api/files_handler.go @@ -33,11 +33,11 @@ type FilesHandler struct { maxUploadSize int64 } -func NewFilesHandler(transactor repository.Transactor, nodeRepo repository.NodeRepository, storage storage.Storage, logger *slog.Logger) *FilesHandler { +func NewFilesHandler(transactor repository.Transactor, nodeRepo repository.NodeRepository, blobStore storage.Storage, logger *slog.Logger) *FilesHandler { return &FilesHandler{ transactor: transactor, nodeRepo: nodeRepo, - storage: storage, + storage: blobStore, logger: logger, maxUploadSize: config.DefaultMaxUploadSize, } @@ -115,6 +115,109 @@ func (h *FilesHandler) GetFilesHandler(w http.ResponseWriter, r *http.Request) { }) } +func (h *FilesHandler) resolveUploadDestination(ctx context.Context, userID int64, formPath, formDir, filename string) (string, error) { + if formPath == "" { + destDir := formDir + if destDir == "" { + destDir = "/" + } + fileName := path.Base(filename) + if fileName == "" || fileName == "." || fileName == "/" { + return "", errors.New("invalid file name") + } + destPath := path.Clean(path.Join(destDir, fileName)) + if err := pathutil.ValidatePathLength(destPath); err != nil { + return "", err + } + return destPath, nil + } + + cleanDest := path.Clean(formPath) + targetNode, _ := h.nodeRepo.GetByPath(ctx, userID, cleanDest) + destPath := cleanDest + if (targetNode != nil && targetNode.IsDir) || strings.HasSuffix(formPath, "/") || cleanDest == "." || cleanDest == "/" { + destPath = path.Join(cleanDest, filename) + } + destPath = path.Clean(destPath) + if err := pathutil.ValidatePathLength(destPath); err != nil { + return "", err + } + return destPath, nil +} + +func (h *FilesHandler) compensateFailedUpload(username, destPath string, isNew bool, err error) { + if isNew { + if delErr := h.storage.Delete(username, destPath); delErr != nil { + h.logger.Error("Failed to clean up storage after DB failure during upload", slog.String("path", destPath), slog.Any("error", delErr)) + } + } + h.logger.Error("API upload node persistence failed", slog.Any("error", err)) +} + +func parseUploadMTime(ocMTime string) time.Time { + if ocMTime != "" { + if sec, err := strconv.ParseInt(ocMTime, 10, 64); err == nil { + return time.Unix(sec, 0) + } + } + return time.Now() +} + +func saveUploadedNode( + ctx context.Context, + tx *repository.Repositories, + userID int64, + destPath, contentType, etag string, + size int64, + mtime time.Time, +) (*domain.Node, bool, error) { + parentPath, name := pathutil.SplitPath(destPath) + if err := tx.Nodes.EnsureParentDirectoryExists(ctx, userID, parentPath); err != nil { + return nil, false, fmt.Errorf("failed to ensure parent directory exists: %w", err) + } + + existingNode, err := tx.Nodes.GetByPath(ctx, userID, destPath) + if err != nil { + return nil, false, err + } + + var node *domain.Node + isNew := false + + if existingNode == nil { + node = &domain.Node{ + UserID: userID, + Path: destPath, + ParentPath: parentPath, + Name: name, + IsDir: false, + Size: size, + ETag: etag, + FileID: pathutil.GenerateFileID(userID), + MTime: mtime, + ContentType: contentType, + } + if err := tx.Nodes.Create(ctx, node); err != nil { + return nil, false, err + } + isNew = true + } else { + existingNode.Size = size + existingNode.ETag = etag + existingNode.MTime = mtime + existingNode.ContentType = contentType + if err := tx.Nodes.Update(ctx, existingNode); err != nil { + return nil, false, err + } + node = existingNode + } + + if err := tx.Nodes.TouchAncestors(ctx, userID, destPath); err != nil { + return nil, false, err + } + return node, isNew, nil +} + // UploadHandler handles file uploads from the Web UI. func (h *FilesHandler) UploadHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -147,29 +250,8 @@ func (h *FilesHandler) UploadHandler(w http.ResponseWriter, r *http.Request) { } defer file.Close() - destPath := r.FormValue("path") - if destPath == "" { - destDir := r.FormValue("dir") - if destDir == "" { - destDir = "/" - } - fileName := path.Base(fileHeader.Filename) - if fileName == "" || fileName == "." || fileName == "/" { - writeJSONError(w, http.StatusBadRequest, "Invalid file name") - return - } - destPath = path.Join(destDir, fileName) - } else { - cleanDest := path.Clean(destPath) - targetNode, _ := h.nodeRepo.GetByPath(r.Context(), user.ID, cleanDest) - if (targetNode != nil && targetNode.IsDir) || strings.HasSuffix(destPath, "/") || cleanDest == "." || cleanDest == "/" { - destPath = path.Join(cleanDest, fileHeader.Filename) - } else { - destPath = cleanDest - } - } - destPath = path.Clean(destPath) - if err := pathutil.ValidatePathLength(destPath); err != nil { + destPath, err := h.resolveUploadDestination(r.Context(), user.ID, r.FormValue("path"), r.FormValue("dir"), fileHeader.Filename) + if err != nil { writeJSONError(w, http.StatusBadRequest, err.Error()) return } @@ -181,71 +263,28 @@ func (h *FilesHandler) UploadHandler(w http.ResponseWriter, r *http.Request) { return } - mtime := time.Now() - if ocMTime := r.FormValue("mtime"); ocMTime != "" { - if sec, err := strconv.ParseInt(ocMTime, 10, 64); err == nil { - mtime = time.Unix(sec, 0) - } - } - + mtime := parseUploadMTime(r.FormValue("mtime")) etag := fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprintf("%s-%d-%d", destPath, size, mtime.UnixNano())))) contentType := fileHeader.Header.Get("Content-Type") if contentType == "" { contentType = "application/octet-stream" } - parentPath, name := pathutil.SplitPath(destPath) var node *domain.Node - isNew := false + var isNew bool err = h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error { - if err := tx.Nodes.EnsureParentDirectoryExists(r.Context(), user.ID, parentPath); err != nil { - return fmt.Errorf("failed to ensure parent directory exists: %w", err) - } - - existingNode, err := tx.Nodes.GetByPath(r.Context(), user.ID, destPath) + n, created, err := saveUploadedNode(r.Context(), tx, user.ID, destPath, contentType, etag, size, mtime) if err != nil { return err } - - if existingNode == nil { - fileID := pathutil.GenerateFileID(user.ID) - node = &domain.Node{ - UserID: user.ID, - Path: destPath, - ParentPath: parentPath, - Name: name, - IsDir: false, - Size: size, - ETag: etag, - FileID: fileID, - MTime: mtime, - ContentType: contentType, - } - if err := tx.Nodes.Create(r.Context(), node); err != nil { - return err - } - isNew = true - } else { - existingNode.Size = size - existingNode.ETag = etag - existingNode.MTime = mtime - existingNode.ContentType = contentType - if err := tx.Nodes.Update(r.Context(), existingNode); err != nil { - return err - } - node = existingNode - } - return tx.Nodes.TouchAncestors(r.Context(), user.ID, destPath) + node = n + isNew = created + return nil }) if err != nil { - if isNew { - if delErr := h.storage.Delete(user.Username, destPath); delErr != nil { - h.logger.Error("Failed to clean up storage after DB failure during upload", slog.String("path", destPath), slog.Any("error", delErr)) - } - } - h.logger.Error("API upload node persistence failed", slog.Any("error", err)) + h.compensateFailedUpload(user.Username, destPath, isNew, err) writeJSONError(w, http.StatusInternalServerError, "Failed to save node metadata") return } @@ -301,28 +340,7 @@ func (h *FilesHandler) MkdirHandler(w http.ResponseWriter, r *http.Request) { ContentType: "httpd/unix-directory", } - err := h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error { - existing, err := tx.Nodes.GetByPath(r.Context(), user.ID, dirPath) - if err != nil { - return err - } - if existing != nil { - return domain.ErrAlreadyExists - } - - if err := tx.Nodes.EnsureParentDirectoryExists(r.Context(), user.ID, parentPath); err != nil { - return fmt.Errorf("failed to ensure parent directory exists: %w", err) - } - if err := tx.Nodes.Create(r.Context(), node); err != nil { - if strings.Contains(strings.ToLower(err.Error()), "duplicate") || strings.Contains(err.Error(), "1062") { - return domain.ErrAlreadyExists - } - return err - } - return tx.Nodes.TouchAncestors(r.Context(), user.ID, dirPath) - }) - - if err != nil { + if err := h.persistDirNode(r.Context(), user, node); err != nil { if errors.Is(err, domain.ErrAlreadyExists) { writeJSONError(w, http.StatusBadRequest, "Directory or file already exists") return @@ -343,6 +361,29 @@ func (h *FilesHandler) MkdirHandler(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, map[string]interface{}{"node": node}) } +func (h *FilesHandler) persistDirNode(ctx context.Context, user *domain.User, node *domain.Node) error { + return h.transactor.WithTx(ctx, func(tx *repository.Repositories) error { + existing, err := tx.Nodes.GetByPath(ctx, user.ID, node.Path) + if err != nil { + return err + } + if existing != nil { + return domain.ErrAlreadyExists + } + + if err := tx.Nodes.EnsureParentDirectoryExists(ctx, user.ID, node.ParentPath); err != nil { + return fmt.Errorf("failed to ensure parent directory exists: %w", err) + } + if err := tx.Nodes.Create(ctx, node); err != nil { + if strings.Contains(strings.ToLower(err.Error()), "duplicate") || strings.Contains(err.Error(), "1062") { + return domain.ErrAlreadyExists + } + return err + } + return tx.Nodes.TouchAncestors(ctx, user.ID, node.Path) + }) +} + // DeleteHandler removes a file or directory. func (h *FilesHandler) DeleteHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { @@ -377,35 +418,36 @@ func (h *FilesHandler) DeleteHandler(w http.ResponseWriter, r *http.Request) { return } + if err := h.deleteNodeMetadata(r.Context(), user.ID, targetPath, node.IsDir); err != nil { + h.logger.Error("API delete DB transaction failed", slog.String("path", targetPath), slog.Bool("is_dir", node.IsDir), slog.Any("error", err)) + writeJSONError(w, http.StatusInternalServerError, "Failed to delete metadata") + return + } + if node.IsDir { - if err := h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error { - if err := tx.Nodes.DeletePrefix(r.Context(), user.ID, targetPath); err != nil { - return err - } - return tx.Nodes.TouchAncestors(r.Context(), user.ID, targetPath) - }); err != nil { - h.logger.Error("API delete dir DB transaction failed", slog.String("path", targetPath), slog.Any("error", err)) - writeJSONError(w, http.StatusInternalServerError, "Failed to delete directory metadata") - return - } _ = h.storage.DeleteAll(user.Username, targetPath) audit.Record(r.Context(), "api_delete_dir", targetPath, fmt.Sprintf("User %q deleted directory %q", user.Username, targetPath)) } else { - if err := h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error { - if err := tx.Nodes.Delete(r.Context(), user.ID, targetPath); err != nil { - return err - } - return tx.Nodes.TouchAncestors(r.Context(), user.ID, targetPath) - }); err != nil { - h.logger.Error("API delete file DB transaction failed", slog.String("path", targetPath), slog.Any("error", err)) - writeJSONError(w, http.StatusInternalServerError, "Failed to delete file metadata") - return - } _ = h.storage.Delete(user.Username, targetPath) audit.Record(r.Context(), "api_delete_file", targetPath, fmt.Sprintf("User %q deleted file %q", user.Username, targetPath)) } - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + writeStatusOK(w) +} + +func (h *FilesHandler) deleteNodeMetadata(ctx context.Context, userID int64, targetPath string, isDir bool) error { + return h.transactor.WithTx(ctx, func(tx *repository.Repositories) error { + if isDir { + if err := tx.Nodes.DeletePrefix(ctx, userID, targetPath); err != nil { + return err + } + } else { + if err := tx.Nodes.Delete(ctx, userID, targetPath); err != nil { + return err + } + } + return tx.Nodes.TouchAncestors(ctx, userID, targetPath) + }) } // RenameHandler renames or moves a node. @@ -490,7 +532,7 @@ func (h *FilesHandler) DownloadHandler(w http.ResponseWriter, r *http.Request) { } defer rc.Close() - w.Header().Set("ETag", fmt.Sprintf(`"%s"`, node.ETag)) + w.Header().Set("ETag", fmt.Sprintf("%q", node.ETag)) if node.ContentType != "" { w.Header().Set("Content-Type", node.ContentType) } @@ -514,6 +556,24 @@ func (h *FilesHandler) DownloadZipHandler(w http.ResponseWriter, r *http.Request return } + targetPaths := extractZipTargetPaths(r) + if len(targetPaths) == 0 { + writeJSONError(w, http.StatusBadRequest, "No paths specified for ZIP download") + return + } + + archiveName := resolveArchiveName(targetPaths) + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", pathutil.FormatContentDisposition("attachment", archiveName)) + + zipWriter := zip.NewWriter(w) + defer zipWriter.Close() + + h.streamZipNodes(r.Context(), user, targetPaths, zipWriter) + audit.Record(r.Context(), "api_download_zip", targetPaths[0], fmt.Sprintf("User %q downloaded ZIP archive for %v", user.Username, targetPaths)) +} + +func extractZipTargetPaths(r *http.Request) []string { var targetPaths []string if reqPath := r.URL.Query().Get("path"); reqPath != "" { targetPaths = append(targetPaths, path.Clean(reqPath)) @@ -524,102 +584,108 @@ func (h *FilesHandler) DownloadZipHandler(w http.ResponseWriter, r *http.Request targetPaths = append(targetPaths, path.Clean(p)) } } + return targetPaths +} - if len(targetPaths) == 0 { - writeJSONError(w, http.StatusBadRequest, "No paths specified for ZIP download") - return - } - - archiveName := "download.zip" +func resolveArchiveName(targetPaths []string) string { if len(targetPaths) == 1 && targetPaths[0] != "/" { - archiveName = fmt.Sprintf("%s.zip", path.Base(targetPaths[0])) + return fmt.Sprintf("%s.zip", path.Base(targetPaths[0])) } + return "download.zip" +} - w.Header().Set("Content-Type", "application/zip") - w.Header().Set("Content-Disposition", pathutil.FormatContentDisposition("attachment", archiveName)) - - zipWriter := zip.NewWriter(w) - defer zipWriter.Close() - +func (h *FilesHandler) streamZipNodes(ctx context.Context, user *domain.User, targetPaths []string, zw *zip.Writer) { for _, targetPath := range targetPaths { - if err := r.Context().Err(); err != nil { + if ctx.Err() != nil { break } - node, err := h.nodeRepo.GetByPath(r.Context(), user.ID, targetPath) + node, err := h.nodeRepo.GetByPath(ctx, user.ID, targetPath) if err != nil || node == nil { continue } if node.IsDir { - if err := h.addFolderToZip(r.Context(), user, node.Path, zipWriter); err != nil { - h.logger.WarnContext(r.Context(), "ZIP directory streaming interrupted", slog.String("path", node.Path), slog.Any("error", err)) + if err := h.addFolderToZip(ctx, user, node.Path, zw); err != nil { + h.logger.WarnContext(ctx, "ZIP directory streaming interrupted", slog.String("path", node.Path), slog.Any("error", err)) break } } else { - if err := h.addFileToZip(r.Context(), user.Username, node, node.Name, zipWriter); err != nil { - h.logger.WarnContext(r.Context(), "ZIP file streaming interrupted", slog.String("path", node.Path), slog.Any("error", err)) + if err := h.addFileToZip(ctx, user.Username, node, node.Name, zw); err != nil { + h.logger.WarnContext(ctx, "ZIP file streaming interrupted", slog.String("path", node.Path), slog.Any("error", err)) break } } } - - audit.Record(r.Context(), "api_download_zip", targetPaths[0], fmt.Sprintf("User %q downloaded ZIP archive for %v", user.Username, targetPaths)) } const maxZipRecursionDepth = 64 -func (h *FilesHandler) addFolderToZip(ctx context.Context, user *domain.User, folderPath string, zw *zip.Writer) error { - baseDir := path.Base(folderPath) - visited := make(map[string]bool) +type zipDirContext struct { + ctx context.Context + user *domain.User + zw *zip.Writer + h *FilesHandler + visited map[string]bool +} - var addRecursive func(currentPath, zipPrefix string, depth int) error - addRecursive = func(currentPath, zipPrefix string, depth int) error { - if depth > maxZipRecursionDepth { - return fmt.Errorf("maximum zip directory depth exceeded") - } - if err := ctx.Err(); err != nil { +func (z *zipDirContext) addRecursive(currentPath, zipPrefix string, depth int) error { + if depth > maxZipRecursionDepth { + return errors.New("maximum zip directory depth exceeded") + } + if err := z.ctx.Err(); err != nil { + return err + } + if z.visited[currentPath] { + return nil + } + z.visited[currentPath] = true + + children, err := z.h.nodeRepo.ListByParentPath(z.ctx, z.user.ID, currentPath) + if err != nil { + return err + } + for _, child := range children { + if err := z.ctx.Err(); err != nil { return err } - if visited[currentPath] { - return nil + if child.Path == currentPath || child.Path == "/" || child.Name == "" || z.visited[child.Path] { + continue } - visited[currentPath] = true - children, err := h.nodeRepo.ListByParentPath(ctx, user.ID, currentPath) - if err != nil { + zipEntryPath := path.Join(zipPrefix, child.Name) + if err := z.addZipChild(child, zipEntryPath, depth); err != nil { return err } - for _, child := range children { - if err := ctx.Err(); err != nil { - return err - } - if child.Path == currentPath || child.Path == "/" || child.Name == "" || visited[child.Path] { - continue - } + } + return nil +} - zipEntryPath := path.Join(zipPrefix, child.Name) - if child.IsDir { - if _, err := zw.Create(zipEntryPath + "/"); err != nil { - return err - } - if err := addRecursive(child.Path, zipEntryPath, depth+1); err != nil { - return err - } - } else { - if err := h.addFileToZip(ctx, user.Username, child, zipEntryPath, zw); err != nil { - return err - } - } +func (z *zipDirContext) addZipChild(child *domain.Node, zipEntryPath string, depth int) error { + if child.IsDir { + if _, err := z.zw.Create(zipEntryPath + "/"); err != nil { + return err } - return nil + return z.addRecursive(child.Path, zipEntryPath, depth+1) + } + return z.h.addFileToZip(z.ctx, z.user.Username, child, zipEntryPath, z.zw) +} + +func (h *FilesHandler) addFolderToZip(ctx context.Context, user *domain.User, folderPath string, zw *zip.Writer) error { + zCtx := &zipDirContext{ + ctx: ctx, + user: user, + zw: zw, + h: h, + visited: make(map[string]bool), } if folderPath == "/" { - return addRecursive("/", "", 0) + return zCtx.addRecursive("/", "", 0) } + baseDir := path.Base(folderPath) if _, err := zw.Create(baseDir + "/"); err != nil { return err } - return addRecursive(folderPath, baseDir, 0) + return zCtx.addRecursive(folderPath, baseDir, 0) } func (h *FilesHandler) addFileToZip(ctx context.Context, username string, node *domain.Node, zipPath string, zw *zip.Writer) error { diff --git a/internal/api/graph_handler.go b/internal/api/graph_handler.go index 64c902a..0bb53fd 100644 --- a/internal/api/graph_handler.go +++ b/internal/api/graph_handler.go @@ -35,17 +35,9 @@ func (h *GraphHandler) MeDrivesHandler(w http.ResponseWriter, r *http.Request) { return } - scheme := "http" - if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { - scheme = "https" - } - host := r.Host - if host == "" { - host = "localhost:8080" - } - - webDavURL := fmt.Sprintf("%s://%s/remote.php/webdav", scheme, host) - webURL := fmt.Sprintf("%s://%s/", scheme, host) + baseURL := resolveBaseURL(r) + webDavURL := fmt.Sprintf("%s/remote.php/webdav", baseURL) + webURL := fmt.Sprintf("%s/", baseURL) var usedBytes int64 rootETag := `"1"` diff --git a/internal/api/helpers.go b/internal/api/helpers.go new file mode 100644 index 0000000..34d127a --- /dev/null +++ b/internal/api/helpers.go @@ -0,0 +1,40 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" +) + +const ( + schemeHTTP = "http" + schemeHTTPS = "https" +) + +// resolveBaseURL extracts the request scheme and host, returning "scheme://host". +// Falls back to "localhost:8080" if the Host header is empty. +func resolveBaseURL(r *http.Request) string { + scheme := schemeHTTP + if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == schemeHTTPS { + scheme = schemeHTTPS + } + host := r.Host + if host == "" { + host = "localhost:8080" + } + return fmt.Sprintf("%s://%s", scheme, host) +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeJSONError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} + +func writeStatusOK(w http.ResponseWriter) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} diff --git a/internal/api/oauth_handler.go b/internal/api/oauth_handler.go index d936dd8..1a70838 100644 --- a/internal/api/oauth_handler.go +++ b/internal/api/oauth_handler.go @@ -15,6 +15,7 @@ import ( "net/url" "strings" "time" + "unicode/utf8" "selfcloud/internal/audit" "selfcloud/internal/domain" @@ -62,22 +63,13 @@ func NewOAuthHandler( } // OpenIDConfigurationHandler serves /.well-known/openid-configuration discovery document. -func (h *OAuthHandler) OpenIDConfigurationHandler(w http.ResponseWriter, r *http.Request) { +func (*OAuthHandler) OpenIDConfigurationHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } - scheme := "http" - if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { - scheme = "https" - } - host := r.Host - if host == "" { - host = "localhost:8080" - } - - baseURL := fmt.Sprintf("%s://%s", scheme, host) + baseURL := resolveBaseURL(r) doc := map[string]any{ "issuer": baseURL, @@ -97,21 +89,13 @@ func (h *OAuthHandler) OpenIDConfigurationHandler(w http.ResponseWriter, r *http } // WebFingerHandler serves /.well-known/webfinger for OIDC issuer discovery (used by mobile apps). -func (h *OAuthHandler) WebFingerHandler(w http.ResponseWriter, r *http.Request) { +func (*OAuthHandler) WebFingerHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } - scheme := "http" - if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { - scheme = "https" - } - host := r.Host - if host == "" { - host = "localhost:8080" - } - baseURL := fmt.Sprintf("%s://%s", scheme, host) + baseURL := resolveBaseURL(r) doc := map[string]any{ "subject": baseURL, @@ -169,114 +153,97 @@ func (h *OAuthHandler) handleAuthorizeGet(w http.ResponseWriter, r *http.Request } // Check if already authenticated via session cookie - var currentUser *domain.User - token := middleware.ExtractSessionToken(r) - if token != "" { - sess, err := h.sessionRepo.GetByToken(r.Context(), token) - if err == nil && sess != nil && !sess.IsExpired() { - user, err := h.userRepo.GetByID(r.Context(), sess.UserID) - if err == nil && user != nil { - currentUser = user - } - } - } + currentUser := h.resolveActiveUser(r) - h.renderAuthorizePage(w, r, currentUser, clientID, redirectURI, state, scope, codeChallenge, codeChallengeMethod, loginHint, "") + h.renderAuthorizePage(w, currentUser, clientID, redirectURI, state, scope, codeChallenge, codeChallengeMethod, loginHint, "") } -func (h *OAuthHandler) handleAuthorizePost(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, "Invalid form data", http.StatusBadRequest) - return +func (h *OAuthHandler) resolveActiveUser(r *http.Request) *domain.User { + token := middleware.ExtractSessionToken(r) + if token == "" { + return nil } - - clientID := r.FormValue("client_id") - redirectURI := r.FormValue("redirect_uri") - codeChallenge := r.FormValue("code_challenge") - codeChallengeMethod := r.FormValue("code_challenge_method") - state := r.FormValue("state") - scope := r.FormValue("scope") - action := r.FormValue("action") - - if !h.validateClient(clientID) || !validateRedirectURI(redirectURI) { - http.Error(w, "Invalid client or redirect URI", http.StatusBadRequest) - return + sess, err := h.sessionRepo.GetByToken(r.Context(), token) + if err != nil || sess == nil || sess.IsExpired() { + return nil + } + u, err := h.userRepo.GetByID(r.Context(), sess.UserID) + if err != nil { + return nil } + return u +} - if action == "cancel" { - parsedRedirect, err := url.Parse(redirectURI) - if err == nil { - rq := parsedRedirect.Query() - rq.Set("error", "access_denied") - if state != "" { - rq.Set("state", state) - } - parsedRedirect.RawQuery = rq.Encode() - http.Redirect(w, r, parsedRedirect.String(), http.StatusFound) - return - } +func handleAuthorizeCancel(w http.ResponseWriter, r *http.Request, redirectURI, state string) { + parsedRedirect, err := url.Parse(redirectURI) + if err != nil { http.Error(w, "Access Denied", http.StatusForbidden) return } + rq := parsedRedirect.Query() + rq.Set("error", "access_denied") + if state != "" { + rq.Set("state", state) + } + parsedRedirect.RawQuery = rq.Encode() + http.Redirect(w, r, parsedRedirect.String(), http.StatusFound) +} - var user *domain.User +func (h *OAuthHandler) authenticateFormUser( + w http.ResponseWriter, + r *http.Request, + clientID, redirectURI, state, scope, codeChallenge, codeChallengeMethod string, +) *domain.User { + username := strings.TrimSpace(r.FormValue("username")) + password := r.FormValue("password") - // Check if user is already logged in - token := middleware.ExtractSessionToken(r) - if token != "" { - sess, err := h.sessionRepo.GetByToken(r.Context(), token) - if err == nil && sess != nil && !sess.IsExpired() { - u, err := h.userRepo.GetByID(r.Context(), sess.UserID) - if err == nil && u != nil { - user = u - } + if username == "" || password == "" { + h.renderAuthorizePage(w, nil, clientID, redirectURI, state, scope, codeChallenge, codeChallengeMethod, username, "Username and password are required") + return nil + } + + u, err := h.userRepo.GetByUsername(r.Context(), username) + if err != nil || u == nil || !u.CheckPassword(password) { + if u == nil { + domain.CheckDummyPassword(password) } + audit.RecordFailure(r.Context(), "oauth_login_failed", username, "Invalid credentials during OAuth authorization") + h.renderAuthorizePage(w, nil, clientID, redirectURI, state, scope, codeChallenge, codeChallengeMethod, username, "Invalid username or password") + return nil } - // If not logged in, authenticate via submitted form credentials - if user == nil { - username := strings.TrimSpace(r.FormValue("username")) - password := r.FormValue("password") + h.issueSessionCookie(w, r, u) + return u +} - if username == "" || password == "" { - h.renderAuthorizePage(w, r, nil, clientID, redirectURI, state, scope, codeChallenge, codeChallengeMethod, username, "Username and password are required") - return - } +func (h *OAuthHandler) issueSessionCookie(w http.ResponseWriter, r *http.Request, user *domain.User) { + sessionToken := generateSecureToken() + sessionExpiry := time.Now().Add(7 * 24 * time.Hour) + _ = h.sessionRepo.Create(r.Context(), &domain.Session{ + Token: sessionToken, + UserID: user.ID, + ExpiresAt: sessionExpiry, + CreatedAt: time.Now(), + }) - u, err := h.userRepo.GetByUsername(r.Context(), username) - if err != nil || u == nil || !u.CheckPassword(password) { - if u == nil { - domain.CheckDummyPassword(password) - } - audit.RecordFailure(r.Context(), "oauth_login_failed", username, "Invalid credentials during OAuth authorization") - h.renderAuthorizePage(w, r, nil, clientID, redirectURI, state, scope, codeChallenge, codeChallengeMethod, username, "Invalid username or password") - return - } - user = u - - // Issue a web session cookie so the user stays authenticated in browser - sessionToken := generateSecureToken(32) - sessionExpiry := time.Now().Add(7 * 24 * time.Hour) - _ = h.sessionRepo.Create(r.Context(), &domain.Session{ - Token: sessionToken, - UserID: user.ID, - ExpiresAt: sessionExpiry, - CreatedAt: time.Now(), - }) - - http.SetCookie(w, &http.Cookie{ - Name: middleware.SessionCookieName, - Value: sessionToken, - Path: "/", - Expires: sessionExpiry, - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - Secure: middleware.IsSecureRequest(r), - }) - } - - // Generate one-time authorization code - code := generateSecureToken(32) + http.SetCookie(w, &http.Cookie{ + Name: middleware.SessionCookieName, + Value: sessionToken, + Path: "/", + Expires: sessionExpiry, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: middleware.IsSecureRequest(r), + }) +} + +func (h *OAuthHandler) issueOAuthCodeAndRedirect( + w http.ResponseWriter, + r *http.Request, + user *domain.User, + clientID, redirectURI, codeChallenge, codeChallengeMethod, state, scope string, +) { + code := generateSecureToken() authCode := &domain.OAuthAuthCode{ Code: code, ClientID: clientID, @@ -313,6 +280,41 @@ func (h *OAuthHandler) handleAuthorizePost(w http.ResponseWriter, r *http.Reques http.Redirect(w, r, parsedRedirect.String(), http.StatusFound) } +func (h *OAuthHandler) handleAuthorizePost(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "Invalid form data", http.StatusBadRequest) + return + } + + clientID := r.FormValue("client_id") + redirectURI := r.FormValue("redirect_uri") + codeChallenge := r.FormValue("code_challenge") + codeChallengeMethod := r.FormValue("code_challenge_method") + state := r.FormValue("state") + scope := r.FormValue("scope") + action := r.FormValue("action") + + if !h.validateClient(clientID) || !validateRedirectURI(redirectURI) { + http.Error(w, "Invalid client or redirect URI", http.StatusBadRequest) + return + } + + if action == "cancel" { + handleAuthorizeCancel(w, r, redirectURI, state) + return + } + + user := h.resolveActiveUser(r) + if user == nil { + user = h.authenticateFormUser(w, r, clientID, redirectURI, state, scope, codeChallenge, codeChallengeMethod) + if user == nil { + return + } + } + + h.issueOAuthCodeAndRedirect(w, r, user, clientID, redirectURI, codeChallenge, codeChallengeMethod, state, scope) +} + // TokenHandler handles POST requests for token exchange (authorization_code and refresh_token). func (h *OAuthHandler) TokenHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -426,8 +428,8 @@ func (h *OAuthHandler) handleRefreshTokenGrant(w http.ResponseWriter, r *http.Re } func (h *OAuthHandler) issueTokens(w http.ResponseWriter, r *http.Request, userID int64, clientID, scope string) { - accessToken := generateSecureToken(32) - refreshToken := generateSecureToken(32) + accessToken := generateSecureToken() + refreshToken := generateSecureToken() accessExpiresAt := time.Now().Add(DefaultAccessTokenTTL) refreshExpiresAt := time.Now().Add(DefaultRefreshTokenTTL) @@ -475,7 +477,7 @@ func (h *OAuthHandler) issueTokens(w http.ResponseWriter, r *http.Request, userI _ = json.NewEncoder(w).Encode(resp) } -func (h *OAuthHandler) extractClientCredentials(r *http.Request) (string, string) { +func (*OAuthHandler) extractClientCredentials(r *http.Request) (string, string) { clientID, clientSecret, ok := r.BasicAuth() if ok { return clientID, clientSecret @@ -483,11 +485,11 @@ func (h *OAuthHandler) extractClientCredentials(r *http.Request) (string, string return r.FormValue("client_id"), r.FormValue("client_secret") } -func (h *OAuthHandler) validateClient(clientID string) bool { +func (*OAuthHandler) validateClient(clientID string) bool { return clientID != "" } -func (h *OAuthHandler) validateClientSecret(clientID, clientSecret string) bool { +func (*OAuthHandler) validateClientSecret(clientID, clientSecret string) bool { switch clientID { case DefaultOwnCloudClientID: if clientSecret == "" || clientSecret == DefaultOwnCloudClientSecret { @@ -525,12 +527,12 @@ func validateRedirectURI(rawURL string) bool { // Native desktop apps per RFC 8252 use loopback with dynamic port hostname := u.Hostname() if strings.EqualFold(hostname, "localhost") || hostname == "127.0.0.1" || hostname == "::1" { - return scheme == "http" || scheme == "https" + return scheme == schemeHTTP || scheme == schemeHTTPS } ip := net.ParseIP(hostname) if ip != nil && ip.IsLoopback() { - return scheme == "http" || scheme == "https" + return scheme == schemeHTTP || scheme == schemeHTTPS } return false @@ -562,13 +564,13 @@ func verifyPKCE(verifier, challenge, method string) bool { } } -func generateSecureToken(byteLength int) string { - b := make([]byte, byteLength) +func generateSecureToken() string { + b := make([]byte, 32) _, _ = rand.Read(b) return hex.EncodeToString(b) } -func (h *OAuthHandler) writeOAuthError(w http.ResponseWriter, errCode, errDesc string, status int) { +func (*OAuthHandler) writeOAuthError(w http.ResponseWriter, errCode, errDesc string, status int) { w.Header().Set("Content-Type", "application/json;charset=UTF-8") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]string{ @@ -577,93 +579,96 @@ func (h *OAuthHandler) writeOAuthError(w http.ResponseWriter, errCode, errDesc s }) } -func (h *OAuthHandler) renderAuthorizePage( - w http.ResponseWriter, - r *http.Request, - user *domain.User, - clientID, redirectURI, state, scope, challenge, method, loginHint, errMsg string, -) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") +func userInitial(username string) string { + if username == "" { + return "U" + } + firstRune, _ := utf8.DecodeRuneInString(username) + if firstRune == utf8.RuneError { + return "U" + } + return strings.ToUpper(string(firstRune)) +} - escapedError := "" - if errMsg != "" { - escapedError = fmt.Sprintf(` -
- - %s -
- `, html.EscapeString(errMsg)) +func renderErrorBox(errMsg string) string { + if errMsg == "" { + return "" } + return fmt.Sprintf(` +
+ + %s +
+ `, html.EscapeString(errMsg)) +} - bodyContent := "" - if user != nil { - initial := "U" - if len(user.Username) > 0 { - initial = strings.ToUpper(string([]rune(user.Username)[0])) - } - bodyContent = fmt.Sprintf(` -

- Application %s is requesting access to your account: -

-
-
%s
-
-
%s
-
Currently signed in
-
+func renderSignedInBody(clientID string, user *domain.User) string { + initial := userInitial(user.Username) + return fmt.Sprintf(` +

+ Application %s is requesting access to your account: +

+
+
%s
+
+
%s
+
Currently signed in
-
- - -
- `, html.EscapeString(getClientDisplayName(clientID)), html.EscapeString(initial), html.EscapeString(user.Username)) - } else { - usernameAutofocus := "autofocus" - passwordAutofocus := "" - if loginHint != "" { - usernameAutofocus = "" - passwordAutofocus = "autofocus" - } - bodyContent = fmt.Sprintf(` -

- Sign in to your SelfCloud account to connect %s: -

-
- -
-
- -
- +
+
+ + +
+ `, html.EscapeString(getClientDisplayName(clientID)), html.EscapeString(initial), html.EscapeString(user.Username)) +} + +func renderLoginFormBody(clientID, loginHint string) string { + usernameAutofocus := "autofocus" + passwordAutofocus := "" + if loginHint != "" { + usernameAutofocus = "" + passwordAutofocus = "autofocus" + } + return fmt.Sprintf(` +

+ Sign in to your SelfCloud account to connect %s: +

+
+ +
+
+
+
-
- -
-
- -
- +
+
+ +
+
+
+
-
- - -
- `, html.EscapeString(getClientDisplayName(clientID)), html.EscapeString(loginHint), usernameAutofocus, passwordAutofocus) - } +
+
+ + +
+ `, html.EscapeString(getClientDisplayName(clientID)), html.EscapeString(loginHint), usernameAutofocus, passwordAutofocus) +} - page := fmt.Sprintf(` +const authorizePageTemplate = ` @@ -886,7 +891,31 @@ func (h *OAuthHandler) renderAuthorizePage(
-`, escapedError, html.EscapeString(clientID), html.EscapeString(redirectURI), html.EscapeString(state), html.EscapeString(scope), html.EscapeString(challenge), html.EscapeString(method), bodyContent) +` + +func (*OAuthHandler) renderAuthorizePage( + w http.ResponseWriter, + user *domain.User, + clientID, redirectURI, state, scope, challenge, method, loginHint, errMsg string, +) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + bodyContent := renderLoginFormBody(clientID, loginHint) + if user != nil { + bodyContent = renderSignedInBody(clientID, user) + } + + page := fmt.Sprintf( + authorizePageTemplate, + renderErrorBox(errMsg), + html.EscapeString(clientID), + html.EscapeString(redirectURI), + html.EscapeString(state), + html.EscapeString(scope), + html.EscapeString(challenge), + html.EscapeString(method), + bodyContent, + ) _, _ = w.Write([]byte(page)) } diff --git a/internal/api/shares_handler.go b/internal/api/shares_handler.go index a210eed..c085d93 100644 --- a/internal/api/shares_handler.go +++ b/internal/api/shares_handler.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" "log/slog" "net/http" @@ -30,13 +31,13 @@ type SharesHandler struct { logger *slog.Logger } -func NewSharesHandler(transactor repository.Transactor, shareRepo repository.ShareRepository, nodeRepo repository.NodeRepository, userRepo repository.UserRepository, storage storage.Storage, logger *slog.Logger) *SharesHandler { +func NewSharesHandler(transactor repository.Transactor, shareRepo repository.ShareRepository, nodeRepo repository.NodeRepository, userRepo repository.UserRepository, blobStore storage.Storage, logger *slog.Logger) *SharesHandler { return &SharesHandler{ transactor: transactor, shareRepo: shareRepo, nodeRepo: nodeRepo, userRepo: userRepo, - storage: storage, + storage: blobStore, logger: logger, } } @@ -85,7 +86,7 @@ func (h *SharesHandler) CreateShareHandler(w http.ResponseWriter, r *http.Reques return err } if node == nil { - return fmt.Errorf("node not found") + return errors.New("node not found") } // Check if already shared @@ -199,7 +200,7 @@ func (h *SharesHandler) RevokeShareHandler(w http.ResponseWriter, r *http.Reques } audit.Record(r.Context(), "api_revoke_share", token, fmt.Sprintf("User %q revoked public share %q", user.Username, token)) - writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + writeStatusOK(w) } // PublicShareInfoHandler returns public unauthenticated preview info for a share token. @@ -297,7 +298,7 @@ func (h *SharesHandler) PublicShareDownloadHandler(w http.ResponseWriter, r *htt } defer rc.Close() - w.Header().Set("ETag", fmt.Sprintf(`"%s"`, node.ETag)) + w.Header().Set("ETag", fmt.Sprintf("%q", node.ETag)) if node.ContentType != "" { w.Header().Set("Content-Type", node.ContentType) } @@ -309,15 +310,7 @@ func (h *SharesHandler) PublicShareDownloadHandler(w http.ResponseWriter, r *htt } func formatShareResponse(r *http.Request, s *domain.Share) ShareResponse { - scheme := "http" - if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { - scheme = "https" - } - host := r.Host - if host == "" { - host = "localhost:8080" - } - shareURL := fmt.Sprintf("%s://%s/s/%s", scheme, host, s.Token) + shareURL := fmt.Sprintf("%s/s/%s", resolveBaseURL(r), s.Token) return ShareResponse{ ID: s.ID, diff --git a/internal/api/sso_handler.go b/internal/api/sso_handler.go index 2d68e8c..ff0fa4f 100644 --- a/internal/api/sso_handler.go +++ b/internal/api/sso_handler.go @@ -61,10 +61,12 @@ type googleUserInfo struct { Name string `json:"name"` } -func (h *SSOHandler) getRedirectURI(r *http.Request) string { +func (h *SSOHandler) getRedirectURI() string { return fmt.Sprintf("%s/api/v1/auth/sso/google/callback", strings.TrimRight(h.cfg.SSORedirectBaseURL, "/")) } +const oauthStateCookieName = "oauth_state" + func (h *SSOHandler) GoogleLoginHandler(w http.ResponseWriter, r *http.Request) { if h.cfg.SSOGoogleClientID == "" { writeJSONError(w, http.StatusBadRequest, "Google SSO is not configured on this server") @@ -73,7 +75,7 @@ func (h *SSOHandler) GoogleLoginHandler(w http.ResponseWriter, r *http.Request) state := generateOAuthState() http.SetCookie(w, &http.Cookie{ - Name: "oauth_state", + Name: oauthStateCookieName, Value: state, Path: "/", Expires: time.Now().Add(10 * time.Minute), @@ -82,7 +84,7 @@ func (h *SSOHandler) GoogleLoginHandler(w http.ResponseWriter, r *http.Request) SameSite: http.SameSiteLaxMode, }) - redirectURI := h.getRedirectURI(r) + redirectURI := h.getRedirectURI() authURL := fmt.Sprintf( "https://accounts.google.com/o/oauth2/v2/auth?client_id=%s&redirect_uri=%s&response_type=code&scope=openid%%20email%%20profile&state=%s&prompt=select_account", url.QueryEscape(h.cfg.SSOGoogleClientID), @@ -93,29 +95,24 @@ func (h *SSOHandler) GoogleLoginHandler(w http.ResponseWriter, r *http.Request) http.Redirect(w, r, authURL, http.StatusFound) } -func (h *SSOHandler) GoogleCallbackHandler(w http.ResponseWriter, r *http.Request) { - if h.cfg.SSOGoogleClientID == "" { - writeJSONError(w, http.StatusBadRequest, "Google SSO is not configured on this server") - return - } - - cookieState, err := r.Cookie("oauth_state") +func validateOAuthState(w http.ResponseWriter, r *http.Request) bool { + cookieState, err := r.Cookie(oauthStateCookieName) if err != nil || cookieState.Value == "" { audit.RecordFailure(r.Context(), "sso_google_login", "", "SSO failed: missing or expired oauth_state cookie") writeJSONError(w, http.StatusBadRequest, "Invalid or expired OAuth state") - return + return false } queryState := r.URL.Query().Get("state") if queryState != cookieState.Value { audit.RecordFailure(r.Context(), "sso_google_login", "", "SSO failed: state mismatch (possible CSRF)") writeJSONError(w, http.StatusBadRequest, "OAuth state mismatch") - return + return false } // Invalidate state cookie http.SetCookie(w, &http.Cookie{ - Name: "oauth_state", + Name: oauthStateCookieName, Value: "", Path: "/", Expires: time.Unix(0, 0), @@ -123,6 +120,18 @@ func (h *SSOHandler) GoogleCallbackHandler(w http.ResponseWriter, r *http.Reques HttpOnly: true, Secure: middleware.IsSecureRequest(r), }) + return true +} + +func (h *SSOHandler) GoogleCallbackHandler(w http.ResponseWriter, r *http.Request) { + if h.cfg.SSOGoogleClientID == "" { + writeJSONError(w, http.StatusBadRequest, "Google SSO is not configured on this server") + return + } + + if !validateOAuthState(w, r) { + return + } code := r.URL.Query().Get("code") if code == "" { @@ -131,7 +140,7 @@ func (h *SSOHandler) GoogleCallbackHandler(w http.ResponseWriter, r *http.Reques return } - userInfo, err := h.exchangeGoogleCode(r.Context(), code, h.getRedirectURI(r)) + userInfo, err := h.exchangeGoogleCode(r.Context(), code, h.getRedirectURI()) if err != nil { h.logger.Error("Failed to exchange Google OAuth code", slog.Any("error", err)) audit.RecordFailure(r.Context(), "sso_google_login", "", "SSO failed: token exchange with Google failed", slog.Any("error", err)) @@ -150,93 +159,13 @@ func (h *SSOHandler) GoogleCallbackHandler(w http.ResponseWriter, r *http.Reques sessionExpiresAt := time.Now().Add(7 * 24 * time.Hour) err = h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error { - // 1. Check if identity exists by (provider="google", sub=userInfo.Sub) - identity, err := tx.Identities.GetByProviderUID(r.Context(), "google", userInfo.Sub) + activeUserID := extractActiveUserID(r, tx) + u, err := resolveOrCreateSSOUser(r.Context(), tx, userInfo, activeUserID) if err != nil { - return fmt.Errorf("failed to lookup identity: %w", err) + return err } + user = u - if identity != nil { - user, err = tx.Users.GetByID(r.Context(), identity.UserID) - if err != nil || user == nil { - return fmt.Errorf("user account no longer exists") - } - } else { - // 2. Lookup existing user by email - existingUser, _ := tx.Users.GetByEmail(r.Context(), userInfo.Email) - if existingUser != nil { - // To prevent account takeover, an existing account can only be linked to a new SSO identity - // if the request originates from an active authenticated session belonging to that user. - var activeUserID int64 = -1 - if sessToken := middleware.ExtractSessionToken(r); sessToken != "" { - if sess, err := tx.Sessions.GetByToken(r.Context(), sessToken); err == nil && sess != nil && !sess.IsExpired() { - activeUserID = sess.UserID - } - } - if activeUserID != existingUser.ID { - return ErrAccountLinkConflict - } - user = existingUser - } else { - // Auto-provision user account with a unique non-colliding username - baseUsername := sanitizeUsername(userInfo.Email) - username := baseUsername - counter := 1 - for { - u, err := tx.Users.GetByUsername(r.Context(), username) - if err != nil || u == nil { - break - } - username = fmt.Sprintf("%s_%d", baseUsername, counter) - counter++ - } - - randomPass, _ := domain.HashPassword(generateOAuthState()) - newUser := &domain.User{ - Username: username, - PasswordHash: randomPass, - Email: userInfo.Email, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - if err := tx.Users.Create(r.Context(), newUser); err != nil { - return fmt.Errorf("failed to create user: %w", err) - } - user = newUser - - // Seed root directory node for new SSO user - fileID := fmt.Sprintf("%08doc_%s", user.ID, generateOAuthState()[:12]) - etag := fmt.Sprintf("%x", md5.Sum([]byte("/"+fileID))) - if err := tx.Nodes.Create(r.Context(), &domain.Node{ - UserID: user.ID, - Path: "/", - ParentPath: "", - Name: "", - IsDir: true, - Size: 0, - ETag: etag, - FileID: fileID, - MTime: time.Now(), - ContentType: "httpd/unix-directory", - }); err != nil { - return fmt.Errorf("failed to seed root node: %w", err) - } - } - - // Bind identity to user - newIdentity := &domain.UserIdentity{ - UserID: user.ID, - Provider: "google", - ProviderUserID: userInfo.Sub, - Email: userInfo.Email, - CreatedAt: time.Now(), - } - if err := tx.Identities.Create(r.Context(), newIdentity); err != nil { - return fmt.Errorf("failed to create identity: %w", err) - } - } - - // 3. Issue session token session := &domain.Session{ Token: sessionToken, UserID: user.ID, @@ -277,6 +206,110 @@ func (h *SSOHandler) GoogleCallbackHandler(w http.ResponseWriter, r *http.Reques http.Redirect(w, r, "/", http.StatusFound) } +func extractActiveUserID(r *http.Request, tx *repository.Repositories) int64 { + sessToken := middleware.ExtractSessionToken(r) + if sessToken == "" { + return -1 + } + sess, err := tx.Sessions.GetByToken(r.Context(), sessToken) + if err == nil && sess != nil && !sess.IsExpired() { + return sess.UserID + } + return -1 +} + +func resolveOrCreateSSOUser(ctx context.Context, tx *repository.Repositories, info *googleUserInfo, activeUserID int64) (*domain.User, error) { + identity, err := tx.Identities.GetByProviderUID(ctx, "google", info.Sub) + if err != nil { + return nil, fmt.Errorf("failed to lookup identity: %w", err) + } + + if identity != nil { + user, err := tx.Users.GetByID(ctx, identity.UserID) + if err != nil || user == nil { + return nil, errors.New("user account no longer exists") + } + return user, nil + } + + user, err := linkOrCreateUser(ctx, tx, info, activeUserID) + if err != nil { + return nil, err + } + + // Bind identity to user + newIdentity := &domain.UserIdentity{ + UserID: user.ID, + Provider: "google", + ProviderUserID: info.Sub, + Email: info.Email, + CreatedAt: time.Now(), + } + if err := tx.Identities.Create(ctx, newIdentity); err != nil { + return nil, fmt.Errorf("failed to create identity: %w", err) + } + + return user, nil +} + +func linkOrCreateUser(ctx context.Context, tx *repository.Repositories, info *googleUserInfo, activeUserID int64) (*domain.User, error) { + existingUser, _ := tx.Users.GetByEmail(ctx, info.Email) + if existingUser != nil { + if activeUserID != existingUser.ID { + return nil, ErrAccountLinkConflict + } + return existingUser, nil + } + + return provisionSSOUser(ctx, tx, info.Email) +} + +func provisionSSOUser(ctx context.Context, tx *repository.Repositories, email string) (*domain.User, error) { + baseUsername := sanitizeUsername(email) + username := baseUsername + counter := 1 + for { + u, err := tx.Users.GetByUsername(ctx, username) + if err != nil || u == nil { + break + } + username = fmt.Sprintf("%s_%d", baseUsername, counter) + counter++ + } + + randomPass, _ := domain.HashPassword(generateOAuthState()) + newUser := &domain.User{ + Username: username, + PasswordHash: randomPass, + Email: email, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := tx.Users.Create(ctx, newUser); err != nil { + return nil, fmt.Errorf("failed to create user: %w", err) + } + + // Seed root directory node for new SSO user + fileID := fmt.Sprintf("%08doc_%s", newUser.ID, generateOAuthState()[:12]) + etag := fmt.Sprintf("%x", md5.Sum([]byte("/"+fileID))) + if err := tx.Nodes.Create(ctx, &domain.Node{ + UserID: newUser.ID, + Path: "/", + ParentPath: "", + Name: "", + IsDir: true, + Size: 0, + ETag: etag, + FileID: fileID, + MTime: time.Now(), + ContentType: "httpd/unix-directory", + }); err != nil { + return nil, fmt.Errorf("failed to seed root node: %w", err) + } + + return newUser, nil +} + func (h *SSOHandler) exchangeGoogleCode(ctx context.Context, code, redirectURI string) (*googleUserInfo, error) { data := url.Values{} data.Set("code", code) @@ -307,10 +340,10 @@ func (h *SSOHandler) exchangeGoogleCode(ctx context.Context, code, redirectURI s AccessToken string `json:"access_token"` } if err := json.NewDecoder(resp.Body).Decode(&tokenRes); err != nil || tokenRes.AccessToken == "" { - return nil, fmt.Errorf("invalid token response") + return nil, errors.New("invalid token response") } - userReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/oauth2/v3/userinfo", nil) + userReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/oauth2/v3/userinfo", http.NoBody) if err != nil { return nil, fmt.Errorf("failed to create userinfo request: %w", err) } @@ -324,7 +357,7 @@ func (h *SSOHandler) exchangeGoogleCode(ctx context.Context, code, redirectURI s var info googleUserInfo if err := json.NewDecoder(userResp.Body).Decode(&info); err != nil || info.Sub == "" { - return nil, fmt.Errorf("invalid userinfo response") + return nil, errors.New("invalid userinfo response") } return &info, nil diff --git a/internal/api/system_handler.go b/internal/api/system_handler.go index 58589a5..7a40c2a 100644 --- a/internal/api/system_handler.go +++ b/internal/api/system_handler.go @@ -29,7 +29,7 @@ func NewSystemHandler(version, commit, buildDate string) *SystemHandler { } // SystemInfoHandler returns build version information. -func (h *SystemHandler) SystemInfoHandler(w http.ResponseWriter, r *http.Request) { +func (h *SystemHandler) SystemInfoHandler(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "service": h.buildInfo, diff --git a/internal/db/db.go b/internal/db/db.go index 53168c9..3edf545 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -30,32 +30,15 @@ func Connect(driver, dsn string, poolCfgs ...PoolConfig) (*sql.DB, error) { return nil, fmt.Errorf("failed to open database connection: %w", err) } - maxOpen := 25 - maxIdle := 10 - maxLifetime := 5 * time.Minute - maxIdleTime := 1 * time.Minute + db.SetMaxOpenConns(25) + db.SetMaxIdleConns(10) + db.SetConnMaxLifetime(5 * time.Minute) + db.SetConnMaxIdleTime(1 * time.Minute) if len(poolCfgs) > 0 { - cfg := poolCfgs[0] - if cfg.MaxOpenConns > 0 { - maxOpen = cfg.MaxOpenConns - } - if cfg.MaxIdleConns > 0 { - maxIdle = cfg.MaxIdleConns - } - if cfg.ConnMaxLifetime > 0 { - maxLifetime = cfg.ConnMaxLifetime - } - if cfg.ConnMaxIdleTime > 0 { - maxIdleTime = cfg.ConnMaxIdleTime - } + applyPoolConfig(db, poolCfgs[0]) } - db.SetMaxOpenConns(maxOpen) - db.SetMaxIdleConns(maxIdle) - db.SetConnMaxLifetime(maxLifetime) - db.SetConnMaxIdleTime(maxIdleTime) - if err := db.Ping(); err != nil { return nil, fmt.Errorf("failed to ping database: %w", err) } @@ -63,6 +46,21 @@ func Connect(driver, dsn string, poolCfgs ...PoolConfig) (*sql.DB, error) { return db, nil } +func applyPoolConfig(db *sql.DB, cfg PoolConfig) { + if cfg.MaxOpenConns > 0 { + db.SetMaxOpenConns(cfg.MaxOpenConns) + } + if cfg.MaxIdleConns > 0 { + db.SetMaxIdleConns(cfg.MaxIdleConns) + } + if cfg.ConnMaxLifetime > 0 { + db.SetConnMaxLifetime(cfg.ConnMaxLifetime) + } + if cfg.ConnMaxIdleTime > 0 { + db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime) + } +} + type migration struct { version int description string @@ -70,7 +68,18 @@ type migration struct { } func Migrate(db *sql.DB) error { - // Ensure schema_migrations version table exists + if err := ensureMigrationsTable(db); err != nil { + return err + } + for _, m := range getMigrations() { + if err := applyMigration(db, m); err != nil { + return err + } + } + return nil +} + +func ensureMigrationsTable(db *sql.DB) error { initTable := `CREATE TABLE IF NOT EXISTS schema_migrations ( version INT PRIMARY KEY, description VARCHAR(255) NOT NULL, @@ -79,199 +88,211 @@ func Migrate(db *sql.DB) error { if _, err := db.Exec(initTable); err != nil { return fmt.Errorf("failed to create schema_migrations table: %w", err) } + return nil +} - migrations := []migration{ +func getMigrations() []migration { + return []migration{ { version: 1, description: "Initial schema: users, nodes, sessions, shares, user_identities", - run: func(db *sql.DB) error { - queries := []string{ - `CREATE TABLE IF NOT EXISTS users ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - username VARCHAR(255) NOT NULL UNIQUE, - password_hash VARCHAR(255) NOT NULL, - email VARCHAR(255) NOT NULL DEFAULT '', - created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), - updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, - - `CREATE TABLE IF NOT EXISTS nodes ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - user_id BIGINT NOT NULL, - path VARCHAR(700) NOT NULL, - parent_path VARCHAR(700) NOT NULL, - name VARCHAR(255) NOT NULL, - is_dir TINYINT(1) NOT NULL DEFAULT 0, - size BIGINT NOT NULL DEFAULT 0, - etag VARCHAR(255) NOT NULL DEFAULT '', - file_id VARCHAR(255) NOT NULL DEFAULT '', - mtime DATETIME(6) NOT NULL, - content_type VARCHAR(255) NOT NULL DEFAULT '', - created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), - updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), - UNIQUE KEY idx_user_path (user_id, path), - KEY idx_user_parent (user_id, parent_path), - CONSTRAINT fk_nodes_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, - - `CREATE TABLE IF NOT EXISTS sessions ( - token VARCHAR(64) PRIMARY KEY, - user_id BIGINT NOT NULL, - expires_at DATETIME NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - KEY idx_sessions_user (user_id), - CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, - - `CREATE TABLE IF NOT EXISTS shares ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - token VARCHAR(64) NOT NULL UNIQUE, - user_id BIGINT NOT NULL, - path VARCHAR(700) NOT NULL, - is_dir TINYINT(1) NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - KEY idx_shares_token (token), - KEY idx_shares_user (user_id), - CONSTRAINT fk_shares_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, - - `CREATE TABLE IF NOT EXISTS user_identities ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - user_id BIGINT NOT NULL, - provider VARCHAR(50) NOT NULL, - provider_user_id VARCHAR(255) NOT NULL, - email VARCHAR(255) NOT NULL DEFAULT '', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY idx_provider_uid (provider, provider_user_id), - KEY idx_identities_user (user_id), - CONSTRAINT fk_identities_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, - } - - for _, q := range queries { - if _, err := db.Exec(q); err != nil { - return fmt.Errorf("migration query failed: %w", err) - } - } - - var fkExists int - checkFKQuery := `SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS - WHERE TABLE_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'fk_nodes_user' AND TABLE_NAME = 'nodes'` - if err := db.QueryRow(checkFKQuery).Scan(&fkExists); err == nil && fkExists == 0 { - _, _ = db.Exec(`DELETE FROM nodes WHERE user_id NOT IN (SELECT id FROM users)`) - _, _ = db.Exec(`ALTER TABLE nodes ADD CONSTRAINT fk_nodes_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE`) - } - return nil - }, + run: migrateV1, }, { version: 2, description: "Add expires_at column and index to shares table", - run: func(db *sql.DB) error { - var colExists int - checkColQuery := `SELECT COUNT(*) FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'shares' AND COLUMN_NAME = 'expires_at'` - if err := db.QueryRow(checkColQuery).Scan(&colExists); err == nil && colExists == 0 { - if _, err := db.Exec(`ALTER TABLE shares ADD COLUMN expires_at DATETIME NULL DEFAULT NULL AFTER is_dir`); err != nil { - return fmt.Errorf("failed to add expires_at to shares: %w", err) - } - _, _ = db.Exec(`CREATE INDEX idx_shares_expires ON shares (expires_at)`) - } - return nil - }, + run: migrateV2, }, { version: 3, description: "Add idx_sessions_expires index to sessions table for cleanup", - run: func(db *sql.DB) error { - var idxExists int - checkIdxQuery := `SELECT COUNT(*) FROM information_schema.STATISTICS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sessions' AND INDEX_NAME = 'idx_sessions_expires'` - if err := db.QueryRow(checkIdxQuery).Scan(&idxExists); err == nil && idxExists == 0 { - _, _ = db.Exec(`CREATE INDEX idx_sessions_expires ON sessions (expires_at)`) - } - return nil - }, + run: migrateV3, }, { version: 4, description: "Create oauth_auth_codes and oauth_tokens tables for OAuth2/OIDC support", - run: func(db *sql.DB) error { - queries := []string{ - `CREATE TABLE IF NOT EXISTS oauth_auth_codes ( - code VARCHAR(128) PRIMARY KEY, - client_id VARCHAR(255) NOT NULL, - user_id BIGINT NOT NULL, - redirect_uri VARCHAR(1024) NOT NULL, - code_challenge VARCHAR(255) NOT NULL DEFAULT '', - code_challenge_method VARCHAR(32) NOT NULL DEFAULT '', - scope VARCHAR(255) NOT NULL DEFAULT '', - expires_at DATETIME NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_oauth_codes_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, - - `CREATE TABLE IF NOT EXISTS oauth_tokens ( - id BIGINT AUTO_INCREMENT PRIMARY KEY, - client_id VARCHAR(255) NOT NULL, - user_id BIGINT NOT NULL, - access_token VARCHAR(255) NOT NULL UNIQUE, - refresh_token VARCHAR(255) NOT NULL UNIQUE, - access_token_expires_at DATETIME NOT NULL, - refresh_token_expires_at DATETIME NOT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT fk_oauth_tokens_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, - } - - for _, q := range queries { - if _, err := db.Exec(q); err != nil { - return fmt.Errorf("migration query failed: %w", err) - } - } - return nil - }, + run: migrateV4, }, { version: 5, description: "Calculate initial sizes for all directory nodes", - run: func(db *sql.DB) error { - query := `UPDATE nodes d - JOIN ( - SELECT d2.id, COALESCE(SUM(f.size), 0) AS total_size - FROM nodes d2 - LEFT JOIN nodes f ON f.user_id = d2.user_id AND f.is_dir = 0 AND ( - CASE WHEN d2.path = '/' THEN 1 ELSE f.path LIKE CONCAT(d2.path, '/%') END - ) - WHERE d2.is_dir = 1 - GROUP BY d2.id - ) s ON d.id = s.id - SET d.size = s.total_size;` - _, err := db.Exec(query) - return err - }, + run: migrateV5, }, } +} - for _, m := range migrations { - var count int - err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, m.version).Scan(&count) - if err != nil { - return fmt.Errorf("failed to check migration status for version %d: %w", m.version, err) - } - if count > 0 { - continue - } +func applyMigration(db *sql.DB, m migration) error { + var count int + err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE version = ?`, m.version).Scan(&count) + if err != nil { + return fmt.Errorf("failed to check migration status for version %d: %w", m.version, err) + } + if count > 0 { + return nil + } + + if err := m.run(db); err != nil { + return fmt.Errorf("failed applying migration %d (%s): %w", m.version, m.description, err) + } + + if _, err := db.Exec(`INSERT INTO schema_migrations (version, description, applied_at) VALUES (?, ?, ?)`, + m.version, m.description, time.Now()); err != nil { + return fmt.Errorf("failed to record migration %d: %w", m.version, err) + } + return nil +} + +func migrateV1(db *sql.DB) error { + queries := []string{ + `CREATE TABLE IF NOT EXISTS users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(255) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, - if err := m.run(db); err != nil { - return fmt.Errorf("failed applying migration %d (%s): %w", m.version, m.description, err) + `CREATE TABLE IF NOT EXISTS nodes ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + path VARCHAR(700) NOT NULL, + parent_path VARCHAR(700) NOT NULL, + name VARCHAR(255) NOT NULL, + is_dir TINYINT(1) NOT NULL DEFAULT 0, + size BIGINT NOT NULL DEFAULT 0, + etag VARCHAR(255) NOT NULL DEFAULT '', + file_id VARCHAR(255) NOT NULL DEFAULT '', + mtime DATETIME(6) NOT NULL, + content_type VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + UNIQUE KEY idx_user_path (user_id, path), + KEY idx_user_parent (user_id, parent_path), + CONSTRAINT fk_nodes_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, + + `CREATE TABLE IF NOT EXISTS sessions ( + token VARCHAR(64) PRIMARY KEY, + user_id BIGINT NOT NULL, + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_sessions_user (user_id), + CONSTRAINT fk_sessions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, + + `CREATE TABLE IF NOT EXISTS shares ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + token VARCHAR(64) NOT NULL UNIQUE, + user_id BIGINT NOT NULL, + path VARCHAR(700) NOT NULL, + is_dir TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_shares_token (token), + KEY idx_shares_user (user_id), + CONSTRAINT fk_shares_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, + + `CREATE TABLE IF NOT EXISTS user_identities ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + provider VARCHAR(50) NOT NULL, + provider_user_id VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY idx_provider_uid (provider, provider_user_id), + KEY idx_identities_user (user_id), + CONSTRAINT fk_identities_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, + } + + for _, q := range queries { + if _, err := db.Exec(q); err != nil { + return fmt.Errorf("migration query failed: %w", err) } + } - if _, err := db.Exec(`INSERT INTO schema_migrations (version, description, applied_at) VALUES (?, ?, ?)`, - m.version, m.description, time.Now()); err != nil { - return fmt.Errorf("failed to record migration %d: %w", m.version, err) + var fkExists int + checkFKQuery := `SELECT COUNT(*) FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() AND CONSTRAINT_NAME = 'fk_nodes_user' AND TABLE_NAME = 'nodes'` + if err := db.QueryRow(checkFKQuery).Scan(&fkExists); err == nil && fkExists == 0 { + _, _ = db.Exec(`DELETE FROM nodes WHERE user_id NOT IN (SELECT id FROM users)`) + _, _ = db.Exec(`ALTER TABLE nodes ADD CONSTRAINT fk_nodes_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE`) + } + return nil +} + +func migrateV2(db *sql.DB) error { + var colExists int + checkColQuery := `SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'shares' AND COLUMN_NAME = 'expires_at'` + if err := db.QueryRow(checkColQuery).Scan(&colExists); err == nil && colExists == 0 { + if _, err := db.Exec(`ALTER TABLE shares ADD COLUMN expires_at DATETIME NULL DEFAULT NULL AFTER is_dir`); err != nil { + return fmt.Errorf("failed to add expires_at to shares: %w", err) } + _, _ = db.Exec(`CREATE INDEX idx_shares_expires ON shares (expires_at)`) } + return nil +} +func migrateV3(db *sql.DB) error { + var idxExists int + checkIdxQuery := `SELECT COUNT(*) FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sessions' AND INDEX_NAME = 'idx_sessions_expires'` + if err := db.QueryRow(checkIdxQuery).Scan(&idxExists); err == nil && idxExists == 0 { + _, _ = db.Exec(`CREATE INDEX idx_sessions_expires ON sessions (expires_at)`) + } return nil } + +func migrateV4(db *sql.DB) error { + queries := []string{ + `CREATE TABLE IF NOT EXISTS oauth_auth_codes ( + code VARCHAR(128) PRIMARY KEY, + client_id VARCHAR(255) NOT NULL, + user_id BIGINT NOT NULL, + redirect_uri VARCHAR(1024) NOT NULL, + code_challenge VARCHAR(255) NOT NULL DEFAULT '', + code_challenge_method VARCHAR(32) NOT NULL DEFAULT '', + scope VARCHAR(255) NOT NULL DEFAULT '', + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_oauth_codes_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, + + `CREATE TABLE IF NOT EXISTS oauth_tokens ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + client_id VARCHAR(255) NOT NULL, + user_id BIGINT NOT NULL, + access_token VARCHAR(255) NOT NULL UNIQUE, + refresh_token VARCHAR(255) NOT NULL UNIQUE, + access_token_expires_at DATETIME NOT NULL, + refresh_token_expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_oauth_tokens_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`, + } + + for _, q := range queries { + if _, err := db.Exec(q); err != nil { + return fmt.Errorf("migration query failed: %w", err) + } + } + return nil +} + +func migrateV5(db *sql.DB) error { + query := `UPDATE nodes d + JOIN ( + SELECT d2.id, COALESCE(SUM(f.size), 0) AS total_size + FROM nodes d2 + LEFT JOIN nodes f ON f.user_id = d2.user_id AND f.is_dir = 0 AND ( + CASE WHEN d2.path = '/' THEN 1 ELSE f.path LIKE CONCAT(d2.path, '/%') END + ) + WHERE d2.is_dir = 1 + GROUP BY d2.id + ) s ON d.id = s.id + SET d.size = s.total_size;` + _, err := db.Exec(query) + return err +} diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index 94694ce..170557b 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -59,7 +59,6 @@ func (r *Reindexer) ReindexUser(ctx context.Context, user *domain.User, removeOr return stats, nil } - // Track all relative paths found on disk for orphan detection. diskPaths := make(map[string]struct{}) diskPaths["/"] = struct{}{} // root always exists @@ -73,102 +72,18 @@ func (r *Reindexer) ReindexUser(ctx context.Context, user *domain.User, removeOr return nil } - // Compute the relative WebDAV path. - rel, err := filepath.Rel(userDir, absPath) - if err != nil || rel == "." { - return nil // skip the root itself; we handle it separately - } - relPath := "/" + filepath.ToSlash(rel) - diskPaths[relPath] = struct{}{} - - info, err := d.Info() + meta, err := inspectFSEntry(userDir, absPath, d) if err != nil { r.logger.Warn("Cannot stat entry", slog.String("path", absPath), slog.Any("error", err)) stats.Errors++ return nil } - - // Derive metadata from filesystem. - isDir := d.IsDir() - size := int64(0) - if !isDir { - size = info.Size() - } - mtime := info.ModTime() - contentType := "application/octet-stream" - if isDir { - contentType = "httpd/unix-directory" - } - etag := etagFromFS(relPath, size, mtime) - parentPath, name := pathutil.SplitPath(relPath) - - // Check existing DB node. - existing, dbErr := r.nodeRepo.GetByPath(ctx, user.ID, relPath) - if dbErr != nil { - r.logger.Error("DB lookup failed", - slog.String("path", relPath), - slog.Any("error", dbErr), - ) - stats.Errors++ + if meta == nil { return nil } - if existing == nil { - // Node missing in DB → create it. - node := &domain.Node{ - UserID: user.ID, - Path: relPath, - ParentPath: parentPath, - Name: name, - IsDir: isDir, - Size: size, - ETag: etag, - FileID: pathutil.GenerateFileID(user.ID), - MTime: mtime, - ContentType: contentType, - } - r.logger.Info("Creating missing node", - slog.String("path", relPath), - slog.Bool("dry_run", r.dryRun), - ) - if !r.dryRun { - if err := r.nodeRepo.Create(ctx, node); err != nil { - r.logger.Error("Failed to create node", slog.String("path", relPath), slog.Any("error", err)) - stats.Errors++ - return nil - } - } - stats.Created++ - } else { - // Node exists — check if metadata drifted. - changed := existing.Size != size || - existing.MTime.Unix() != mtime.Unix() || - existing.IsDir != isDir || - existing.Name != name || - existing.ParentPath != parentPath - - if changed { - r.logger.Info("Updating stale node", - slog.String("path", relPath), - slog.Bool("dry_run", r.dryRun), - ) - if !r.dryRun { - existing.Name = name - existing.ParentPath = parentPath - existing.Size = size - existing.MTime = mtime - existing.ETag = etag - existing.ContentType = contentType - if err := r.nodeRepo.Update(ctx, existing); err != nil { - r.logger.Error("Failed to update node", slog.String("path", relPath), slog.Any("error", err)) - stats.Errors++ - return nil - } - } - stats.Updated++ - } - } - + diskPaths[meta.relPath] = struct{}{} + r.syncNode(ctx, user, meta, &stats) return nil }) @@ -176,12 +91,10 @@ func (r *Reindexer) ReindexUser(ctx context.Context, user *domain.User, removeOr return stats, fmt.Errorf("walk failed for user %s: %w", user.Username, err) } - // Ensure root node exists. if err := r.ensureRootNode(ctx, user); err != nil { r.logger.Warn("Could not ensure root node", slog.Any("error", err)) } - // Orphan removal: DB nodes with no corresponding disk file. if removeOrphans { orphanStats, err := r.removeOrphans(ctx, user, diskPaths) if err != nil { @@ -198,6 +111,131 @@ func (r *Reindexer) ReindexUser(ctx context.Context, user *domain.User, removeOr return stats, nil } +type fsEntryMeta struct { + relPath string + parentPath string + name string + isDir bool + size int64 + mtime time.Time + contentType string + etag string +} + +func inspectFSEntry(userDir, absPath string, d fs.DirEntry) (*fsEntryMeta, error) { + rel, err := filepath.Rel(userDir, absPath) + if err != nil || rel == "." { + return nil, nil // skip root itself + } + info, err := d.Info() + if err != nil { + return nil, err + } + + relPath := "/" + filepath.ToSlash(rel) + isDir := d.IsDir() + size := int64(0) + contentType := "application/octet-stream" + if isDir { + contentType = "httpd/unix-directory" + } else { + size = info.Size() + } + mtime := info.ModTime() + parentPath, name := pathutil.SplitPath(relPath) + + return &fsEntryMeta{ + relPath: relPath, + parentPath: parentPath, + name: name, + isDir: isDir, + size: size, + mtime: mtime, + contentType: contentType, + etag: etagFromFS(relPath, size, mtime), + }, nil +} + +func (r *Reindexer) syncNode(ctx context.Context, user *domain.User, meta *fsEntryMeta, stats *Stats) { + existing, dbErr := r.nodeRepo.GetByPath(ctx, user.ID, meta.relPath) + if dbErr != nil { + r.logger.Error("DB lookup failed", slog.String("path", meta.relPath), slog.Any("error", dbErr)) + stats.Errors++ + return + } + + if existing == nil { + if err := r.createMissingNode(ctx, user, meta); err != nil { + r.logger.Error("Failed to create node", slog.String("path", meta.relPath), slog.Any("error", err)) + stats.Errors++ + return + } + stats.Created++ + return + } + + updated, err := r.updateStaleNode(ctx, existing, meta) + if err != nil { + r.logger.Error("Failed to update node", slog.String("path", meta.relPath), slog.Any("error", err)) + stats.Errors++ + return + } + if updated { + stats.Updated++ + } +} + +func (r *Reindexer) createMissingNode(ctx context.Context, user *domain.User, meta *fsEntryMeta) error { + r.logger.Info("Creating missing node", + slog.String("path", meta.relPath), + slog.Bool("dry_run", r.dryRun), + ) + if r.dryRun { + return nil + } + node := &domain.Node{ + UserID: user.ID, + Path: meta.relPath, + ParentPath: meta.parentPath, + Name: meta.name, + IsDir: meta.isDir, + Size: meta.size, + ETag: meta.etag, + FileID: pathutil.GenerateFileID(user.ID), + MTime: meta.mtime, + ContentType: meta.contentType, + } + return r.nodeRepo.Create(ctx, node) +} + +func (r *Reindexer) updateStaleNode(ctx context.Context, existing *domain.Node, meta *fsEntryMeta) (bool, error) { + changed := existing.Size != meta.size || + existing.MTime.Unix() != meta.mtime.Unix() || + existing.IsDir != meta.isDir || + existing.Name != meta.name || + existing.ParentPath != meta.parentPath + + if !changed { + return false, nil + } + + r.logger.Info("Updating stale node", + slog.String("path", meta.relPath), + slog.Bool("dry_run", r.dryRun), + ) + if r.dryRun { + return true, nil + } + + existing.Name = meta.name + existing.ParentPath = meta.parentPath + existing.Size = meta.size + existing.MTime = meta.mtime + existing.ETag = meta.etag + existing.ContentType = meta.contentType + return true, r.nodeRepo.Update(ctx, existing) +} + func (r *Reindexer) ensureRootNode(ctx context.Context, user *domain.User) error { existing, err := r.nodeRepo.GetByPath(ctx, user.ID, "/") if err != nil || existing != nil { @@ -221,50 +259,74 @@ func (r *Reindexer) ensureRootNode(ctx context.Context, user *domain.User) error return r.nodeRepo.Create(ctx, node) } -func (r *Reindexer) removeOrphans(ctx context.Context, user *domain.User, diskPaths map[string]struct{}) (int, error) { - count := 0 - visited := make(map[string]bool) +type orphanPruner struct { + ctx context.Context + user *domain.User + diskPaths map[string]struct{} + r *Reindexer + visited map[string]bool + count int +} - var walk func(parentPath string) error - walk = func(parentPath string) error { - if visited[parentPath] { - return nil - } - visited[parentPath] = true +func (p *orphanPruner) processChild(child *domain.Node) error { + if _, onDisk := p.diskPaths[child.Path]; !onDisk { + p.r.deleteOrphanNode(p.ctx, p.user.ID, child.Path) + p.count++ + return nil + } + if child.IsDir { + return p.walk(child.Path) + } + return nil +} - children, err := r.nodeRepo.ListByParentPath(ctx, user.ID, parentPath) - if err != nil { - return err +func (p *orphanPruner) walk(parentPath string) error { + if p.visited[parentPath] { + return nil + } + p.visited[parentPath] = true + + children, err := p.r.nodeRepo.ListByParentPath(p.ctx, p.user.ID, parentPath) + if err != nil { + return err + } + for _, child := range children { + if child.Path == parentPath || child.Path == "/" || child.Name == "" || p.visited[child.Path] { + continue } - for _, child := range children { - if child.Path == parentPath || child.Path == "/" || child.Name == "" || visited[child.Path] { - continue - } - - if _, onDisk := diskPaths[child.Path]; !onDisk { - r.logger.Info("Removing orphan DB node", - slog.String("path", child.Path), - slog.Bool("dry_run", r.dryRun), - ) - if !r.dryRun { - if err := r.nodeRepo.Delete(ctx, user.ID, child.Path); err != nil { - r.logger.Error("Failed to delete orphan node", - slog.String("path", child.Path), - slog.Any("error", err), - ) - } - } - count++ - } else if child.IsDir { - // Recurse into existing directories. - if err := walk(child.Path); err != nil { - return err - } - } + if err := p.processChild(child); err != nil { + return err } - return nil } - return count, walk("/") + return nil +} + +func (r *Reindexer) deleteOrphanNode(ctx context.Context, userID int64, childPath string) { + r.logger.Info("Removing orphan DB node", + slog.String("path", childPath), + slog.Bool("dry_run", r.dryRun), + ) + if r.dryRun { + return + } + if err := r.nodeRepo.Delete(ctx, userID, childPath); err != nil { + r.logger.Error("Failed to delete orphan node", + slog.String("path", childPath), + slog.Any("error", err), + ) + } +} + +func (r *Reindexer) removeOrphans(ctx context.Context, user *domain.User, diskPaths map[string]struct{}) (int, error) { + pruner := &orphanPruner{ + ctx: ctx, + user: user, + diskPaths: diskPaths, + r: r, + visited: make(map[string]bool), + } + err := pruner.walk("/") + return pruner.count, err } // etagFromFS computes an ETag from file path, size, and mtime — no file reads needed. diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 8f3c536..410b2a2 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "strconv" "strings" "selfcloud/internal/audit" @@ -27,6 +28,11 @@ func WithUser(ctx context.Context, user *domain.User) context.Context { return context.WithValue(ctx, UserObjKey, user) } +func injectAuthenticatedUser(ctx context.Context, user *domain.User) context.Context { + audit.SetUsername(ctx, user.Username) + return WithUser(ctx, user) +} + func BasicAuthMiddleware(userRepo repository.UserRepository, rateLimiters ...*IPRateLimiter) func(http.Handler) http.Handler { var limiter *IPRateLimiter if len(rateLimiters) > 0 { @@ -41,7 +47,7 @@ func BasicAuthMiddleware(userRepo repository.UserRepository, rateLimiters ...*IP audit.RecordFailure(r.Context(), "auth_rate_limited", "", fmt.Sprintf("Request rejected: rate limit exceeded for %s", ip), ) - w.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds()))) + w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds()))) http.Error(w, "Too Many Requests", http.StatusTooManyRequests) return } @@ -57,42 +63,11 @@ func BasicAuthMiddleware(userRepo repository.UserRepository, rateLimiters ...*IP return } - user, err := userRepo.GetByUsername(r.Context(), username) - if err != nil || user == nil { - if limiter != nil { - limiter.RecordFailure(ip) - } - domain.CheckDummyPassword(password) - audit.RecordFailure(r.Context(), "auth_unknown_user", username, - fmt.Sprintf("Authentication failed: user %q not found", username), - ) - w.Header().Set("WWW-Authenticate", `Basic realm="selfcloud"`) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - if !user.CheckPassword(password) { - if limiter != nil { - limiter.RecordFailure(ip) - } - audit.RecordFailure(r.Context(), "auth_bad_password", username, - fmt.Sprintf("Authentication failed: invalid password for user %q", username), - ) - w.Header().Set("WWW-Authenticate", `Basic realm="selfcloud"`) - http.Error(w, "Unauthorized", http.StatusUnauthorized) + user, ok := verifyBasicCredentials(w, r, username, password, ip, userRepo, limiter) + if !ok { return } - - if limiter != nil { - limiter.Reset(ip) - } - - ctx := context.WithValue(r.Context(), UserKey, user.Username) - ctx = context.WithValue(ctx, UserObjKey, user) - // Write username into the shared *audit.Event so LoggingMiddleware can - // read it after ServeHTTP returns, without needing to inspect child contexts. - audit.SetUsername(r.Context(), user.Username) - next.ServeHTTP(w, r.WithContext(ctx)) + next.ServeHTTP(w, r.WithContext(injectAuthenticatedUser(r.Context(), user))) }) } } @@ -126,99 +101,22 @@ func DualAuthMiddleware(userRepo repository.UserRepository, sessionRepo reposito // 2. Check for Bearer token authHeader := r.Header.Get("Authorization") if strings.HasPrefix(authHeader, "Bearer ") { - if !oauthEnabled { - audit.RecordFailure(r.Context(), "auth_oauth_disabled", "", - fmt.Sprintf("Request rejected: OAuth/Bearer auth disabled from %s", ip), - ) - w.Header().Set("WWW-Authenticate", wwwAuth) - http.Error(w, "Unauthorized", http.StatusUnauthorized) + user, ok := handleBearerAuth(w, r, authHeader, ip, wwwAuth, oauthEnabled, sessionRepo, userRepo) + if !ok { return } - - token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) - if token != "" && sessionRepo != nil { - session, err := sessionRepo.GetByToken(r.Context(), token) - if err == nil && session != nil { - if session.IsExpired() { - _ = sessionRepo.DeleteByToken(r.Context(), token) - } else { - user, err := userRepo.GetByID(r.Context(), session.UserID) - if err == nil && user != nil { - ctx := context.WithValue(r.Context(), UserKey, user.Username) - ctx = context.WithValue(ctx, UserObjKey, user) - audit.SetUsername(r.Context(), user.Username) - next.ServeHTTP(w, r.WithContext(ctx)) - return - } - } - } - } - audit.RecordFailure(r.Context(), "auth_bad_bearer", "", - fmt.Sprintf("Request rejected: invalid or expired Bearer token from %s", ip), - ) - w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token", realm="selfcloud"`) - http.Error(w, "Unauthorized", http.StatusUnauthorized) + next.ServeHTTP(w, r.WithContext(injectAuthenticatedUser(r.Context(), user))) return } // 3. Check for Basic Auth username, password, ok := r.BasicAuth() if ok { - if !basicEnabled { - audit.RecordFailure(r.Context(), "auth_basic_disabled", "", - fmt.Sprintf("Request rejected: Basic auth disabled from %s", ip), - ) - w.Header().Set("WWW-Authenticate", wwwAuth) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - // Rate limiting for Basic Auth attempts - if limiter != nil { - if allowed, retryAfter := limiter.Check(ip); !allowed { - audit.RecordFailure(r.Context(), "auth_rate_limited", "", - fmt.Sprintf("Request rejected: rate limit exceeded for %s", ip), - ) - w.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds()))) - http.Error(w, "Too Many Requests", http.StatusTooManyRequests) - return - } - } - - user, err := userRepo.GetByUsername(r.Context(), username) - if err != nil || user == nil { - if limiter != nil { - limiter.RecordFailure(ip) - } - domain.CheckDummyPassword(password) - audit.RecordFailure(r.Context(), "auth_unknown_user", username, - fmt.Sprintf("Authentication failed: user %q not found", username), - ) - w.Header().Set("WWW-Authenticate", `Basic realm="selfcloud"`) - http.Error(w, "Unauthorized", http.StatusUnauthorized) + user, ok := handleBasicAuth(w, r, username, password, ip, wwwAuth, basicEnabled, userRepo, limiter) + if !ok { return } - - if !user.CheckPassword(password) { - if limiter != nil { - limiter.RecordFailure(ip) - } - audit.RecordFailure(r.Context(), "auth_bad_password", username, - fmt.Sprintf("Authentication failed: invalid password for user %q", username), - ) - w.Header().Set("WWW-Authenticate", `Basic realm="selfcloud"`) - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - - if limiter != nil { - limiter.Reset(ip) - } - - ctx := context.WithValue(r.Context(), UserKey, user.Username) - ctx = context.WithValue(ctx, UserObjKey, user) - audit.SetUsername(r.Context(), user.Username) - next.ServeHTTP(w, r.WithContext(ctx)) + next.ServeHTTP(w, r.WithContext(injectAuthenticatedUser(r.Context(), user))) return } @@ -231,3 +129,103 @@ func DualAuthMiddleware(userRepo repository.UserRepository, sessionRepo reposito }) } } + +func handleBearerAuth( + w http.ResponseWriter, + r *http.Request, + authHeader, ip, wwwAuth string, + oauthEnabled bool, + sessionRepo repository.SessionRepository, + userRepo repository.UserRepository, +) (*domain.User, bool) { + if !oauthEnabled { + audit.RecordFailure(r.Context(), "auth_oauth_disabled", "", + fmt.Sprintf("Request rejected: OAuth/Bearer auth disabled from %s", ip), + ) + w.Header().Set("WWW-Authenticate", wwwAuth) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return nil, false + } + + token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + if user := resolveSessionUser(r.Context(), sessionRepo, userRepo, token); user != nil { + return user, true + } + + audit.RecordFailure(r.Context(), "auth_bad_bearer", "", + fmt.Sprintf("Request rejected: invalid or expired Bearer token from %s", ip), + ) + w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token", realm="selfcloud"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return nil, false +} + +func handleBasicAuth( + w http.ResponseWriter, + r *http.Request, + username, password, ip, wwwAuth string, + basicEnabled bool, + userRepo repository.UserRepository, + limiter *IPRateLimiter, +) (*domain.User, bool) { + if !basicEnabled { + audit.RecordFailure(r.Context(), "auth_basic_disabled", "", + fmt.Sprintf("Request rejected: Basic auth disabled from %s", ip), + ) + w.Header().Set("WWW-Authenticate", wwwAuth) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return nil, false + } + + if limiter != nil { + if allowed, retryAfter := limiter.Check(ip); !allowed { + audit.RecordFailure(r.Context(), "auth_rate_limited", "", + fmt.Sprintf("Request rejected: rate limit exceeded for %s", ip), + ) + w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds()))) + http.Error(w, "Too Many Requests", http.StatusTooManyRequests) + return nil, false + } + } + + return verifyBasicCredentials(w, r, username, password, ip, userRepo, limiter) +} + +func verifyBasicCredentials( + w http.ResponseWriter, + r *http.Request, + username, password, ip string, + userRepo repository.UserRepository, + limiter *IPRateLimiter, +) (*domain.User, bool) { + user, err := userRepo.GetByUsername(r.Context(), username) + if err != nil || user == nil { + if limiter != nil { + limiter.RecordFailure(ip) + } + domain.CheckDummyPassword(password) + audit.RecordFailure(r.Context(), "auth_unknown_user", username, + fmt.Sprintf("Authentication failed: user %q not found", username), + ) + w.Header().Set("WWW-Authenticate", `Basic realm="selfcloud"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return nil, false + } + + if !user.CheckPassword(password) { + if limiter != nil { + limiter.RecordFailure(ip) + } + audit.RecordFailure(r.Context(), "auth_bad_password", username, + fmt.Sprintf("Authentication failed: invalid password for user %q", username), + ) + w.Header().Set("WWW-Authenticate", `Basic realm="selfcloud"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return nil, false + } + + if limiter != nil { + limiter.Reset(ip) + } + return user, true +} diff --git a/internal/middleware/logging.go b/internal/middleware/logging.go index 8850c5f..7cac240 100644 --- a/internal/middleware/logging.go +++ b/internal/middleware/logging.go @@ -71,18 +71,26 @@ func getClientIP(r *http.Request) string { return GetClientIP(r) } +func getTrustedProxyIP(r *http.Request) string { + if !IsProxyTrusted() { + return "" + } + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + if clientIP := strings.TrimSpace(parts[0]); clientIP != "" { + return clientIP + } + } + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return strings.TrimSpace(xri) + } + return "" +} + // GetClientIP resolves the client IP address, only respecting proxy headers if trusted. func GetClientIP(r *http.Request) string { - if IsProxyTrusted() { - if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - parts := strings.Split(xff, ",") - if clientIP := strings.TrimSpace(parts[0]); clientIP != "" { - return clientIP - } - } - if xri := r.Header.Get("X-Real-IP"); xri != "" { - return strings.TrimSpace(xri) - } + if ip := getTrustedProxyIP(r); ip != "" { + return ip } host, _, err := net.SplitHostPort(r.RemoteAddr) if err == nil { diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go index 809679a..99dc049 100644 --- a/internal/middleware/ratelimit.go +++ b/internal/middleware/ratelimit.go @@ -1,8 +1,8 @@ package middleware import ( - "fmt" "net/http" + "strconv" "sync" "time" ) @@ -153,7 +153,7 @@ func (rl *IPRateLimiter) LimitMiddleware(next http.Handler) http.Handler { ip := getClientIP(r) allowed, retryAfter := rl.Allow(ip) if !allowed { - w.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds()))) + w.Header().Set("Retry-After", strconv.Itoa(int(retryAfter.Seconds()))) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusTooManyRequests) _, _ = w.Write([]byte(`{"error":"Too many requests. Please try again later."}`)) diff --git a/internal/middleware/session_auth.go b/internal/middleware/session_auth.go index 4f8bd82..c6ba9d9 100644 --- a/internal/middleware/session_auth.go +++ b/internal/middleware/session_auth.go @@ -9,30 +9,40 @@ import ( "sync" "selfcloud/internal/audit" + "selfcloud/internal/domain" "selfcloud/internal/repository" ) const SessionCookieName = "selfcloud_session" +func resolveSessionUser(ctx context.Context, sessionRepo repository.SessionRepository, userRepo repository.UserRepository, token string) *domain.User { + if token == "" || sessionRepo == nil || userRepo == nil { + return nil + } + session, err := sessionRepo.GetByToken(ctx, token) + if err != nil || session == nil { + return nil + } + if session.IsExpired() { + _ = sessionRepo.DeleteByToken(ctx, token) + return nil + } + user, err := userRepo.GetByID(ctx, session.UserID) + if err != nil || user == nil { + return nil + } + return user +} + func SessionAuthMiddleware(sessionRepo repository.SessionRepository, userRepo repository.UserRepository) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := ExtractSessionToken(r) - if token != "" { - session, err := sessionRepo.GetByToken(r.Context(), token) - if err == nil && session != nil { - if session.IsExpired() { - _ = sessionRepo.DeleteByToken(r.Context(), token) - } else { - user, err := userRepo.GetByID(r.Context(), session.UserID) - if err == nil && user != nil { - ctx := context.WithValue(r.Context(), UserKey, user.Username) - ctx = context.WithValue(ctx, UserObjKey, user) - audit.SetUsername(ctx, user.Username) - r = r.WithContext(ctx) - } - } - } + if user := resolveSessionUser(r.Context(), sessionRepo, userRepo, token); user != nil { + ctx := context.WithValue(r.Context(), UserKey, user.Username) + ctx = context.WithValue(ctx, UserObjKey, user) + audit.SetUsername(ctx, user.Username) + r = r.WithContext(ctx) } next.ServeHTTP(w, r) diff --git a/internal/ocs/ocs.go b/internal/ocs/ocs.go index 8a6e610..d5ea302 100644 --- a/internal/ocs/ocs.go +++ b/internal/ocs/ocs.go @@ -25,7 +25,7 @@ func (h *OCSHandler) WithConfig(cfg *config.Config) *OCSHandler { return h } -func (h *OCSHandler) StatusHandler(w http.ResponseWriter, r *http.Request) { +func (*OCSHandler) StatusHandler(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") status := map[string]any{ "installed": true, @@ -39,7 +39,7 @@ func (h *OCSHandler) StatusHandler(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(status) } -func (h *OCSHandler) CapabilitiesHandler(w http.ResponseWriter, r *http.Request) { +func (h *OCSHandler) CapabilitiesHandler(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") oauthEnabled := true @@ -95,7 +95,7 @@ func (h *OCSHandler) CapabilitiesHandler(w http.ResponseWriter, r *http.Request) _ = json.NewEncoder(w).Encode(resp) } -func (h *OCSHandler) UserHandler(w http.ResponseWriter, r *http.Request) { +func (*OCSHandler) UserHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") user := middleware.UserFromContext(r.Context()) diff --git a/internal/pathutil/pathutil.go b/internal/pathutil/pathutil.go index f4f0803..970fd50 100644 --- a/internal/pathutil/pathutil.go +++ b/internal/pathutil/pathutil.go @@ -26,7 +26,7 @@ func FormatContentDisposition(dispositionType, filename string) string { if formatted != "" { return formatted } - return fmt.Sprintf(`%s; filename="%s"`, dispositionType, cleanFilename) + return fmt.Sprintf("%s; filename=%q", dispositionType, cleanFilename) } // MaxPathLength is the maximum allowed path length supported by the database schema. diff --git a/internal/repository/identity_repository.go b/internal/repository/identity_repository.go index dd995e2..d5fbb4d 100644 --- a/internal/repository/identity_repository.go +++ b/internal/repository/identity_repository.go @@ -70,7 +70,7 @@ func (r *identityRepo) GetByProviderUID(ctx context.Context, provider, providerU func (r *identityRepo) ListByUserID(ctx context.Context, userID int64) ([]*domain.UserIdentity, error) { query, args, err := dialect.From("user_identities"). Select("id", "user_id", "provider", "provider_user_id", "email", "created_at"). - Where(goqu.Ex{"user_id": userID}). + Where(goqu.Ex{colUserID: userID}). ToSQL() if err != nil { diff --git a/internal/repository/node_repository.go b/internal/repository/node_repository.go index 73f0a97..d9132f3 100644 --- a/internal/repository/node_repository.go +++ b/internal/repository/node_repository.go @@ -31,7 +31,7 @@ func (r *nodeRepo) GetByPath(ctx context.Context, userID int64, path string) (*d path = normalizePath(path) query, args, err := dialect.From("nodes"). Select("id", "user_id", "path", "parent_path", "name", "is_dir", "size", "etag", "file_id", "mtime", "content_type", "created_at", "updated_at"). - Where(goqu.Ex{"user_id": userID, "path": path}). + Where(goqu.Ex{colUserID: userID, colPath: path}). Limit(1). ToSQL() @@ -59,7 +59,7 @@ func (r *nodeRepo) ListByParentPath(ctx context.Context, userID int64, parentPat parentPath = normalizePath(parentPath) query, args, err := dialect.From("nodes"). Select("id", "user_id", "path", "parent_path", "name", "is_dir", "size", "etag", "file_id", "mtime", "content_type", "created_at", "updated_at"). - Where(goqu.Ex{"user_id": userID, "parent_path": parentPath}). + Where(goqu.Ex{colUserID: userID, "parent_path": parentPath}). Order(goqu.I("is_dir").Desc(), goqu.I("name").Asc()). ToSQL() @@ -144,9 +144,9 @@ func (r *nodeRepo) Update(ctx context.Context, node *domain.Node) error { "mtime": node.MTime, "content_type": node.ContentType, "is_dir": isDirInt, - "updated_at": time.Now(), + colUpdatedAt: time.Now(), }). - Where(goqu.Ex{"user_id": node.UserID, "path": node.Path}). + Where(goqu.Ex{colUserID: node.UserID, colPath: node.Path}). ToSQL() if err != nil { @@ -164,7 +164,7 @@ func (r *nodeRepo) Update(ctx context.Context, node *domain.Node) error { func (r *nodeRepo) Delete(ctx context.Context, userID int64, path string) error { path = normalizePath(path) query, args, err := dialect.Delete("nodes"). - Where(goqu.Ex{"user_id": userID, "path": path}). + Where(goqu.Ex{colUserID: userID, colPath: path}). ToSQL() if err != nil { @@ -186,8 +186,8 @@ func (r *nodeRepo) DeletePrefix(ctx context.Context, userID int64, pathPrefix st escapedPrefix := escapeSQLLike(pathPrefix) query, args, err := dialect.Delete("nodes"). - Where(goqu.Ex{"user_id": userID}, goqu.Or( - goqu.Ex{"path": strings.TrimSuffix(pathPrefix, "/")}, + Where(goqu.Ex{colUserID: userID}, goqu.Or( + goqu.Ex{colPath: strings.TrimSuffix(pathPrefix, "/")}, goqu.L("path LIKE ?", escapedPrefix+"%"), )). ToSQL() @@ -215,12 +215,12 @@ func (r *nodeRepo) MovePrefix(ctx context.Context, userID int64, oldPrefix, newP // 1. Update the moved root node itself updateRootQuery, rootArgs, err := dialect.Update("nodes"). Set(goqu.Record{ - "path": newPrefix, + colPath: newPrefix, "parent_path": targetParent, "name": targetName, - "updated_at": now, + colUpdatedAt: now, }). - Where(goqu.Ex{"user_id": userID, "path": oldPrefix}). + Where(goqu.Ex{colUserID: userID, colPath: oldPrefix}). ToSQL() if err != nil { return fmt.Errorf("failed to build SQL for MovePrefix root update: %w", err) @@ -301,7 +301,7 @@ func (r *nodeRepo) ensureParentDirectoryExistsWithDepth(ctx context.Context, use return nil } -func (r *nodeRepo) TouchAncestors(ctx context.Context, userID int64, paths ...string) error { +func collectUniqueAncestors(paths []string) []string { seen := make(map[string]struct{}) var ancestors []string @@ -313,7 +313,29 @@ func (r *nodeRepo) TouchAncestors(ctx context.Context, userID int64, paths ...st } } } + return ancestors +} + +func (r *nodeRepo) calculateAncestorSize(ctx context.Context, userID int64, ancestorPath string) (int64, error) { + var dirSize int64 + if ancestorPath == "/" { + const sizeQuery = `SELECT COALESCE(SUM(size), 0) FROM nodes WHERE user_id = ? AND is_dir = 0` + if err := r.db.QueryRowContext(ctx, sizeQuery, userID).Scan(&dirSize); err != nil { + return 0, fmt.Errorf("failed to calculate size for root ancestor: %w", err) + } + return dirSize, nil + } + + const sizeQuery = `SELECT COALESCE(SUM(size), 0) FROM nodes WHERE user_id = ? AND is_dir = 0 AND path LIKE ?` + likePattern := escapeSQLLike(ancestorPath) + "/%" + if err := r.db.QueryRowContext(ctx, sizeQuery, userID, likePattern).Scan(&dirSize); err != nil { + return 0, fmt.Errorf("failed to calculate size for ancestor %s: %w", ancestorPath, err) + } + return dirSize, nil +} +func (r *nodeRepo) TouchAncestors(ctx context.Context, userID int64, paths ...string) error { + ancestors := collectUniqueAncestors(paths) if len(ancestors) == 0 { return nil } @@ -324,18 +346,9 @@ func (r *nodeRepo) TouchAncestors(ctx context.Context, userID int64, paths ...st _, _ = rand.Read(b) newETag := hex.EncodeToString(b) - var dirSize int64 - if a == "/" { - const sizeQuery = `SELECT COALESCE(SUM(size), 0) FROM nodes WHERE user_id = ? AND is_dir = 0` - if err := r.db.QueryRowContext(ctx, sizeQuery, userID).Scan(&dirSize); err != nil { - return fmt.Errorf("failed to calculate size for root ancestor: %w", err) - } - } else { - const sizeQuery = `SELECT COALESCE(SUM(size), 0) FROM nodes WHERE user_id = ? AND is_dir = 0 AND path LIKE ?` - likePattern := escapeSQLLike(a) + "/%" - if err := r.db.QueryRowContext(ctx, sizeQuery, userID, likePattern).Scan(&dirSize); err != nil { - return fmt.Errorf("failed to calculate size for ancestor %s: %w", a, err) - } + dirSize, err := r.calculateAncestorSize(ctx, userID, a) + if err != nil { + return err } query, args, err := dialect.Update("nodes"). @@ -343,11 +356,11 @@ func (r *nodeRepo) TouchAncestors(ctx context.Context, userID int64, paths ...st "etag": newETag, "size": dirSize, "mtime": now, - "updated_at": now, + colUpdatedAt: now, }). Where(goqu.Ex{ - "user_id": userID, - "path": a, + colUserID: userID, + colPath: a, "is_dir": 1, }). ToSQL() diff --git a/internal/repository/oauth_repository.go b/internal/repository/oauth_repository.go index 84b7112..c1fa624 100644 --- a/internal/repository/oauth_repository.go +++ b/internal/repository/oauth_repository.go @@ -131,15 +131,15 @@ func (r *oauthRepo) CreateToken(ctx context.Context, token *domain.OAuthToken) e return nil } -func (r *oauthRepo) GetTokenByAccessToken(ctx context.Context, accessToken string) (*domain.OAuthToken, error) { +func (r *oauthRepo) getTokenWhere(ctx context.Context, where goqu.Ex) (*domain.OAuthToken, error) { query, args, err := dialect.From("oauth_tokens"). Select("id", "client_id", "user_id", "access_token", "refresh_token", "access_token_expires_at", "refresh_token_expires_at", "created_at"). - Where(goqu.Ex{"access_token": accessToken}). + Where(where). Limit(1). ToSQL() if err != nil { - return nil, fmt.Errorf("failed to build SQL for GetTokenByAccessToken: %w", err) + return nil, fmt.Errorf("failed to build SQL for oauth token query: %w", err) } t := &domain.OAuthToken{} @@ -155,58 +155,34 @@ func (r *oauthRepo) GetTokenByAccessToken(ctx context.Context, accessToken strin return t, nil } -func (r *oauthRepo) GetTokenByRefreshToken(ctx context.Context, refreshToken string) (*domain.OAuthToken, error) { - query, args, err := dialect.From("oauth_tokens"). - Select("id", "client_id", "user_id", "access_token", "refresh_token", "access_token_expires_at", "refresh_token_expires_at", "created_at"). - Where(goqu.Ex{"refresh_token": refreshToken}). - Limit(1). - ToSQL() - - if err != nil { - return nil, fmt.Errorf("failed to build SQL for GetTokenByRefreshToken: %w", err) - } - - t := &domain.OAuthToken{} - row := r.db.QueryRowContext(ctx, query, args...) - err = row.Scan(&t.ID, &t.ClientID, &t.UserID, &t.AccessToken, &t.RefreshToken, &t.AccessTokenExpiresAt, &t.RefreshTokenExpiresAt, &t.CreatedAt) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - return nil, fmt.Errorf("failed to query oauth token: %w", err) - } - - return t, nil -} - -func (r *oauthRepo) DeleteTokenByAccessToken(ctx context.Context, accessToken string) error { +func (r *oauthRepo) deleteTokensWhere(ctx context.Context, where goqu.Ex) error { query, args, err := dialect.Delete("oauth_tokens"). - Where(goqu.Ex{"access_token": accessToken}). + Where(where). ToSQL() if err != nil { - return fmt.Errorf("failed to build SQL for DeleteTokenByAccessToken: %w", err) + return fmt.Errorf("failed to build SQL for delete oauth tokens: %w", err) } if _, err := r.db.ExecContext(ctx, query, args...); err != nil { - return fmt.Errorf("failed to delete oauth token: %w", err) + return fmt.Errorf("failed to delete oauth tokens: %w", err) } return nil } -func (r *oauthRepo) DeleteTokensByUserID(ctx context.Context, userID int64) error { - query, args, err := dialect.Delete("oauth_tokens"). - Where(goqu.Ex{"user_id": userID}). - ToSQL() +func (r *oauthRepo) GetTokenByAccessToken(ctx context.Context, accessToken string) (*domain.OAuthToken, error) { + return r.getTokenWhere(ctx, goqu.Ex{"access_token": accessToken}) +} - if err != nil { - return fmt.Errorf("failed to build SQL for DeleteTokensByUserID: %w", err) - } +func (r *oauthRepo) GetTokenByRefreshToken(ctx context.Context, refreshToken string) (*domain.OAuthToken, error) { + return r.getTokenWhere(ctx, goqu.Ex{"refresh_token": refreshToken}) +} - if _, err := r.db.ExecContext(ctx, query, args...); err != nil { - return fmt.Errorf("failed to delete oauth tokens: %w", err) - } +func (r *oauthRepo) DeleteTokenByAccessToken(ctx context.Context, accessToken string) error { + return r.deleteTokensWhere(ctx, goqu.Ex{"access_token": accessToken}) +} - return nil +func (r *oauthRepo) DeleteTokensByUserID(ctx context.Context, userID int64) error { + return r.deleteTokensWhere(ctx, goqu.Ex{colUserID: userID}) } diff --git a/internal/repository/repository.go b/internal/repository/repository.go index ca9230c..072c938 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -13,6 +13,13 @@ import ( // dialect is the shared MySQL goqu dialect builder for all repositories. var dialect = goqu.Dialect("mysql") +const ( + colUserID = "user_id" + colPath = "path" + colUpdatedAt = "updated_at" + colToken = "token" +) + // Repositories holds all domain repositories configured against a common DBTX. type Repositories struct { Users UserRepository diff --git a/internal/repository/session_repository.go b/internal/repository/session_repository.go index f88ea99..9d68595 100644 --- a/internal/repository/session_repository.go +++ b/internal/repository/session_repository.go @@ -42,7 +42,7 @@ func (r *sessionRepo) Create(ctx context.Context, session *domain.Session) error func (r *sessionRepo) GetByToken(ctx context.Context, token string) (*domain.Session, error) { query, args, err := dialect.From("sessions"). Select("token", "user_id", "expires_at", "created_at"). - Where(goqu.Ex{"token": token}). + Where(goqu.Ex{colToken: token}). Limit(1). ToSQL() @@ -63,13 +63,13 @@ func (r *sessionRepo) GetByToken(ctx context.Context, token string) (*domain.Ses return s, nil } -func (r *sessionRepo) DeleteByToken(ctx context.Context, token string) error { +func (r *sessionRepo) deleteSessionsWhere(ctx context.Context, where goqu.Ex) error { query, args, err := dialect.Delete("sessions"). - Where(goqu.Ex{"token": token}). + Where(where). ToSQL() if err != nil { - return fmt.Errorf("failed to build SQL for DeleteByToken: %w", err) + return fmt.Errorf("failed to build SQL for delete session: %w", err) } if _, err := r.db.ExecContext(ctx, query, args...); err != nil { @@ -79,20 +79,12 @@ func (r *sessionRepo) DeleteByToken(ctx context.Context, token string) error { return nil } -func (r *sessionRepo) DeleteByUserID(ctx context.Context, userID int64) error { - query, args, err := dialect.Delete("sessions"). - Where(goqu.Ex{"user_id": userID}). - ToSQL() - - if err != nil { - return fmt.Errorf("failed to build SQL for DeleteByUserID: %w", err) - } - - if _, err := r.db.ExecContext(ctx, query, args...); err != nil { - return fmt.Errorf("failed to delete sessions: %w", err) - } +func (r *sessionRepo) DeleteByToken(ctx context.Context, token string) error { + return r.deleteSessionsWhere(ctx, goqu.Ex{colToken: token}) +} - return nil +func (r *sessionRepo) DeleteByUserID(ctx context.Context, userID int64) error { + return r.deleteSessionsWhere(ctx, goqu.Ex{colUserID: userID}) } func (r *sessionRepo) DeleteExpired(ctx context.Context) (int64, error) { diff --git a/internal/repository/share_repository.go b/internal/repository/share_repository.go index dbb681f..99dff4f 100644 --- a/internal/repository/share_repository.go +++ b/internal/repository/share_repository.go @@ -46,7 +46,7 @@ func (r *shareRepo) Create(ctx context.Context, share *domain.Share) error { func (r *shareRepo) GetByToken(ctx context.Context, token string) (*domain.Share, error) { query, args, err := dialect.From("shares"). Select("id", "token", "user_id", "path", "is_dir", "expires_at", "created_at"). - Where(goqu.Ex{"token": token}). + Where(goqu.Ex{colToken: token}). Limit(1). ToSQL() @@ -70,7 +70,7 @@ func (r *shareRepo) GetByToken(ctx context.Context, token string) (*domain.Share func (r *shareRepo) GetByPath(ctx context.Context, userID int64, path string) (*domain.Share, error) { query, args, err := dialect.From("shares"). Select("id", "token", "user_id", "path", "is_dir", "expires_at", "created_at"). - Where(goqu.Ex{"user_id": userID, "path": path}). + Where(goqu.Ex{colUserID: userID, colPath: path}). Limit(1). ToSQL() @@ -94,7 +94,7 @@ func (r *shareRepo) GetByPath(ctx context.Context, userID int64, path string) (* func (r *shareRepo) ListByUserID(ctx context.Context, userID int64) ([]*domain.Share, error) { query, args, err := dialect.From("shares"). Select("id", "token", "user_id", "path", "is_dir", "expires_at", "created_at"). - Where(goqu.Ex{"user_id": userID}). + Where(goqu.Ex{colUserID: userID}). Order(goqu.I("created_at").Desc()). ToSQL() @@ -125,7 +125,7 @@ func (r *shareRepo) ListByUserID(ctx context.Context, userID int64) ([]*domain.S func (r *shareRepo) DeleteByToken(ctx context.Context, token string, userID int64) error { query, args, err := dialect.Delete("shares"). - Where(goqu.Ex{"token": token, "user_id": userID}). + Where(goqu.Ex{colToken: token, colUserID: userID}). ToSQL() if err != nil { diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index a554dea..3fdbd79 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -22,15 +22,15 @@ func NewUserRepository(d db.DBTX) UserRepository { return &userRepo{db: d} } -func (r *userRepo) GetByUsername(ctx context.Context, username string) (*domain.User, error) { +func (r *userRepo) getUserWhere(ctx context.Context, where goqu.Ex) (*domain.User, error) { query, args, err := dialect.From("users"). Select("id", "username", "password_hash", "email", "created_at", "updated_at"). - Where(goqu.Ex{"username": username}). + Where(where). Limit(1). ToSQL() if err != nil { - return nil, fmt.Errorf("failed to build SQL for GetByUsername: %w", err) + return nil, fmt.Errorf("failed to build SQL for query user: %w", err) } user := &domain.User{} @@ -46,55 +46,19 @@ func (r *userRepo) GetByUsername(ctx context.Context, username string) (*domain. return user, nil } +func (r *userRepo) GetByUsername(ctx context.Context, username string) (*domain.User, error) { + return r.getUserWhere(ctx, goqu.Ex{"username": username}) +} + func (r *userRepo) GetByEmail(ctx context.Context, email string) (*domain.User, error) { if email == "" { return nil, nil } - query, args, err := dialect.From("users"). - Select("id", "username", "password_hash", "email", "created_at", "updated_at"). - Where(goqu.Ex{"email": email}). - Limit(1). - ToSQL() - - if err != nil { - return nil, fmt.Errorf("failed to build SQL for GetByEmail: %w", err) - } - - user := &domain.User{} - row := r.db.QueryRowContext(ctx, query, args...) - err = row.Scan(&user.ID, &user.Username, &user.PasswordHash, &user.Email, &user.CreatedAt, &user.UpdatedAt) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - return nil, fmt.Errorf("failed to query user by email: %w", err) - } - - return user, nil + return r.getUserWhere(ctx, goqu.Ex{"email": email}) } func (r *userRepo) GetByID(ctx context.Context, id int64) (*domain.User, error) { - query, args, err := dialect.From("users"). - Select("id", "username", "password_hash", "email", "created_at", "updated_at"). - Where(goqu.Ex{"id": id}). - Limit(1). - ToSQL() - - if err != nil { - return nil, fmt.Errorf("failed to build SQL for GetByID: %w", err) - } - - user := &domain.User{} - row := r.db.QueryRowContext(ctx, query, args...) - err = row.Scan(&user.ID, &user.Username, &user.PasswordHash, &user.Email, &user.CreatedAt, &user.UpdatedAt) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - return nil, fmt.Errorf("failed to query user: %w", err) - } - - return user, nil + return r.getUserWhere(ctx, goqu.Ex{"id": id}) } func (r *userRepo) Create(ctx context.Context, user *domain.User) error { @@ -126,7 +90,7 @@ func (r *userRepo) Update(ctx context.Context, user *domain.User) error { Set(goqu.Record{ "password_hash": user.PasswordHash, "email": user.Email, - "updated_at": user.UpdatedAt, + colUpdatedAt: user.UpdatedAt, }). Where(goqu.Ex{"id": user.ID}). ToSQL() diff --git a/internal/storage/storage.go b/internal/storage/storage.go index d5f3210..6fbf633 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -1,6 +1,7 @@ package storage import ( + "errors" "fmt" "io" "os" @@ -27,7 +28,7 @@ func NewLocalStorage(baseDir string) (Storage, error) { if err != nil { return nil, fmt.Errorf("invalid storage base dir: %w", err) } - if err := os.MkdirAll(abs, 0755); err != nil { + if err := os.MkdirAll(abs, 0o755); err != nil { return nil, fmt.Errorf("failed to create base storage directory: %w", err) } return &localStorage{baseDir: abs}, nil @@ -65,19 +66,25 @@ func (s *localStorage) resolvePath(username, relPath string) (string, error) { return fullPath, nil } -func checkSymlinkEscape(userDir, fullPath string) error { +func resolveUserDirSymlinks(userDir string) (string, error) { realUserDir, err := filepath.EvalSymlinks(userDir) + if err == nil { + return realUserDir, nil + } + if !os.IsNotExist(err) { + return "", fmt.Errorf("failed to evaluate user directory symlinks: %w", err) + } + realBaseDir, bErr := filepath.EvalSymlinks(filepath.Dir(userDir)) + if bErr == nil { + return filepath.Join(realBaseDir, filepath.Base(userDir)), nil + } + return userDir, nil +} + +func checkSymlinkEscape(userDir, fullPath string) error { + realUserDir, err := resolveUserDirSymlinks(userDir) if err != nil { - if os.IsNotExist(err) { - realBaseDir, bErr := filepath.EvalSymlinks(filepath.Dir(userDir)) - if bErr == nil { - realUserDir = filepath.Join(realBaseDir, filepath.Base(userDir)) - } else { - realUserDir = userDir - } - } else { - return fmt.Errorf("failed to evaluate user directory symlinks: %w", err) - } + return err } realUserDirSep := realUserDir + string(filepath.Separator) @@ -90,7 +97,7 @@ func checkSymlinkEscape(userDir, fullPath string) error { return fmt.Errorf("symlink resolution failed: %w", evalErr) } if realCurr != realUserDir && !strings.HasPrefix(realCurr, realUserDirSep) { - return fmt.Errorf("symlink traversal attempt detected: target resolves outside user storage root") + return errors.New("symlink traversal attempt detected: target resolves outside user storage root") } break } @@ -110,7 +117,7 @@ func (s *localStorage) Write(username, relPath string, r io.Reader) (int64, erro } dir := filepath.Dir(fullPath) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { return 0, fmt.Errorf("failed to create directory structure: %w", err) } @@ -174,7 +181,7 @@ func (s *localStorage) Mkdir(username, relPath string) error { if err != nil { return err } - return os.MkdirAll(fullPath, 0755) + return os.MkdirAll(fullPath, 0o755) } func (s *localStorage) Move(username, oldRelPath, newRelPath string) error { @@ -188,7 +195,7 @@ func (s *localStorage) Move(username, oldRelPath, newRelPath string) error { } dir := filepath.Dir(newPath) - if err := os.MkdirAll(dir, 0755); err != nil { + if err := os.MkdirAll(dir, 0o755); err != nil { return err } diff --git a/internal/webdav/handler.go b/internal/webdav/handler.go index 8e471ad..e0518b3 100644 --- a/internal/webdav/handler.go +++ b/internal/webdav/handler.go @@ -1,6 +1,7 @@ package webdav import ( + "context" "crypto/md5" "errors" "fmt" @@ -32,11 +33,11 @@ type WebDAVHandler struct { wwwAuthHeader string } -func NewWebDAVHandler(transactor repository.Transactor, nodeRepo repository.NodeRepository, storage storage.Storage, logger *slog.Logger) *WebDAVHandler { +func NewWebDAVHandler(transactor repository.Transactor, nodeRepo repository.NodeRepository, blobStore storage.Storage, logger *slog.Logger) *WebDAVHandler { return &WebDAVHandler{ transactor: transactor, nodeRepo: nodeRepo, - storage: storage, + storage: blobStore, logger: logger, maxUploadSize: config.DefaultMaxUploadSize, } @@ -125,9 +126,9 @@ func (h *WebDAVHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case "MKCOL": h.handleMkcol(rec, r, user, relPath) case "MOVE": - h.handleMove(rec, r, user, basePath, relPath) + h.handleMove(rec, r, user, relPath) case "COPY": - h.handleCopy(rec, r, user, basePath, relPath) + h.handleCopy(rec, r, user, relPath) case "PROPPATCH": h.handleProppatch(rec, r, user, basePath, relPath) default: @@ -135,7 +136,7 @@ func (h *WebDAVHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -func (h *WebDAVHandler) handleOptions(w http.ResponseWriter, r *http.Request) { +func (*WebDAVHandler) handleOptions(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) } @@ -219,8 +220,8 @@ func (h *WebDAVHandler) handleGet(w http.ResponseWriter, r *http.Request, user * } defer rc.Close() - w.Header().Set("ETag", fmt.Sprintf(`"%s"`, node.ETag)) - w.Header().Set("OC-ETag", fmt.Sprintf(`"%s"`, node.ETag)) + w.Header().Set("ETag", fmt.Sprintf("%q", node.ETag)) + w.Header().Set("OC-ETag", fmt.Sprintf("%q", node.ETag)) w.Header().Set("OC-FileId", node.FileID) if node.ContentType != "" { w.Header().Set("Content-Type", node.ContentType) @@ -243,20 +244,16 @@ func (h *WebDAVHandler) handleGet(w http.ResponseWriter, r *http.Request, user * http.ServeContent(w, r, path.Base(node.Path), node.MTime, rc) } -func (h *WebDAVHandler) handlePut(w http.ResponseWriter, r *http.Request, user *domain.User, relPath string) { - // Parse optional ownCloud client timestamp header - mtime := time.Now() +func parsePutMTime(r *http.Request) time.Time { if ocMTime := r.Header.Get("X-OC-MTime"); ocMTime != "" { if sec, err := strconv.ParseInt(ocMTime, 10, 64); err == nil { - mtime = time.Unix(sec, 0) + return time.Unix(sec, 0) } } + return time.Now() +} - if err := pathutil.ValidatePathLength(relPath); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - +func (h *WebDAVHandler) savePutBlob(w http.ResponseWriter, r *http.Request, user *domain.User, relPath string) (int64, error) { maxSize := h.maxUploadSize if maxSize <= 0 { maxSize = 10 << 30 @@ -271,7 +268,7 @@ func (h *WebDAVHandler) handlePut(w http.ResponseWriter, r *http.Request, user * fmt.Sprintf("User %q upload failed: payload exceeds maximum allowed size of %s", user.Username, pathutil.FormatBytes(maxSize)), ) http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge) - return + return 0, err } h.logger.Error("PUT storage write failed", slog.String("path", relPath), slog.Any("error", err)) audit.RecordFailure(r.Context(), "file_upload", relPath, @@ -279,24 +276,21 @@ func (h *WebDAVHandler) handlePut(w http.ResponseWriter, r *http.Request, user * slog.Any("error", err), ) http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - - etag := fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprintf("%s-%d-%d", relPath, size, mtime.UnixNano())))) - contentType := r.Header.Get("Content-Type") - if contentType == "" { - contentType = "application/octet-stream" + return 0, err } + return size, nil +} +func (h *WebDAVHandler) savePutNode(ctx context.Context, user *domain.User, relPath, contentType, etag string, size int64, mtime time.Time) (bool, error) { parentPath, name := pathutil.SplitPath(relPath) isNew := false - err = h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error { - if err := tx.Nodes.EnsureParentDirectoryExists(r.Context(), user.ID, parentPath); err != nil { + err := h.transactor.WithTx(ctx, func(tx *repository.Repositories) error { + if err := tx.Nodes.EnsureParentDirectoryExists(ctx, user.ID, parentPath); err != nil { return fmt.Errorf("failed to ensure parent directory exists: %w", err) } - existingNode, err := tx.Nodes.GetByPath(r.Context(), user.ID, relPath) + existingNode, err := tx.Nodes.GetByPath(ctx, user.ID, relPath) if err != nil { return err } @@ -316,57 +310,83 @@ func (h *WebDAVHandler) handlePut(w http.ResponseWriter, r *http.Request, user * MTime: mtime, ContentType: contentType, } - if err := tx.Nodes.Create(r.Context(), node); err != nil { + if err := tx.Nodes.Create(ctx, node); err != nil { return err } - return tx.Nodes.TouchAncestors(r.Context(), user.ID, relPath) + return tx.Nodes.TouchAncestors(ctx, user.ID, relPath) } existingNode.Size = size existingNode.ETag = etag existingNode.MTime = mtime existingNode.ContentType = contentType - if err := tx.Nodes.Update(r.Context(), existingNode); err != nil { + if err := tx.Nodes.Update(ctx, existingNode); err != nil { return err } - return tx.Nodes.TouchAncestors(r.Context(), user.ID, relPath) + return tx.Nodes.TouchAncestors(ctx, user.ID, relPath) }) + return isNew, err +} - if err != nil { - if isNew { - if delErr := h.storage.Delete(user.Username, relPath); delErr != nil { - h.logger.Error("Failed to clean up storage after DB failure during PUT", slog.String("path", relPath), slog.Any("error", delErr)) - } - } - h.logger.Error("PUT database transaction failed", slog.Any("error", err)) - audit.RecordFailure(r.Context(), "file_upload", relPath, - fmt.Sprintf("User %q failed to save metadata for %q: database error", user.Username, relPath), - slog.Any("error", err), - ) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - +func (*WebDAVHandler) recordPutAudit(ctx context.Context, user *domain.User, relPath, contentType, etag string, size int64, isNew bool) { metrics.BlobBytesUploaded.WithLabelValues("webdav").Add(float64(size)) if isNew { - audit.Record(r.Context(), "file_create", relPath, + audit.Record(ctx, "file_create", relPath, fmt.Sprintf("User %q uploaded new file %q (%s, %s)", user.Username, relPath, contentType, pathutil.FormatBytes(size)), slog.Int64("size_bytes", size), slog.String("content_type", contentType), slog.String("etag", etag), ) } else { - audit.Record(r.Context(), "file_update", relPath, + audit.Record(ctx, "file_update", relPath, fmt.Sprintf("User %q updated existing file %q (%s, %s)", user.Username, relPath, contentType, pathutil.FormatBytes(size)), slog.Int64("size_bytes", size), slog.String("content_type", contentType), slog.String("etag", etag), ) } +} + +func (h *WebDAVHandler) handlePut(w http.ResponseWriter, r *http.Request, user *domain.User, relPath string) { + mtime := parsePutMTime(r) + + if err := pathutil.ValidatePathLength(relPath); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + size, err := h.savePutBlob(w, r, user, relPath) + if err != nil { + return + } + + etag := fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprintf("%s-%d-%d", relPath, size, mtime.UnixNano())))) + contentType := r.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } - w.Header().Set("ETag", fmt.Sprintf(`"%s"`, etag)) - w.Header().Set("OC-ETag", fmt.Sprintf(`"%s"`, etag)) + isNew, err := h.savePutNode(r.Context(), user, relPath, contentType, etag, size, mtime) + if err != nil { + if isNew { + if delErr := h.storage.Delete(user.Username, relPath); delErr != nil { + h.logger.Error("Failed to clean up storage after DB failure during PUT", slog.String("path", relPath), slog.Any("error", delErr)) + } + } + h.logger.Error("PUT database transaction failed", slog.Any("error", err)) + audit.RecordFailure(r.Context(), "file_upload", relPath, + fmt.Sprintf("User %q failed to save metadata for %q: database error", user.Username, relPath), + slog.Any("error", err), + ) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + h.recordPutAudit(r.Context(), user, relPath, contentType, etag, size, isNew) + + w.Header().Set("ETag", fmt.Sprintf("%q", etag)) + w.Header().Set("OC-ETag", fmt.Sprintf("%q", etag)) if isNew { w.WriteHeader(http.StatusCreated) } else { @@ -374,6 +394,21 @@ func (h *WebDAVHandler) handlePut(w http.ResponseWriter, r *http.Request, user * } } +func (h *WebDAVHandler) deleteNodeMetadata(ctx context.Context, userID int64, relPath string, isDir bool) error { + return h.transactor.WithTx(ctx, func(tx *repository.Repositories) error { + if isDir { + if err := tx.Nodes.DeletePrefix(ctx, userID, relPath); err != nil { + return err + } + } else { + if err := tx.Nodes.Delete(ctx, userID, relPath); err != nil { + return err + } + } + return tx.Nodes.TouchAncestors(ctx, userID, relPath) + }) +} + func (h *WebDAVHandler) handleDelete(w http.ResponseWriter, r *http.Request, user *domain.User, relPath string) { node, err := h.nodeRepo.GetByPath(r.Context(), user.ID, relPath) if err != nil || node == nil { @@ -384,26 +419,29 @@ func (h *WebDAVHandler) handleDelete(w http.ResponseWriter, r *http.Request, use return } + var childCount int if node.IsDir { - // Count children before deletion for the audit log. children, _ := h.nodeRepo.ListByParentPath(r.Context(), user.ID, relPath) - childCount := len(children) + childCount = len(children) + } - if err := h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error { - if err := tx.Nodes.DeletePrefix(r.Context(), user.ID, relPath); err != nil { - return err - } - return tx.Nodes.TouchAncestors(r.Context(), user.ID, relPath) - }); err != nil { - h.logger.Error("DELETE dir database transaction failed", slog.String("path", relPath), slog.Any("error", err)) - audit.RecordFailure(r.Context(), "dir_delete", relPath, - fmt.Sprintf("User %q failed to delete directory metadata for %q: database error", user.Username, relPath), - slog.Any("error", err), - ) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return + if err := h.deleteNodeMetadata(r.Context(), user.ID, relPath, node.IsDir); err != nil { + action := "file_delete" + entity := "file" + if node.IsDir { + action = "dir_delete" + entity = "directory" } + h.logger.Error(fmt.Sprintf("DELETE %s database transaction failed", entity), slog.String("path", relPath), slog.Any("error", err)) + audit.RecordFailure(r.Context(), action, relPath, + fmt.Sprintf("User %q failed to delete %s metadata for %q: database error", user.Username, entity, relPath), + slog.Any("error", err), + ) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + if node.IsDir { _ = h.storage.DeleteAll(user.Username, relPath) audit.Record(r.Context(), "dir_delete", relPath, @@ -411,21 +449,6 @@ func (h *WebDAVHandler) handleDelete(w http.ResponseWriter, r *http.Request, use slog.Int("child_count", childCount), ) } else { - if err := h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error { - if err := tx.Nodes.Delete(r.Context(), user.ID, relPath); err != nil { - return err - } - return tx.Nodes.TouchAncestors(r.Context(), user.ID, relPath) - }); err != nil { - h.logger.Error("DELETE file database transaction failed", slog.String("path", relPath), slog.Any("error", err)) - audit.RecordFailure(r.Context(), "file_delete", relPath, - fmt.Sprintf("User %q failed to delete file metadata for %q: database error", user.Username, relPath), - slog.Any("error", err), - ) - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return - } - _ = h.storage.Delete(user.Username, relPath) audit.Record(r.Context(), "file_delete", relPath, @@ -515,23 +538,31 @@ func (h *WebDAVHandler) handleMkcol(w http.ResponseWriter, r *http.Request, user w.WriteHeader(http.StatusCreated) } -func (h *WebDAVHandler) handleMove(w http.ResponseWriter, r *http.Request, user *domain.User, basePath, relPath string) { +func resolveDestinationHeader(w http.ResponseWriter, r *http.Request, username string) (string, bool) { destHeader := r.Header.Get("Destination") if destHeader == "" { http.Error(w, "Bad Request: missing Destination header", http.StatusBadRequest) - return + return "", false } destURL, err := url.Parse(destHeader) if err != nil { http.Error(w, "Bad Request: invalid Destination header", http.StatusBadRequest) - return + return "", false } - _, destRelPath := extractPaths(destURL.Path, user.Username) + _, destRelPath := extractPaths(destURL.Path, username) if err := pathutil.ValidatePathLength(destRelPath); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) + return "", false + } + return destRelPath, true +} + +func (h *WebDAVHandler) handleMove(w http.ResponseWriter, r *http.Request, user *domain.User, relPath string) { + destRelPath, ok := resolveDestinationHeader(w, r, user.Username) + if !ok { return } @@ -577,56 +608,7 @@ func (h *WebDAVHandler) handleMove(w http.ResponseWriter, r *http.Request, user w.WriteHeader(http.StatusCreated) } -func (h *WebDAVHandler) handleCopy(w http.ResponseWriter, r *http.Request, user *domain.User, basePath, relPath string) { - destHeader := r.Header.Get("Destination") - if destHeader == "" { - http.Error(w, "Bad Request: missing Destination header", http.StatusBadRequest) - return - } - - destURL, err := url.Parse(destHeader) - if err != nil { - http.Error(w, "Bad Request: invalid Destination header", http.StatusBadRequest) - return - } - - _, destRelPath := extractPaths(destURL.Path, user.Username) - - if err := pathutil.ValidatePathLength(destRelPath); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - sourceNode, err := h.nodeRepo.GetByPath(r.Context(), user.ID, relPath) - if err != nil || sourceNode == nil { - audit.RecordFailure(r.Context(), "file_copy", relPath, - fmt.Sprintf("User %q failed to copy %q to %q: source not found", user.Username, relPath, destRelPath), - slog.String("destination", destRelPath), - ) - http.Error(w, "Not Found", http.StatusNotFound) - return - } - - overwriteHeader := r.Header.Get("Overwrite") - if overwriteHeader == "" { - overwriteHeader = "T" // RFC 4918 default - } - - existingDest, _ := h.nodeRepo.GetByPath(r.Context(), user.ID, destRelPath) - if existingDest != nil && strings.EqualFold(overwriteHeader, "F") { - audit.RecordFailure(r.Context(), "file_copy", relPath, - fmt.Sprintf("User %q copy of %q to %q rejected: destination exists and Overwrite=F", user.Username, relPath, destRelPath), - slog.String("destination", destRelPath), - ) - http.Error(w, "Precondition Failed", http.StatusPreconditionFailed) - return - } - - if sourceNode.IsDir { - h.handleCopyDir(w, r, user, sourceNode, relPath, destRelPath, existingDest != nil) - return - } - +func (h *WebDAVHandler) copyFileBlob(w http.ResponseWriter, r *http.Request, user *domain.User, relPath, destRelPath string) (int64, error) { rc, err := h.storage.Read(user.Username, relPath) if err != nil { audit.RecordFailure(r.Context(), "file_copy", relPath, @@ -634,7 +616,7 @@ func (h *WebDAVHandler) handleCopy(w http.ResponseWriter, r *http.Request, user slog.String("destination", destRelPath), ) http.Error(w, "Not Found", http.StatusNotFound) - return + return 0, err } defer rc.Close() @@ -646,9 +628,18 @@ func (h *WebDAVHandler) handleCopy(w http.ResponseWriter, r *http.Request, user slog.Any("error", err), ) http.Error(w, "Internal Server Error", http.StatusInternalServerError) - return + return 0, err } + return size, nil +} +func (h *WebDAVHandler) persistCopiedFileNode( + ctx context.Context, + user *domain.User, + destRelPath, contentType string, + size int64, + existingDest *domain.Node, +) error { parentPath, name := pathutil.SplitPath(destRelPath) fileID := pathutil.GenerateFileID(user.ID) etag := fmt.Sprintf("%x", md5.Sum([]byte(destRelPath+fileID))) @@ -663,26 +654,69 @@ func (h *WebDAVHandler) handleCopy(w http.ResponseWriter, r *http.Request, user ETag: etag, FileID: fileID, MTime: time.Now(), - ContentType: sourceNode.ContentType, + ContentType: contentType, } - err = h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error { - if err := tx.Nodes.EnsureParentDirectoryExists(r.Context(), user.ID, parentPath); err != nil { + return h.transactor.WithTx(ctx, func(tx *repository.Repositories) error { + if err := tx.Nodes.EnsureParentDirectoryExists(ctx, user.ID, parentPath); err != nil { return fmt.Errorf("failed to ensure parent directory exists: %w", err) } if existingDest != nil { node.ID = existingDest.ID - if err := tx.Nodes.Update(r.Context(), node); err != nil { + if err := tx.Nodes.Update(ctx, node); err != nil { return err } - return tx.Nodes.TouchAncestors(r.Context(), user.ID, destRelPath) + return tx.Nodes.TouchAncestors(ctx, user.ID, destRelPath) } - if err := tx.Nodes.Create(r.Context(), node); err != nil { + if err := tx.Nodes.Create(ctx, node); err != nil { return err } - return tx.Nodes.TouchAncestors(r.Context(), user.ID, destRelPath) + return tx.Nodes.TouchAncestors(ctx, user.ID, destRelPath) }) +} + +func (h *WebDAVHandler) handleCopy(w http.ResponseWriter, r *http.Request, user *domain.User, relPath string) { + destRelPath, ok := resolveDestinationHeader(w, r, user.Username) + if !ok { + return + } + + sourceNode, err := h.nodeRepo.GetByPath(r.Context(), user.ID, relPath) + if err != nil || sourceNode == nil { + audit.RecordFailure(r.Context(), "file_copy", relPath, + fmt.Sprintf("User %q failed to copy %q to %q: source not found", user.Username, relPath, destRelPath), + slog.String("destination", destRelPath), + ) + http.Error(w, "Not Found", http.StatusNotFound) + return + } + + overwriteHeader := r.Header.Get("Overwrite") + if overwriteHeader == "" { + overwriteHeader = "T" // RFC 4918 default + } + + existingDest, _ := h.nodeRepo.GetByPath(r.Context(), user.ID, destRelPath) + if existingDest != nil && strings.EqualFold(overwriteHeader, "F") { + audit.RecordFailure(r.Context(), "file_copy", relPath, + fmt.Sprintf("User %q copy of %q to %q rejected: destination exists and Overwrite=F", user.Username, relPath, destRelPath), + slog.String("destination", destRelPath), + ) + http.Error(w, "Precondition Failed", http.StatusPreconditionFailed) + return + } + + if sourceNode.IsDir { + h.handleCopyDir(w, r, user, relPath, destRelPath, existingDest != nil) + return + } + + size, err := h.copyFileBlob(w, r, user, relPath, destRelPath) if err != nil { + return + } + + if err := h.persistCopiedFileNode(r.Context(), user, destRelPath, sourceNode.ContentType, size, existingDest); err != nil { if delErr := h.storage.Delete(user.Username, destRelPath); delErr != nil { h.logger.Error("Failed to clean up storage after DB failure during COPY", slog.String("path", destRelPath), slog.Any("error", delErr)) } @@ -708,7 +742,115 @@ func (h *WebDAVHandler) handleCopy(w http.ResponseWriter, r *http.Request, user } } -func (h *WebDAVHandler) handleCopyDir(w http.ResponseWriter, r *http.Request, user *domain.User, sourceNode *domain.Node, relPath, destRelPath string, isOverwrite bool) { +type dirCopyContext struct { + ctx context.Context + user *domain.User + h *WebDAVHandler + visited map[string]bool + nodesToCreate []*domain.Node +} + +func (c *dirCopyContext) copyChild(currDest string, child *domain.Node, d int) error { + childDest := path.Join(currDest, child.Name) + childParent, childName := pathutil.SplitPath(childDest) + childFileID := pathutil.GenerateFileID(c.user.ID) + childETag := fmt.Sprintf("%x", md5.Sum([]byte(childDest+childFileID))) + + if child.IsDir { + if err := c.h.storage.Mkdir(c.user.Username, childDest); err != nil { + return err + } + c.nodesToCreate = append(c.nodesToCreate, &domain.Node{ + UserID: c.user.ID, + Path: childDest, + ParentPath: childParent, + Name: childName, + IsDir: true, + Size: 0, + ETag: childETag, + FileID: childFileID, + MTime: time.Now(), + ContentType: "httpd/unix-directory", + }) + return c.walk(child.Path, childDest, d+1) + } + + rc, err := c.h.storage.Read(c.user.Username, child.Path) + if err != nil { + return err + } + cSize, err := c.h.storage.Write(c.user.Username, childDest, rc) + _ = rc.Close() + if err != nil { + return err + } + c.nodesToCreate = append(c.nodesToCreate, &domain.Node{ + UserID: c.user.ID, + Path: childDest, + ParentPath: childParent, + Name: childName, + IsDir: false, + Size: cSize, + ETag: childETag, + FileID: childFileID, + MTime: time.Now(), + ContentType: child.ContentType, + }) + return nil +} + +func (c *dirCopyContext) walk(currSrc, currDest string, d int) error { + if d > 64 { + return errors.New("copy directory recursion depth exceeded") + } + if c.visited[currSrc] { + return nil + } + c.visited[currSrc] = true + + children, err := c.h.nodeRepo.ListByParentPath(c.ctx, c.user.ID, currSrc) + if err != nil { + return err + } + for _, child := range children { + if err := c.copyChild(currDest, child, d); err != nil { + return err + } + } + return nil +} + +func (h *WebDAVHandler) copyDirDescendants(ctx context.Context, user *domain.User, srcRoot, destRoot string) ([]*domain.Node, error) { + c := &dirCopyContext{ + ctx: ctx, + user: user, + h: h, + visited: make(map[string]bool), + } + if err := c.walk(srcRoot, destRoot, 1); err != nil { + return nil, err + } + return c.nodesToCreate, nil +} + +func (h *WebDAVHandler) persistCopiedDirMetadata(ctx context.Context, user *domain.User, destRelPath, parentPath string, nodesToCreate []*domain.Node, isOverwrite bool) error { + return h.transactor.WithTx(ctx, func(tx *repository.Repositories) error { + if isOverwrite { + _ = tx.Nodes.DeletePrefix(ctx, user.ID, destRelPath) + } + if err := tx.Nodes.EnsureParentDirectoryExists(ctx, user.ID, parentPath); err != nil { + return fmt.Errorf("failed to ensure parent directory exists: %w", err) + } + for _, n := range nodesToCreate { + if err := tx.Nodes.Create(ctx, n); err != nil { + return err + } + } + return tx.Nodes.TouchAncestors(ctx, user.ID, destRelPath) + }) +} + +func (h *WebDAVHandler) handleCopyDir(w http.ResponseWriter, r *http.Request, user *domain.User, relPath, destRelPath string, isOverwrite bool) { depth := r.Header.Get("Depth") if depth == "" { depth = "infinity" @@ -741,81 +883,12 @@ func (h *WebDAVHandler) handleCopyDir(w http.ResponseWriter, r *http.Request, us ContentType: "httpd/unix-directory", } - var nodesToCreate []*domain.Node - nodesToCreate = append(nodesToCreate, destDirNode) + nodesToCreate := []*domain.Node{destDirNode} // Perform all filesystem I/O outside of the database transaction if depth != "0" { - visited := make(map[string]bool) - var copyDescendants func(currSrc, currDest string, d int) error - copyDescendants = func(currSrc, currDest string, d int) error { - if d > 64 { - return fmt.Errorf("copy directory recursion depth exceeded") - } - if visited[currSrc] { - return nil - } - visited[currSrc] = true - - children, err := h.nodeRepo.ListByParentPath(r.Context(), user.ID, currSrc) - if err != nil { - return err - } - for _, child := range children { - childDest := path.Join(currDest, child.Name) - childParent, childName := pathutil.SplitPath(childDest) - childFileID := pathutil.GenerateFileID(user.ID) - childETag := fmt.Sprintf("%x", md5.Sum([]byte(childDest+childFileID))) - - if child.IsDir { - if err := h.storage.Mkdir(user.Username, childDest); err != nil { - return err - } - cNode := &domain.Node{ - UserID: user.ID, - Path: childDest, - ParentPath: childParent, - Name: childName, - IsDir: true, - Size: 0, - ETag: childETag, - FileID: childFileID, - MTime: time.Now(), - ContentType: "httpd/unix-directory", - } - nodesToCreate = append(nodesToCreate, cNode) - if err := copyDescendants(child.Path, childDest, d+1); err != nil { - return err - } - } else { - rc, err := h.storage.Read(user.Username, child.Path) - if err != nil { - return err - } - cSize, err := h.storage.Write(user.Username, childDest, rc) - _ = rc.Close() - if err != nil { - return err - } - cNode := &domain.Node{ - UserID: user.ID, - Path: childDest, - ParentPath: childParent, - Name: childName, - IsDir: false, - Size: cSize, - ETag: childETag, - FileID: childFileID, - MTime: time.Now(), - ContentType: child.ContentType, - } - nodesToCreate = append(nodesToCreate, cNode) - } - } - return nil - } - - if err := copyDescendants(relPath, destRelPath, 1); err != nil { + descendants, err := h.copyDirDescendants(r.Context(), user, relPath, destRelPath) + if err != nil { _ = h.storage.DeleteAll(user.Username, destRelPath) h.logger.Error("COPY directory storage failed", slog.String("dest", destRelPath), slog.Any("error", err)) audit.RecordFailure(r.Context(), "dir_copy", relPath, @@ -824,25 +897,11 @@ func (h *WebDAVHandler) handleCopyDir(w http.ResponseWriter, r *http.Request, us http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } + nodesToCreate = append(nodesToCreate, descendants...) } // Fast atomic DB metadata persistence - err := h.transactor.WithTx(r.Context(), func(tx *repository.Repositories) error { - if isOverwrite { - _ = tx.Nodes.DeletePrefix(r.Context(), user.ID, destRelPath) - } - if err := tx.Nodes.EnsureParentDirectoryExists(r.Context(), user.ID, parentPath); err != nil { - return fmt.Errorf("failed to ensure parent directory exists: %w", err) - } - for _, n := range nodesToCreate { - if err := tx.Nodes.Create(r.Context(), n); err != nil { - return err - } - } - return tx.Nodes.TouchAncestors(r.Context(), user.ID, destRelPath) - }) - - if err != nil { + if err := h.persistCopiedDirMetadata(r.Context(), user, destRelPath, parentPath, nodesToCreate, isOverwrite); err != nil { _ = h.storage.DeleteAll(user.Username, destRelPath) h.logger.Error("COPY directory DB transaction failed", slog.String("dest", destRelPath), slog.Any("error", err)) audit.RecordFailure(r.Context(), "dir_copy", relPath, diff --git a/internal/webdav/xml.go b/internal/webdav/xml.go index 05da3ac..a471d49 100644 --- a/internal/webdav/xml.go +++ b/internal/webdav/xml.go @@ -66,7 +66,7 @@ func FormatMultiStatus(basePath string, nodes []*domain.Node) ([]byte, error) { // Encode each path segment individually to preserve slashes. href = basePath + encodePath(node.Path) } - if node.IsDir && len(href) > 0 && href[len(href)-1] != '/' { + if node.IsDir && !strings.HasSuffix(href, "/") { href += "/" } @@ -77,7 +77,7 @@ func FormatMultiStatus(basePath string, nodes []*domain.Node) ([]byte, error) { prop := Prop{ GetLastModified: node.MTime.UTC().Format(http.TimeFormat), - GetETag: fmt.Sprintf(`"%s"`, node.ETag), + GetETag: fmt.Sprintf("%q", node.ETag), OCFileID: node.FileID, OCID: node.FileID, OCPermissions: permissions,