diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..150e488 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,48 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +make build # Compile Go binary to bin/server +make run # Build Docker images and start all containers +make run-server # Build Docker images and start only the Go server +make run-db # Start only MongoDB container +make run-test # Run all Go tests +go test ./... # Run all tests (same as make run-test) +go test ./cmd/server/... -run TestHealth # Run a single test by name +``` + +The server listens on `:8080`. MongoDB runs on `27017`. The `MONGO_URI` env var configures the DB connection (default: `mongodb://localhost:27017/logjensticks`). + +## Architecture + +LogJensticks is a trucking job application management system. The stack is Go + MongoDB, containerized with Docker Compose. + +**Current state**: Early skeleton — only `/health` is implemented. The design doc (`DesignDoc`) is the authoritative reference for what needs to be built. + +### Code layout + +- `cmd/server/` — single Go package containing the HTTP server entry point and all handlers (no separate packages yet) +- `Dockerfile` — multi-stage build: `golang:1.21-alpine` builder → `alpine:latest` final image + +### Key design concepts (from DesignDoc) + +**Unregistered user flow**: First upload/submit issues a random `unreg_token` stored as a secure, HTTP-only cookie. All subsequent requests use this cookie. A token can only access applications it created. Registration upgrades the session and allows access to prior documents. + +**Anti-gaming rule**: Applicants must never see check results, OCR data, or the reason for rejection. Only `correction_feedback` (set by an approver) is shown when an application is returned. Approvers see everything. + +**Check pipeline**: On submission, primary checks run immediately in goroutines. Dependent checks launch when their parent check completes. Each check writes `"processing"` → `"pass"` / `"fail"` / `"human_review"` to its named field on the document. + +**Application statuses**: `draft` → `submitted` → `processing` → `approved` / `rejected` / `returned` / `human_review` + +**Roles**: Unregistered Trucker, Registered Trucker, Approver/Manager, Admin. Role-based access enforced on every protected endpoint. + +**API response envelope**: +```json +{"success": true, "data": {...}, "error": null} +{"success": false, "data": null, "error": {"code": "...", "message": "..."}} +``` + +See `DesignDoc` for the full endpoint list, data schema, OCR job format, and MongoDB index recommendations. diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..3d44c52 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,25 @@ +hillsidehaven.tplinkdns.com { + # Proxy API endpoints to the Go server + handle /login { + reverse_proxy server:8080 + } + handle /logout { + reverse_proxy server:8080 + } + handle /health { + reverse_proxy server:8080 + } + handle /me { + reverse_proxy server:8080 + } + handle /lanes* { + reverse_proxy server:8080 + } + + # Serve the Vue SPA — fall back to index.html for client-side routes + handle { + root * /srv/web + try_files {path} /index.html + file_server + } +} diff --git a/DesignDoc b/DesignDoc index 89024a7..4e60f83 100644 --- a/DesignDoc +++ b/DesignDoc @@ -6,13 +6,15 @@ LogJensticks is a trucking application management system that automates the proc ## Architecture ### Infrastructure -- **2 Docker Containers**: Go server + MongoDB database orchestrated with Docker Compose +- **3 Docker Containers**: Caddy (reverse proxy + static frontend) + Go server + MongoDB, orchestrated with Docker Compose - **Build System**: Makefile for building, running, and testing - - `make build` - Compiles code and builds Docker containers - - `make run` - Starts all containers - - `make run server` - Starts only the server - - `make run db` - Starts only MongoDB - - `make run test` - Runs unit tests + - `make build` - Compiles Go binary + - `make run` - Builds Docker images and starts all containers + - `make run-server` - Starts only the Go server + - `make run-db` - Starts only MongoDB + - `make run-caddy` - Starts only Caddy + - `make run-frontend` - Starts Vite dev server locally (no Docker) + - `make run-test` - Runs unit tests --- @@ -25,9 +27,9 @@ LogJensticks is a trucking application management system that automates the proc | POST | `/logout` | Authenticated | User logout | | GET | `/health` | Public | Health check endpoint | -> **Unregistered Flow**: Truckers may use the system without registering. Upon first upload/submit the server issues a random `unreg_token` stored in a **secure, HTTP‑only** cookie. "Secure" means the browser will only send it over HTTPS connections, preventing exposure on unencrypted networks. "HTTP‑only" means JavaScript running in the browser cannot read the cookie; this reduces the risk of token theft via XSS. All subsequent requests (save, submit, get, status) require that cookie; the token is a single-use key that only allows access to applications created with the same token. Tokens cannot access others' applications. Registration upgrades the session to a normal authenticated user and allows reuse of prior documents. +> **Unregistered Flow**: Carriers may use the system without registering. Upon first upload/submit the server issues a random `unreg_token` stored in a **secure, HTTP‑only** cookie. "Secure" means the browser will only send it over HTTPS connections, preventing exposure on unencrypted networks. "HTTP‑only" means JavaScript running in the browser cannot read the cookie; this reduces the risk of token theft via XSS. All subsequent requests (save, submit, get, status) require that cookie; the token is a single-use key that only allows access to applications created with the same token. Tokens cannot access others' applications. Registration upgrades the session to a normal authenticated user and allows reuse of prior documents. -### Application Management (Trucker) +### Application Management (Carrier) | Method | Endpoint | Access | Description | |--------|----------|--------|-------------| | POST | `/saveApplication` | Public or cookie | Save application as draft (auto-generates `app_id`). Sets/reads `anon_token` cookie when user is not registered. | @@ -35,14 +37,22 @@ LogJensticks is a trucking application management system that automates the proc | GET | `/getApplication/:applicationID` | Authenticated or cookie | Retrieve application details. Registered users may view their whole history; unregistered users may only view apps tied to their current `unreg_token`. | | GET | `/applicationStatus/:applicationID` | Authenticated or cookie | Check status and results with same access rules as above. | -### Application Review (Approver/Manager) -| Method | Endpoint | Access | Approvers Only | Description | +### Application Review (Broker) +| Method | Endpoint | Access | Brokers Only | Description | |--------|----------|--------|---|-------------| | GET | `/approvalQueue` | Yes | - | Get list of pending applications | | GET | `/getApplication/:id` | Yes | - | Retrieve application for review (shows all details including check results) | | POST | `/approve/:applicationID` | Yes | - | Approve application with optional internal comments | -| POST | `/reject/:applicationID` | Yes | - | Permanently reject application with internal reason (applicant will not see reason) | -| POST | `/return/:applicationID` | Yes | - | Return application to applicant for corrections with required feedback message | +| POST | `/reject/:applicationID` | Yes | - | Permanently reject application with internal reason (carrier will not see reason) | +| POST | `/return/:applicationID` | Yes | - | Return application to carrier for corrections with required feedback message | + +### Lane Management +| Method | Endpoint | Access | Description | +|--------|----------|--------|-------------| +| POST | `/lanes` | Broker | Create a new lane; returns the lane ID and shareable link | +| GET | `/lanes` | Broker | List all lanes created by the authenticated broker | +| GET | `/lanes/:laneID` | Public (via link) | View lane details | +| POST | `/lanes/:laneID/bid` | Authenticated Carrier | Place a bid on a lane; adds carrier's ID to `bidding_carriers` | ### User Management (Admin/Manager) | Method | Endpoint | Access | Description | @@ -63,16 +73,22 @@ LogJensticks is a trucking application management system that automates the proc ### Public Pages (HTML/CSS/Vue.js) - `/login` - User authentication form -### Trucker Pages (Authenticated or Unregistered) +### Carrier Pages (Authenticated or Unregistered) - `/apply` - Application submission form (multi-step form with file uploads). Works without login; unregistered users are issued a token cookie. Registered users can save and reuse documents. - `/applicationConfirmation` - Post-submission confirmation with application ID and notice about token cookie for unregistered users. - `/applicationStatus` - Track application progress and view check results. Unregistered users may only access apps created with current token. - `/register` - Optional sign-up page for users who want an account to retain history and reuse files. -### Approver/Manager Pages (Authenticated + Authorized) +### Broker Pages (Authenticated + Authorized) - `/approvalQueue` - List of pending applications for review - `/reviewApplication/:applicationID` - Detailed application review interface with approve/reject actions - `/dashboard` - Manager dashboard with application statistics +- `/lanes` - List of broker's lanes with bid counts +- `/lanes/create` - Form to create a new lane +- `/lanes/:laneID` - Lane detail view showing all bids + +### Carrier Pages (Lane Bidding) +- `/lanes/:laneID` - Public lane view; authenticated carriers can place a bid --- @@ -102,8 +118,8 @@ LogJensticks is a trucking application management system that automates the proc - `app_id` (UUID) - Application ID - `submitted_timestamp` (UTC timestamp) - When application was submitted - `status` (string) - Current status: "draft", "submitted", "processing", "approved", "rejected", "returned", "human_review" -- `approver_notes` (string, optional) - Internal notes from approver (not visible to applicant) -- `correction_feedback` (string, optional) - Feedback message sent to applicant when application is returned +- `broker_notes` (string, optional) - Internal notes from broker (not visible to carrier) +- `correction_feedback` (string, optional) - Feedback message sent to carrier when application is returned - `unreg_token` (string, optional) – Random token assigned to unregistered users; stored in cookie and used to authorize access to this application #### Other Metadata @@ -113,6 +129,35 @@ LogJensticks is a trucking application management system that automates the proc #### Check Result Fields (added during processing) - `[CheckName]` (string) - One field per check with value: "processing", "pass", "fail", or "human_review" +### Lane Document Fields + +- `_id` (UUID) - MongoDB auto-generated document ID; used in URLs and shareable links +- `lane_id` (string, nullable) - Brokerage's own internal reference number for the lane; provided by the broker, not generated by the system. May be null until the broker assigns one, but must be filled in eventually. +- `pickup_state` (string, nullable) - Two-letter state code for pickup location (e.g. `"CA"`); may be derived from `pickup_address` +- `dropoff_state` (string, nullable) - Two-letter state code for dropoff location; may be derived from `dropoff_address` +- `pickup_address` (string, nullable) - Full pickup address +- `dropoff_address` (string, nullable) - Full dropoff address +- `bidding_carriers` (array of strings) - `_id`s of carriers who have placed a bid +- `created_by` (string) - Username of the broker who created the lane +- `created_by_id` (ObjectID) - MongoDB `_id` of the broker user document + +> **Validation rule**: At least one of the following must be provided at creation time: `lane_id`, both state codes, or both addresses. A lane cannot be created with none of these. + +#### Example Lane Document +```json +{ + "_id": "<>", + "lane_id": "LN-2094", + "pickup_state": "CA", + "dropoff_state": "TX", + "pickup_address": "123 Warehouse Blvd, Los Angeles, CA 90001", + "dropoff_address": "456 Distribution Dr, Houston, TX 77001", + "bidding_carriers": ["<>", "<>"], + "created_by": "broker_jane", + "created_by_id": "<>" +} +``` + --- ## Application Processing Pipeline @@ -233,41 +278,41 @@ Each check adds a field to the document with status: ### Application State Visibility & Anti-Gaming Strategy -**Critical Rule**: Applicants must never know WHY their application was rejected unless an approver explicitly chooses to share that information. This prevents applicants from gaming the system through trial-and-error re-submissions. +**Critical Rule**: Carriers must never know WHY their application was rejected unless a broker explicitly chooses to share that information. This prevents carriers from gaming the system through trial-and-error re-submissions. -#### What Applicants CAN See +#### What Carriers CAN See - **Approved**: Full approval message -- **Returned**: Only the `correction_feedback` message from the approver +- **Returned**: Only the `correction_feedback` message from the broker - **Processing**: General status ("Your application is being reviewed") - **Draft**: Their own unsent application -#### What Applicants CANNOT See +#### What Carriers CANNOT See - **Rejected**: Application status shows "rejected" but NO reason provided - Check results and names (even passed checks) -- Approver internal notes (`approver_notes` field) +- Broker internal notes (`broker_notes` field) - Specific validation failures or OCR data - Which checks failed or passed -#### What Approvers CAN See (Full Visibility) +#### What Brokers CAN See (Full Visibility) - Complete application data - All OCR results and extracted data - All check results (passed, failed, human_review) -- Previous approver notes and decision history +- Previous broker notes and decision history - Full validation details for debugging -#### Workflow States from Applicant Perspective -- **draft** → (applicant submits) → **submitted** → **processing** → **approved/rejected/returned** -- "returned" shows feedback for corrections; applicant can resubmit -- "rejected" shows no details; applicant cannot resubmit unless approver explicitly allows +#### Workflow States from Carrier Perspective +- **draft** → (carrier submits) → **submitted** → **processing** → **approved/rejected/returned** +- "returned" shows feedback for corrections; carrier can resubmit +- "rejected" shows no details; carrier cannot resubmit unless broker explicitly allows - **Unregistered token behavior**: each new unregistered session creates a fresh token. A token cannot access or enumerate previous sessions; clearing cookies removes access. This helps protect sensitive documents from snooping by others using the same browser. --- ## User Roles & Access Control ### Role Types -- **Unregistered Trucker** – No login required. Receives a transient `unreg_token` cookie to upload and view only the applications tied to that token. Cannot see previous tokens’ data. Token persists until browser clears it or is upgraded by registration. -- **Registered Trucker/Applicant** – Logs in normally. Can view full submission history, reuse documents, and update profile. -- **Approver/Manager** – Can review applications, approve/reject/return, manage users +- **Unregistered Carrier** – No login required. Receives a transient `unreg_token` cookie to upload and view only the applications tied to that token. Cannot see previous tokens’ data. Token persists until browser clears it or is upgraded by registration. +- **Registered Carrier** – Logs in normally. Can view full submission history, reuse documents, and update profile. +- **Broker** – Can review applications, approve/reject/return, manage users - **Admin** – Full system access, user management, configuration ### Authentication @@ -321,11 +366,23 @@ Or on error: ## Database Considerations ### Recommended MongoDB Indexes + +**applications collection** - `_id` (automatic) -- `submitted_timestamp` (for sorting/filtering applications) -- `status` (for querying by application status) +- `submitted_timestamp` (for sorting/filtering) +- `status` (for querying by status) - `driver_license_num` (for duplicate detection) -- `app_id` (if not using `_id` as primary key) + +**lanes collection** +- `_id` (automatic) +- `created_by` (for listing a broker's lanes) + +**users collection** +- `username` (unique) + +**sessions collection** +- `token` (unique) +- `expires_at` (TTL) ### Data Retention - Applications should be archived after [TBD] days/months @@ -350,4 +407,4 @@ Or on error: - Integration with external validation services (FMCSA, state registries) - Multi-language support - Mobile application -- Webhook integrations for downstream systems \ No newline at end of file +- Webhook integrations for downstream systems diff --git a/Dockerfile b/Dockerfile index ba8b67e..2f22df9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # builder stage -FROM golang:1.21-alpine AS builder +FROM golang:1.24-alpine AS builder WORKDIR /app COPY go.mod go.sum ./ diff --git a/Dockerfile.caddy b/Dockerfile.caddy new file mode 100644 index 0000000..2412f25 --- /dev/null +++ b/Dockerfile.caddy @@ -0,0 +1,12 @@ +# Build the Vite frontend +FROM node:20-alpine AS frontend +WORKDIR /app +COPY frontend/package*.json ./ +RUN npm install +COPY frontend/ ./ +RUN npm run build + +# Serve with Caddy +FROM caddy:2-alpine +COPY --from=frontend /app/dist /srv/web +COPY Caddyfile /etc/caddy/Caddyfile diff --git a/Makefile b/Makefile index 19b1c6a..cddab03 100644 --- a/Makefile +++ b/Makefile @@ -1,21 +1,36 @@ # Makefile for building and running LogJensticks services -.PHONY: build run run-server run-db run-test docker-build +.PHONY: build run run-server run-db run-caddy run-frontend run-test docker-build ls + +ls: + @echo "build - Compile Go binary to bin/server" + @echo "run - Build Docker images and start all containers" + @echo "run-server - Build Docker images and start only the Go server" + @echo "run-db - Start only the MongoDB container" + @echo "run-caddy - Start only the Caddy container" + @echo "run-frontend - Start the Vite dev server (local, no Docker)" + @echo "run-test - Run all Go tests" build: go build -o bin/server ./cmd/server run: docker-build - docker-compose up + docker compose up run-server: docker-build - docker-compose up server + docker compose up server run-db: - docker-compose up db + docker compose up db + +run-caddy: + docker compose up caddy + +run-frontend: + cd frontend && npm run dev run-test: go test ./... docker-build: - docker-compose build + docker compose build diff --git a/bin/server b/bin/server new file mode 100755 index 0000000..f284fe4 Binary files /dev/null and b/bin/server differ diff --git a/cmd/server/lanes.go b/cmd/server/lanes.go new file mode 100644 index 0000000..e365396 --- /dev/null +++ b/cmd/server/lanes.go @@ -0,0 +1,79 @@ +package main + +import ( + "encoding/json" + "net/http" + + "go.mongodb.org/mongo-driver/v2/bson" + + "logjensticks/internal/auth" + "logjensticks/internal/db" +) + +type createLaneRequest struct { + LaneID *string `json:"lane_id"` + PickupState *string `json:"pickup_state"` + DropoffState *string `json:"dropoff_state"` + PickupAddress *string `json:"pickup_address"` + DropoffAddress *string `json:"dropoff_address"` +} + +func handleCreateLane(dbc *db.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := auth.SessionFromContext(r.Context()) + if session.Role != "broker" { + writeError(w, http.StatusForbidden, "FORBIDDEN", "brokers only") + return + } + + var req createLaneRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "BAD_REQUEST", "invalid request body") + return + } + + hasLaneID := req.LaneID != nil && *req.LaneID != "" + hasStates := req.PickupState != nil && *req.PickupState != "" && + req.DropoffState != nil && *req.DropoffState != "" + hasAddresses := req.PickupAddress != nil && *req.PickupAddress != "" && + req.DropoffAddress != nil && *req.DropoffAddress != "" + + if !hasLaneID && !hasStates && !hasAddresses { + writeError(w, http.StatusBadRequest, "VALIDATION_ERROR", + "provide at least a lane ID, both state codes, or both addresses") + return + } + + var creatingUser struct { + ID bson.ObjectID `bson:"_id"` + } + err := dbc.Users().FindOne(r.Context(), + bson.D{{Key: "username", Value: session.Username}}, + ).Decode(&creatingUser) + if err != nil { + writeError(w, http.StatusInternalServerError, "SERVER_ERROR", "failed to resolve user") + return + } + + doc := bson.D{ + {Key: "lane_id", Value: req.LaneID}, + {Key: "pickup_state", Value: req.PickupState}, + {Key: "dropoff_state", Value: req.DropoffState}, + {Key: "pickup_address", Value: req.PickupAddress}, + {Key: "dropoff_address", Value: req.DropoffAddress}, + {Key: "bidding_carriers", Value: bson.A{}}, + {Key: "created_by", Value: session.Username}, + {Key: "created_by_id", Value: creatingUser.ID}, + } + + result, err := dbc.Lanes().InsertOne(r.Context(), doc) + if err != nil { + writeError(w, http.StatusInternalServerError, "SERVER_ERROR", "failed to create lane") + return + } + + writeSuccess(w, http.StatusCreated, map[string]any{ + "id": result.InsertedID, + }) + } +} diff --git a/cmd/server/login.go b/cmd/server/login.go new file mode 100644 index 0000000..f3c410d --- /dev/null +++ b/cmd/server/login.go @@ -0,0 +1,78 @@ +package main + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "logjensticks/internal/auth" + "logjensticks/internal/db" +) + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func handleLogin(dbc *db.Client, secureCookie bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed") + return + } + + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Username == "" || req.Password == "" { + writeError(w, http.StatusBadRequest, "BAD_REQUEST", "username and password required") + return + } + + token, session, err := auth.Login(r.Context(), dbc, req.Username, req.Password) + if errors.Is(err, auth.ErrInvalidCredentials) { + writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "invalid username or password") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "SERVER_ERROR", "internal server error") + return + } + + http.SetCookie(w, &http.Cookie{ + Name: auth.CookieName, + Value: token, + Path: "/", + HttpOnly: true, + Secure: secureCookie, + SameSite: http.SameSiteStrictMode, + MaxAge: int(db.SessionTTL / time.Second), + }) + + writeSuccess(w, http.StatusOK, map[string]string{"role": session.Role}) + } +} + +func handleLogout(dbc *db.Client, secureCookie bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed") + return + } + + if cookie, err := r.Cookie(auth.CookieName); err == nil { + auth.DeleteSession(r.Context(), dbc, cookie.Value) + } + + http.SetCookie(w, &http.Cookie{ + Name: auth.CookieName, + Value: "", + Path: "/", + HttpOnly: true, + Secure: secureCookie, + SameSite: http.SameSiteStrictMode, + MaxAge: -1, + }) + + writeSuccess(w, http.StatusOK, nil) + } +} diff --git a/cmd/server/login_test.go b/cmd/server/login_test.go new file mode 100644 index 0000000..35b5112 --- /dev/null +++ b/cmd/server/login_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// loginResponse mirrors the shape returned by handleLogin for assertion purposes. +type loginResponse struct { + Success bool `json:"success"` + Data *struct { + Role string `json:"role"` + } `json:"data"` + Error *struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +func TestHandleLogin_MethodNotAllowed(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/login", nil) + w := httptest.NewRecorder() + handleLogin(nil, false)(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", w.Code) + } +} + +func TestHandleLogin_MissingFields(t *testing.T) { + body := bytes.NewBufferString(`{"username":""}`) + req := httptest.NewRequest(http.MethodPost, "/login", body) + w := httptest.NewRecorder() + handleLogin(nil, false)(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + + var resp loginResponse + json.NewDecoder(w.Body).Decode(&resp) + if resp.Error == nil || resp.Error.Code != "BAD_REQUEST" { + t.Errorf("expected BAD_REQUEST error, got %+v", resp.Error) + } +} + +func TestHandleLogout_MethodNotAllowed(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/logout", nil) + w := httptest.NewRecorder() + handleLogout(nil, false)(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("expected 405, got %d", w.Code) + } +} + +func TestHandleLogout_NoCookie(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/logout", nil) + w := httptest.NewRecorder() + handleLogout(nil, false)(w, req) + + // Logout with no cookie should still succeed (idempotent). + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..1ec907b --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,68 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + + "logjensticks/internal/auth" + "logjensticks/internal/db" +) + +func main() { + mongoURI := os.Getenv("MONGO_URI") + if mongoURI == "" { + mongoURI = "mongodb://localhost:27017/logjensticks" + } + + // In production set COOKIE_SECURE=true (requires HTTPS). + // Defaults to false for local dev over HTTP. + secureCookie := os.Getenv("COOKIE_SECURE") == "true" + + ctx := context.Background() + + dbClient, err := db.Connect(ctx, mongoURI) + if err != nil { + log.Fatalf("db: failed to connect: %v", err) + } + defer dbClient.Disconnect(ctx) + + if err := dbClient.EnsureIndexes(ctx); err != nil { + log.Fatalf("db: failed to create indexes: %v", err) + } + + seedUsername := os.Getenv("SEED_USERNAME") + seedPassword := os.Getenv("SEED_PASSWORD") + if seedUsername == "" { + seedUsername = "broker" + log.Println("WARNING: SEED_USERNAME not set, using dev default 'broker'") + } + if seedPassword == "" { + seedPassword = "changeme" + log.Println("WARNING: SEED_PASSWORD not set, using dev default 'changeme'") + } + if err := dbClient.SeedBroker(ctx, seedUsername, seedPassword); err != nil { + log.Fatalf("db: seed failed: %v", err) + } + + protected := auth.Middleware(dbClient) + + mux := http.NewServeMux() + mux.HandleFunc("/health", handleHealth) + mux.HandleFunc("/login", handleLogin(dbClient, secureCookie)) + mux.HandleFunc("/logout", handleLogout(dbClient, secureCookie)) + mux.Handle("/me", protected(http.HandlerFunc(handleMe))) + + addr := ":8080" + log.Printf("server starting on %s", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("server error: %v", err) + } +} + +func handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"success":true}`)) +} diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 0000000..610814f --- /dev/null +++ b/cmd/server/main_test.go @@ -0,0 +1,21 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestHealth(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/health", nil) + w := httptest.NewRecorder() + handleHealth(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d", w.Code) + } + body := w.Body.String() + if body != `{"success":true}` { + t.Errorf("unexpected body: %s", body) + } +} diff --git a/cmd/server/me.go b/cmd/server/me.go new file mode 100644 index 0000000..b6ef970 --- /dev/null +++ b/cmd/server/me.go @@ -0,0 +1,15 @@ +package main + +import ( + "net/http" + + "logjensticks/internal/auth" +) + +func handleMe(w http.ResponseWriter, r *http.Request) { + session := auth.SessionFromContext(r.Context()) + writeSuccess(w, http.StatusOK, map[string]string{ + "username": session.Username, + "role": session.Role, + }) +} diff --git a/cmd/server/response.go b/cmd/server/response.go new file mode 100644 index 0000000..6065cc9 --- /dev/null +++ b/cmd/server/response.go @@ -0,0 +1,29 @@ +package main + +import ( + "encoding/json" + "net/http" +) + +type apiResponse struct { + Success bool `json:"success"` + Data any `json:"data"` + Error *apiError `json:"error"` +} + +type apiError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func writeSuccess(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(apiResponse{Success: true, Data: data, Error: nil}) +} + +func writeError(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(apiResponse{Success: false, Data: nil, Error: &apiError{Code: code, Message: message}}) +} diff --git a/docker-compose.yml b/docker-compose.yml index 63a09c5..824abee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,16 @@ -version: '3.8' - services: + caddy: + build: + context: . + dockerfile: Dockerfile.caddy + ports: + - "80:80" + - "443:443" + volumes: + - caddy-data:/data + depends_on: + - server + server: build: . ports: @@ -9,6 +19,8 @@ services: - db environment: - MONGO_URI=mongodb://db:27017/logjensticks + - COOKIE_SECURE=true + db: image: mongo:6.0 ports: @@ -18,3 +30,4 @@ services: volumes: mongo-data: + caddy-data: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..97470b1 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + LogJensticks + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f1a6bef --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,17 @@ +{ + "name": "logjensticks-frontend", + "version": "0.0.1", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..d26adbe --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..b7c2371 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import router from './router' + +createApp(App).use(router).mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..260131f --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,49 @@ +import { createRouter, createWebHistory } from 'vue-router' +import Login from '../views/Login.vue' +import Carrier from '../views/Carrier.vue' +import CreateLane from '../views/CreateLane.vue' + +const routes = [ + { path: '/', redirect: '/login' }, + { path: '/login', component: Login }, + { path: '/broker', component: CreateLane, meta: { requiresAuth: true, role: 'broker' } }, + { path: '/carrier', component: Carrier, meta: { requiresAuth: true, role: 'carrier' } }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +// Fetch the current session once per navigation. +async function getSession() { + const res = await fetch('/me') + if (!res.ok) return null + const { data } = await res.json() + return data +} + +function homeForRole(role) { + return role === 'broker' ? '/broker' : '/carrier' +} + +router.beforeEach(async (to) => { + const session = await getSession() + + if (to.path === '/login') { + // Already logged in — send to the right home page. + if (session) return homeForRole(session.role) + return true + } + + if (!session) return '/login' + + // Logged in but landed on the wrong role's page. + if (to.meta.role && session.role !== to.meta.role) { + return homeForRole(session.role) + } + + return true +}) + +export default router diff --git a/frontend/src/views/Carrier.vue b/frontend/src/views/Carrier.vue new file mode 100644 index 0000000..c8c812b --- /dev/null +++ b/frontend/src/views/Carrier.vue @@ -0,0 +1,16 @@ + + + diff --git a/frontend/src/views/CreateLane.vue b/frontend/src/views/CreateLane.vue new file mode 100644 index 0000000..114b4a7 --- /dev/null +++ b/frontend/src/views/CreateLane.vue @@ -0,0 +1,86 @@ + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..a3ca236 --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,43 @@ + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..fd3023e --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + // Proxy API calls to the Go server during local development. + proxy: { + '/login': 'http://localhost:8080', + '/logout': 'http://localhost:8080', + '/me': 'http://localhost:8080', + '/health': 'http://localhost:8080', + }, + }, +}) diff --git a/go.mod b/go.mod index 58100be..27a676f 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,18 @@ -module github.com/example/logjensticks - -go 1.21 +module logjensticks + +go 1.24.0 + +require ( + github.com/golang/snappy v0.0.1 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.mongodb.org/mongo-driver v1.15.0 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/text v0.34.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f11a845 --- /dev/null +++ b/go.sum @@ -0,0 +1,62 @@ +github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/klauspost/compress v1.13.6 h1:P76CopJELS0TiO2mebmnzgWaajssP/EszplttgQxcgc= +github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= +github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver v1.15.0 h1:rJCKC8eEliewXjZGf0ddURtl7tTVy1TK3bfl0gkUSLc= +go.mongodb.org/mongo-driver v1.15.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..078dc9e --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,95 @@ +package auth + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "golang.org/x/crypto/bcrypt" + + "logjensticks/internal/db" +) + +var ErrInvalidCredentials = errors.New("invalid username or password") +var ErrSessionNotFound = errors.New("session not found or expired") + +type User struct { + Username string `bson:"username"` + PasswordHash string `bson:"password_hash"` + Role string `bson:"role"` +} + +type Session struct { + Token string `bson:"token"` + Username string `bson:"username"` + Role string `bson:"role"` + ExpiresAt time.Time `bson:"expires_at"` +} + +// Login verifies credentials, creates a session, and returns the session token. +func Login(ctx context.Context, dbc *db.Client, username, password string) (string, *Session, error) { + var user User + err := dbc.Users().FindOne(ctx, bson.D{{Key: "username", Value: username}}).Decode(&user) + if errors.Is(err, mongo.ErrNoDocuments) { + return "", nil, ErrInvalidCredentials + } + if err != nil { + return "", nil, err + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return "", nil, ErrInvalidCredentials + } + + token, err := generateToken() + if err != nil { + return "", nil, err + } + + session := Session{ + Token: token, + Username: user.Username, + Role: user.Role, + ExpiresAt: time.Now().UTC().Add(db.SessionTTL), + } + + if _, err := dbc.Sessions().InsertOne(ctx, session); err != nil { + return "", nil, err + } + + return token, &session, nil +} + +// ValidateSession looks up a session by token and confirms it has not expired. +func ValidateSession(ctx context.Context, dbc *db.Client, token string) (*Session, error) { + var session Session + err := dbc.Sessions().FindOne(ctx, bson.D{{Key: "token", Value: token}}).Decode(&session) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrSessionNotFound + } + if err != nil { + return nil, err + } + if time.Now().After(session.ExpiresAt) { + return nil, ErrSessionNotFound + } + return &session, nil +} + +// DeleteSession removes a session document (used on logout). +func DeleteSession(ctx context.Context, dbc *db.Client, token string) error { + _, err := dbc.Sessions().DeleteOne(ctx, bson.D{{Key: "token", Value: token}}) + return err +} + +func generateToken() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..dfd7e2d --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,50 @@ +package auth + +import ( + "context" + "net/http" + + "logjensticks/internal/db" +) + +const CookieName = "session_token" + +type contextKey string + +const sessionContextKey contextKey = "session" + +// Middleware validates the session cookie on every request. Attach this to +// any route that requires authentication. +func Middleware(dbc *db.Client) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie(CookieName) + if err != nil { + writeUnauthorized(w) + return + } + + session, err := ValidateSession(r.Context(), dbc, cookie.Value) + if err != nil { + writeUnauthorized(w) + return + } + + ctx := context.WithValue(r.Context(), sessionContextKey, session) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// SessionFromContext retrieves the validated session attached by Middleware. +// Returns nil if called outside an authenticated route. +func SessionFromContext(ctx context.Context) *Session { + s, _ := ctx.Value(sessionContextKey).(*Session) + return s +} + +func writeUnauthorized(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"success":false,"data":null,"error":{"code":"UNAUTHORIZED","message":"authentication required"}}`)) +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..42b696f --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,47 @@ +package db + +import ( + "context" + "time" + + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// Client wraps the mongo client and exposes typed collection accessors. +type Client struct { + client *mongo.Client + db *mongo.Database +} + +func Connect(ctx context.Context, uri string) (*Client, error) { + opts := options.Client().ApplyURI(uri) + c, err := mongo.Connect(opts) + if err != nil { + return nil, err + } + + pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := c.Ping(pingCtx, nil); err != nil { + return nil, err + } + + return &Client{client: c, db: c.Database("logjensticks")}, nil +} + +func (c *Client) Disconnect(ctx context.Context) error { + return c.client.Disconnect(ctx) +} + +func (c *Client) Users() *mongo.Collection { + return c.db.Collection("users") +} + +func (c *Client) Sessions() *mongo.Collection { + return c.db.Collection("sessions") +} + +func (c *Client) Lanes() *mongo.Collection { + return c.db.Collection("lanes") +} diff --git a/internal/db/indexes.go b/internal/db/indexes.go new file mode 100644 index 0000000..73f1b85 --- /dev/null +++ b/internal/db/indexes.go @@ -0,0 +1,62 @@ +package db + +import ( + "context" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// EnsureIndexes creates all required indexes. Safe to call on every startup +// (mongo ignores duplicate index creation). +func (c *Client) EnsureIndexes(ctx context.Context) error { + if err := c.ensureUserIndexes(ctx); err != nil { + return err + } + if err := c.ensureSessionIndexes(ctx); err != nil { + return err + } + return c.ensureLaneIndexes(ctx) +} + +func (c *Client) ensureLaneIndexes(ctx context.Context) error { + _, err := c.Lanes().Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "created_by", Value: 1}}, + }) + return err +} + +func (c *Client) ensureUserIndexes(ctx context.Context) error { + _, err := c.Users().Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "username", Value: 1}}, + Options: options.Index().SetUnique(true), + }) + return err +} + +func (c *Client) ensureSessionIndexes(ctx context.Context) error { + // Unique lookup index on the session token. + if _, err := c.Sessions().Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "token", Value: 1}}, + Options: options.Index().SetUnique(true), + }); err != nil { + return err + } + + // TTL index: MongoDB automatically deletes session documents after + // expires_at has passed (checked roughly every 60 s by the reaper). + expireAfter := int32(0) + if _, err := c.Sessions().Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "expires_at", Value: 1}}, + Options: options.Index().SetExpireAfterSeconds(expireAfter), + }); err != nil { + return err + } + + return nil +} + +// SessionTTL is how long a session stays valid. +const SessionTTL = 24 * time.Hour diff --git a/internal/db/seed.go b/internal/db/seed.go new file mode 100644 index 0000000..899cf79 --- /dev/null +++ b/internal/db/seed.go @@ -0,0 +1,41 @@ +package db + +import ( + "context" + "log" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "golang.org/x/crypto/bcrypt" +) + +// SeedBroker inserts a default broker account if no users exist. +// Credentials are taken from environment variables SEED_USERNAME and +// SEED_PASSWORD; if unset they fall back to dev defaults and a warning +// is printed. This function must not run in production without those vars set. +func (c *Client) SeedBroker(ctx context.Context, username, password string) error { + count, err := c.Users().CountDocuments(ctx, bson.D{}) + if err != nil { + return err + } + if count > 0 { + return nil // already seeded + } + + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + + _, err = c.Users().InsertOne(ctx, bson.D{ + {Key: "username", Value: username}, + {Key: "password_hash", Value: string(hash)}, + {Key: "role", Value: "broker"}, + }) + if err != nil && !mongo.IsDuplicateKeyError(err) { + return err + } + + log.Printf("db: seeded broker user %q", username) + return nil +}