Files
mygo/docs/decisions.md
T
ld b988b4b15e fix(file)!: stream uploads through staged storage
- fix: replace multipart form parsing with streaming multipart reads and
  apply request body limits when max_upload_size is configured.
- refactor: route uploads through staging paths before promotion to
  long-term data paths, keeping incomplete uploads out of durable
  storage records.
- test: cover oversized uploads, unlimited uploads, staged cleanup, and
  local storage promotion boundaries.
- docs: document the staged upload model and multipart parent_id query
  parameter.
2026-07-05 17:18:19 +08:00

6.8 KiB

Technical Decisions

2026-04-25: v0 Tech Stack & Architecture

Context: Project skeleton was created with only cobra CLI. We needed a concrete tech stack and package layout to begin implementation.

Decisions:

Area Choice Rationale
HTTP framework Gin Most widely adopted Go web framework, mature middleware ecosystem
ORM GORM SQLite-first dev, PostgreSQL option later; GORM abstracts dialect differences
Config management Viper YAML + env vars + CLI flags three-way merge, built for cobra integration
Database SQLite (v0) → PostgreSQL (future) SQLite zero setup for dev; repo interface isolates the switch
File storage Local disk (v0) → S3 (future) Backend interface (internal/storage) hides implementation
File identity UUID Distributed-friendly, no coordination needed; cost is negligible for file metadata
Token strategy JWT, refresh token stored in DB Enables server-side revocation (admin kick, logout-all-devices)
Pagination OFFSET/LIMIT Simple, sufficient for v0; migrate to cursor-based if needed
API response format Direct JSON success bodies + unified error body HTTP status codes carry request outcome; error body carries human-readable details

Architecture: Four-layer model — Handler (Gin) → Service (business logic) → Repository (GORM data access) + Storage (file I/O). Each layer depends only on interfaces of the layer below.

Consequences:

  • Handler layer has no business logic; Service layer is reusable across REST API, WebDAV, and future Nextcloud API.
  • Repository interfaces keep DB swappable; future PostgreSQL implementation only needs a new package.
  • Refresh token in DB adds a sessions table and a repository.SessionRepository interface.
  • UUID dependency: github.com/google/uuid to be added.
  • Gin middleware chain: default logger/recovery → cors → auth (route-group-scoped).

2026-04-27: Web API Foundation

Context: The project needed the first HTTP slice that can validate Gin wiring and provide a stable shape for future auth, file, and admin APIs.

Decisions:

Area Choice Guidance
API versioning All REST routes under /api/v1 Keep future REST handlers under the versioned group.
Initial public endpoint GET /api/v1/version Returns build metadata only; health/readiness endpoints need a separate security review.
Success responses Direct JSON resource bodies Use HTTP status codes as the request outcome signal.
Error responses {"error":{"message":"..."}} Add machine-readable error codes only when clients need stable branching behavior.
App composition internal/app.WebApp cmd/serve.go creates the app from config and build metadata, then passes it to router setup.
Router setup internal/server.NewRouter(*app.WebApp) Public routes (routes_public.go) and protected routes (routes_protected.go) split by auth boundary; WebApp serves as the unified dependency container.
Server lifecycle RunWithGracefulShutdown Preserve graceful shutdown while keeping command startup linear.
Default middleware gin.Default() Use default logger/recovery for the skeleton; add CORS/auth explicitly when their policies exist.

Consequences:

  • Version is build metadata from internal/app/version.go, not a config-file field.
  • app.WebApp is the place to add future services, repositories, storage, and app metadata incrementally.
  • Request ID middleware is not part of the current foundation; add it only with a logging/tracing/error-correlation design.

2026-04-29: Auth Refinements

Context: Auth layer had three structural weaknesses — handler duplication, indistinguishable token types, and fragile config duration parsing.

Decisions:

Decision Guidance
One handler per route group AuthHandler owns /auth/* (public); AccountHandler owns /account/* (protected). A route group maps 1:1 to a handler type.
JWT type claim Claims.Type distinguishes access from refresh tokens. Middleware and service enforce the correct type at their respective boundaries. ParseToken does no type check — it verifies cryptographic validity only.
Default JWT secret hardening The development placeholder jwt.secret is replaced with an ephemeral runtime secret during config loading. Production and multi-instance deployments must set a stable secret.
time.Duration in config structs Config fields representing durations use time.Duration directly. Viper's built-in StringToTimeDurationHookFunc handles string→Duration conversion at unmarshal time. No accessor methods, no runtime parsing. Invalid values fail at startup via Load().

Consequences:

  • Handlers are independently extensible (caching, rate limiting scoped per handler).
  • Refresh tokens cannot authenticate API requests; access tokens cannot be used to issue new token pairs.
  • The placeholder JWT secret is safe for local startup, but tokens signed with it are invalidated on restart.
  • New duration config fields require zero boilerplate — declare as time.Duration in the struct.

2026-07-05: Staged File Uploads

Context: Multipart uploads previously used ParseMultipartForm, which parses the request before service-level size checks and may spill oversized requests to temporary disk. The file service also wrote directly to the long-term storage path and then attempted compensating deletes on upload failure.

Decisions:

Decision Guidance
Stream multipart requests Handlers use Request.MultipartReader() instead of ParseMultipartForm/FormFile, so uploads are streamed into the service.
Optional upload limits storage.max_upload_size = 0 means unlimited. Positive values enable both HTTP body limiting and service-level file content limiting.
Staging before promotion Storage backends write upload bytes to a staging path first, then promote the object to the long-term data path only after validation succeeds.
Promote before DB create The service promotes the object before creating the active file record, preventing visible DB rows from pointing at missing objects. If DB creation fails after promotion, the service best-effort deletes the promoted object.
Upload parent location Multipart upload parent_id is passed as a query parameter, keeping the multipart body focused on the file stream.

Consequences:

  • Interrupted, malformed, and oversized uploads leave only staging objects, which are safe to clean by path prefix and age.
  • Local storage can promote with os.Rename; future S3 storage can implement promotion with copy/delete while keeping business visibility controlled by the DB row.
  • A DB failure after promotion can still leave a long-term orphan object, but it is not visible through the file API and can be cleaned independently.