Initial commit
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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": "<<mongo uuid>>",
|
||||
"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": ["<<carrier _id>>", "<<carrier _id>>"],
|
||||
"created_by": "broker_jane",
|
||||
"created_by_id": "<<ObjectID>>"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- Webhook integrations for downstream systems
|
||||
|
||||
+1
-1
@@ -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 ./
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Executable
BIN
Binary file not shown.
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}`))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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}})
|
||||
}
|
||||
+15
-2
@@ -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:
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LogJensticks</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
createApp(App).use(router).mount('#app')
|
||||
@@ -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
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<h1>Carrier</h1>
|
||||
<p>You are logged in.</p>
|
||||
<button @click="logout">Log Out</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
async function logout() {
|
||||
await fetch('/logout', { method: 'POST' })
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<h1>Create Lane</h1>
|
||||
|
||||
<form v-if="!createdLink" @submit.prevent="submit">
|
||||
<fieldset>
|
||||
<legend>Internal Reference</legend>
|
||||
<label for="laneId">Lane ID</label><br>
|
||||
<input id="laneId" v-model="form.laneId" type="text" placeholder="e.g. LN-2094">
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>State Codes</legend>
|
||||
<label for="pickupState">Pickup State</label><br>
|
||||
<input id="pickupState" v-model="form.pickupState" type="text" maxlength="2" placeholder="e.g. CA">
|
||||
<br><br>
|
||||
<label for="dropoffState">Dropoff State</label><br>
|
||||
<input id="dropoffState" v-model="form.dropoffState" type="text" maxlength="2" placeholder="e.g. TX">
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Addresses</legend>
|
||||
<label for="pickupAddress">Pickup Address</label><br>
|
||||
<input id="pickupAddress" v-model="form.pickupAddress" type="text" placeholder="123 Warehouse Blvd, Los Angeles, CA 90001">
|
||||
<br><br>
|
||||
<label for="dropoffAddress">Dropoff Address</label><br>
|
||||
<input id="dropoffAddress" v-model="form.dropoffAddress" type="text" placeholder="456 Distribution Dr, Houston, TX 77001">
|
||||
</fieldset>
|
||||
|
||||
<p v-if="error" style="color:red">{{ error }}</p>
|
||||
<button type="submit">Create Lane</button>
|
||||
</form>
|
||||
|
||||
<div v-else>
|
||||
<p>Lane created. Share this link with carriers:</p>
|
||||
<a :href="createdLink">{{ createdLink }}</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const form = reactive({
|
||||
laneId: '',
|
||||
pickupState: '',
|
||||
dropoffState: '',
|
||||
pickupAddress: '',
|
||||
dropoffAddress: '',
|
||||
})
|
||||
|
||||
const error = ref('')
|
||||
const createdLink = ref('')
|
||||
|
||||
const hasLaneId = () => form.laneId.trim() !== ''
|
||||
const hasStateCodes = () => form.pickupState.trim() !== '' && form.dropoffState.trim() !== ''
|
||||
const hasAddresses = () => form.pickupAddress.trim() !== '' && form.dropoffAddress.trim() !== ''
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
|
||||
if (!hasLaneId() && !hasStateCodes() && !hasAddresses()) {
|
||||
error.value = 'Provide at least a Lane ID, both state codes, or both addresses.'
|
||||
return
|
||||
}
|
||||
|
||||
const res = await fetch('/lanes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
lane_id: form.laneId || null,
|
||||
pickup_state: form.pickupState || null,
|
||||
dropoff_state: form.dropoffState || null,
|
||||
pickup_address: form.pickupAddress || null,
|
||||
dropoff_address: form.dropoffAddress || null,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json()
|
||||
error.value = body.error?.message ?? 'Failed to create lane.'
|
||||
return
|
||||
}
|
||||
|
||||
const { data } = await res.json()
|
||||
createdLink.value = `${window.location.origin}/lanes/${data.id}`
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<h1>LogJensticks</h1>
|
||||
<form @submit.prevent="submit">
|
||||
<div>
|
||||
<label for="username">Username</label><br>
|
||||
<input id="username" v-model="username" type="text" required autofocus>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Password</label><br>
|
||||
<input id="password" v-model="password" type="password" required>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit">Log In</button>
|
||||
</div>
|
||||
<p v-if="error" style="color:red">{{ error }}</p>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
const res = await fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username.value, password: password.value }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const { data } = await res.json()
|
||||
router.push(data.role === 'broker' ? '/broker' : '/carrier')
|
||||
} else {
|
||||
error.value = 'Invalid username or password.'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}}`))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user