Initial commit
This commit is contained in:
@@ -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}})
|
||||
}
|
||||
Reference in New Issue
Block a user