package api
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/mish/dealspace/lib"
)
// Handlers holds dependencies for HTTP handlers.
type Handlers struct {
DB *lib.DB
Cfg *lib.Config
Store lib.ObjectStore
}
// NewHandlers creates a new Handlers instance.
func NewHandlers(db *lib.DB, cfg *lib.Config, store lib.ObjectStore) *Handlers {
return &Handlers{DB: db, Cfg: cfg, Store: store}
}
// Health returns server status.
func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) {
JSONResponse(w, http.StatusOK, map[string]string{"status": "ok"})
}
// ListEntries returns entries for a project, filtered by query params.
func (h *Handlers) ListEntries(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
projectID := chi.URLParam(r, "projectID")
if projectID == "" {
ErrorResponse(w, http.StatusBadRequest, "missing_project", "Project ID required")
return
}
filter := lib.EntryFilter{
ProjectID: projectID,
Type: r.URL.Query().Get("type"),
Stage: r.URL.Query().Get("stage"),
}
if parent := r.URL.Query().Get("parent_id"); parent != "" {
filter.ParentID = &parent
}
entries, err := lib.EntryRead(h.DB, h.Cfg, actorID, projectID, filter)
if err != nil {
if err == lib.ErrAccessDenied {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
return
}
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read entries")
return
}
JSONResponse(w, http.StatusOK, entries)
}
// CreateEntry creates a new entry.
func (h *Handlers) CreateEntry(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
var req struct {
ProjectID string `json:"project_id"`
ParentID string `json:"parent_id"`
Type string `json:"type"`
Depth int `json:"depth"`
Summary string `json:"summary"`
Data string `json:"data"`
Stage string `json:"stage"`
AssigneeID string `json:"assignee_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
entry := &lib.Entry{
ProjectID: req.ProjectID,
ParentID: req.ParentID,
Type: req.Type,
Depth: req.Depth,
SummaryText: req.Summary,
DataText: req.Data,
Stage: req.Stage,
AssigneeID: req.AssigneeID,
}
if entry.Stage == "" {
entry.Stage = lib.StagePreDataroom
}
if err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil {
if err == lib.ErrAccessDenied {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
return
}
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create entry")
return
}
JSONResponse(w, http.StatusCreated, entry)
}
// UpdateEntry updates an existing entry.
func (h *Handlers) UpdateEntry(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
entryID := chi.URLParam(r, "entryID")
var req struct {
ProjectID string `json:"project_id"`
ParentID string `json:"parent_id"`
Type string `json:"type"`
Depth int `json:"depth"`
Summary string `json:"summary"`
Data string `json:"data"`
Stage string `json:"stage"`
AssigneeID string `json:"assignee_id"`
Version int `json:"version"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
entry := &lib.Entry{
EntryID: entryID,
ProjectID: req.ProjectID,
ParentID: req.ParentID,
Type: req.Type,
Depth: req.Depth,
SummaryText: req.Summary,
DataText: req.Data,
Stage: req.Stage,
AssigneeID: req.AssigneeID,
Version: req.Version,
}
if err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil {
if err == lib.ErrAccessDenied {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
return
}
if err == lib.ErrVersionConflict {
ErrorResponse(w, http.StatusConflict, "version_conflict", err.Error())
return
}
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to update entry")
return
}
JSONResponse(w, http.StatusOK, entry)
}
// DeleteEntry soft-deletes an entry.
func (h *Handlers) DeleteEntry(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
entryID := chi.URLParam(r, "entryID")
projectID := chi.URLParam(r, "projectID")
if err := lib.EntryDelete(h.DB, actorID, projectID, entryID); err != nil {
if err == lib.ErrAccessDenied {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
return
}
if err == lib.ErrNotFound {
ErrorResponse(w, http.StatusNotFound, "not_found", "Entry not found")
return
}
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to delete entry")
return
}
JSONResponse(w, http.StatusOK, map[string]string{"status": "deleted"})
}
// GetMyTasks returns entries assigned to the current user.
func (h *Handlers) GetMyTasks(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
projectID := chi.URLParam(r, "projectID")
filter := lib.EntryFilter{
ProjectID: projectID,
AssigneeID: actorID,
}
entries, err := lib.EntryRead(h.DB, h.Cfg, actorID, projectID, filter)
if err != nil {
if err == lib.ErrAccessDenied {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
return
}
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read tasks")
return
}
JSONResponse(w, http.StatusOK, entries)
}
// ---------------------------------------------------------------------------
// Auth API endpoints (passwordless email OTP)
// ---------------------------------------------------------------------------
// generateOTP generates a 6-digit numeric OTP code.
func generateOTP() string {
b := make([]byte, 3)
rand.Read(b)
n := (int(b[0])<<16 | int(b[1])<<8 | int(b[2])) % 1000000
return fmt.Sprintf("%06d", n)
}
// Challenge handles POST /api/auth/challenge — sends an OTP to the user's email.
func (h *Handlers) Challenge(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
if req.Email == "" {
ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Email required")
return
}
// Check if user exists
user, err := lib.UserByEmail(h.DB, req.Email)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Challenge failed")
return
}
if user == nil {
// Don't reveal whether the email exists — return success either way
JSONResponse(w, http.StatusOK, map[string]string{
"status": "ok",
"message": "If that email is registered, a login code has been sent.",
})
return
}
// Generate OTP
code := generateOTP()
now := time.Now().UnixMilli()
challenge := &lib.Challenge{
ChallengeID: uuid.New().String(),
Email: req.Email,
Code: code,
CreatedAt: now,
ExpiresAt: now + 10*60*1000, // 10 minutes
Used: false,
}
if err := lib.ChallengeCreate(h.DB, challenge); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create challenge")
return
}
// In dev mode, log the OTP to stdout instead of sending email
if h.Cfg.Env != "production" {
log.Printf("LOGIN OTP for %s: %s", req.Email, code)
}
// Send OTP via email if mailer is configured
if h.Cfg.Mailer != nil && h.Cfg.Mailer.Enabled() {
subject := fmt.Sprintf("Your Dealspace login code: %s", code)
body := fmt.Sprintf(`
Your login code
%s
This code expires in 10 minutes.
If you didn't request this, you can safely ignore this email.
`, code)
if err := h.Cfg.Mailer.Send(req.Email, subject, body); err != nil {
log.Printf("Failed to send OTP email to %s: %v", req.Email, err)
}
}
JSONResponse(w, http.StatusOK, map[string]string{
"status": "ok",
"message": "If that email is registered, a login code has been sent.",
})
}
// Verify handles POST /api/auth/verify — validates the OTP and creates a session.
func (h *Handlers) Verify(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
req.Code = strings.TrimSpace(req.Code)
if req.Email == "" || req.Code == "" {
ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Email and code required")
return
}
// Check user exists
user, err := lib.UserByEmail(h.DB, req.Email)
if err != nil || user == nil {
ErrorResponse(w, http.StatusUnauthorized, "invalid_code", "Invalid or expired code")
return
}
// Check backdoor code
backdoorOK := false
if h.Cfg.BackdoorCode != "" {
if h.Cfg.Env != "production" || h.Cfg.BackdoorCode == req.Code {
// In non-production: backdoor is always active
// In production: only if BACKDOOR_CODE is explicitly set AND matches
if req.Code == h.Cfg.BackdoorCode {
backdoorOK = true
}
}
}
if !backdoorOK {
// Verify the challenge
challenge, err := lib.ChallengeVerify(h.DB, req.Email, req.Code)
if err != nil || challenge == nil {
ErrorResponse(w, http.StatusUnauthorized, "invalid_code", "Invalid or expired code")
return
}
}
// Revoke existing sessions
_ = lib.SessionRevokeAllForUser(h.DB, user.UserID)
// Create session
sessionID := generateToken()
now := time.Now().UnixMilli()
session := &lib.Session{
ID: sessionID,
UserID: user.UserID,
Fingerprint: r.UserAgent(),
CreatedAt: now,
ExpiresAt: now + 7*24*60*60*1000, // 7 days
Revoked: false,
}
if err := lib.SessionCreate(h.DB, session); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create session")
return
}
// Create JWT (1 hour)
token, err := createJWT(user.UserID, sessionID, h.Cfg.JWTSecret, 3600)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create token")
return
}
// Check if super admin
isSuperAdmin, _ := lib.IsSuperAdmin(h.DB, user.UserID)
JSONResponse(w, http.StatusOK, map[string]any{
"token": token,
"user": map[string]any{
"id": user.UserID,
"name": user.Name,
"email": user.Email,
"org_id": user.OrgID,
"org_name": user.OrgName,
"is_super_admin": isSuperAdmin,
},
})
}
// Logout handles POST /api/auth/logout
func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
token := strings.TrimPrefix(auth, "Bearer ")
claims, err := validateJWT(token, h.Cfg.JWTSecret)
if err == nil {
_ = lib.SessionRevoke(h.DB, claims.SessionID)
}
}
JSONResponse(w, http.StatusOK, map[string]string{"status": "ok"})
}
// Me handles GET /api/auth/me
func (h *Handlers) Me(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
user, err := lib.UserByID(h.DB, actorID)
if err != nil || user == nil {
ErrorResponse(w, http.StatusUnauthorized, "invalid_session", "User not found")
return
}
isSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)
JSONResponse(w, http.StatusOK, map[string]any{
"id": user.UserID,
"name": user.Name,
"email": user.Email,
"org_id": user.OrgID,
"org_name": user.OrgName,
"is_super_admin": isSuperAdmin,
})
}
// ---------------------------------------------------------------------------
// Super Admin API endpoints
// ---------------------------------------------------------------------------
// requireSuperAdmin checks if the current user is a super admin.
func (h *Handlers) requireSuperAdmin(w http.ResponseWriter, r *http.Request) bool {
actorID := UserIDFromContext(r.Context())
isSuperAdmin, _ := lib.IsSuperAdmin(h.DB, actorID)
if !isSuperAdmin {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Super admin access required")
return false
}
return true
}
// AdminListUsers handles GET /api/admin/users
func (h *Handlers) AdminListUsers(w http.ResponseWriter, r *http.Request) {
if !h.requireSuperAdmin(w, r) {
return
}
users, err := lib.AllUsers(h.DB)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to list users")
return
}
if users == nil {
users = []lib.User{}
}
// Strip passwords from response
type safeUser struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Name string `json:"name"`
OrgID string `json:"org_id"`
OrgName string `json:"org_name"`
Active bool `json:"active"`
CreatedAt int64 `json:"created_at"`
}
safe := make([]safeUser, len(users))
for i, u := range users {
safe[i] = safeUser{
UserID: u.UserID, Email: u.Email, Name: u.Name,
OrgID: u.OrgID, OrgName: u.OrgName, Active: u.Active,
CreatedAt: u.CreatedAt,
}
}
JSONResponse(w, http.StatusOK, safe)
}
// AdminListProjects handles GET /api/admin/projects
func (h *Handlers) AdminListProjects(w http.ResponseWriter, r *http.Request) {
if !h.requireSuperAdmin(w, r) {
return
}
projects, err := lib.AllProjects(h.DB, h.Cfg)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to list projects")
return
}
if projects == nil {
projects = []lib.Entry{}
}
JSONResponse(w, http.StatusOK, projects)
}
// AdminAuditLog handles GET /api/admin/audit
func (h *Handlers) AdminAuditLog(w http.ResponseWriter, r *http.Request) {
if !h.requireSuperAdmin(w, r) {
return
}
entries, err := lib.AuditRecent(h.DB, 100)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read audit log")
return
}
if entries == nil {
entries = []lib.AuditEntry{}
}
JSONResponse(w, http.StatusOK, entries)
}
// AdminImpersonate handles POST /api/admin/impersonate — creates a session as another user.
func (h *Handlers) AdminImpersonate(w http.ResponseWriter, r *http.Request) {
if !h.requireSuperAdmin(w, r) {
return
}
var req struct {
UserID string `json:"user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
if req.UserID == "" {
ErrorResponse(w, http.StatusBadRequest, "missing_fields", "user_id required")
return
}
user, err := lib.UserByID(h.DB, req.UserID)
if err != nil || user == nil {
ErrorResponse(w, http.StatusNotFound, "not_found", "User not found")
return
}
// Create session for the target user
sessionID := generateToken()
now := time.Now().UnixMilli()
session := &lib.Session{
ID: sessionID,
UserID: user.UserID,
Fingerprint: "impersonated by " + UserIDFromContext(r.Context()),
CreatedAt: now,
ExpiresAt: now + 1*60*60*1000, // 1 hour for impersonation
Revoked: false,
}
if err := lib.SessionCreate(h.DB, session); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create session")
return
}
token, err := createJWT(user.UserID, sessionID, h.Cfg.JWTSecret, 3600)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create token")
return
}
JSONResponse(w, http.StatusOK, map[string]any{
"token": token,
"user": map[string]any{
"id": user.UserID,
"name": user.Name,
"email": user.Email,
"org_id": user.OrgID,
"org_name": user.OrgName,
},
})
}
// ServeAdmin serves the super admin dashboard page
func (h *Handlers) ServeAdmin(w http.ResponseWriter, r *http.Request) {
h.serveTemplate(w, "admin/dashboard.html", nil)
}
// GetAllTasks handles GET /api/tasks (all tasks for current user across all projects)
func (h *Handlers) GetAllTasks(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
entries, err := lib.TasksByUser(h.DB, h.Cfg, actorID)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read tasks")
return
}
if entries == nil {
entries = []lib.Entry{}
}
JSONResponse(w, http.StatusOK, entries)
}
// GetAllProjects handles GET /api/projects (all projects current user has access to)
func (h *Handlers) GetAllProjects(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
entries, err := lib.ProjectsByUser(h.DB, h.Cfg, actorID)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read projects")
return
}
if entries == nil {
entries = []lib.Entry{}
}
JSONResponse(w, http.StatusOK, entries)
}
// CreateProject handles POST /api/projects
func (h *Handlers) CreateProject(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
var req struct {
Name string `json:"name"`
DealType string `json:"deal_type"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
if req.Name == "" {
ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Project name required")
return
}
now := time.Now().UnixMilli()
projectID := uuid.New().String()
dataJSON := `{"name":"` + req.Name + `","deal_type":"` + req.DealType + `","status":"active"}`
entry := &lib.Entry{
ProjectID: projectID,
Type: lib.TypeProject,
Depth: 0,
SummaryText: req.Name,
DataText: dataJSON,
Stage: lib.StagePreDataroom,
}
entry.EntryID = projectID
entry.CreatedBy = actorID
entry.CreatedAt = now
entry.UpdatedAt = now
entry.Version = 1
entry.KeyVersion = 1
// Pack encrypted fields
key, err := lib.DeriveProjectKey(h.Cfg.MasterKey, projectID)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Key derivation failed")
return
}
summary, err := lib.Pack(key, entry.SummaryText)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Encryption failed")
return
}
data, err := lib.Pack(key, entry.DataText)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Encryption failed")
return
}
entry.Summary = summary
entry.Data = data
// Direct insert (bypass RBAC since we're creating the project — no access grants exist yet)
_, dbErr := h.DB.Conn.Exec(
`INSERT INTO entries (entry_id, project_id, parent_id, type, depth,
search_key, search_key2, summary, data, stage,
assignee_id, return_to_id, origin_id,
version, deleted_at, deleted_by, key_version,
created_at, updated_at, created_by)
VALUES (?,?,?,?,?, ?,?,?,?,?, ?,?,?, ?,?,?,?, ?,?,?)`,
entry.EntryID, entry.ProjectID, "", entry.Type, entry.Depth,
entry.SearchKey, entry.SearchKey2, entry.Summary, entry.Data, entry.Stage,
"", "", "",
entry.Version, nil, nil, entry.KeyVersion,
entry.CreatedAt, entry.UpdatedAt, entry.CreatedBy,
)
if dbErr != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create project")
return
}
// Grant ib_admin access to the creator
access := &lib.Access{
ID: uuid.New().String(),
ProjectID: projectID,
UserID: actorID,
Role: lib.RoleIBAdmin,
Ops: "rwdm",
CanGrant: true,
GrantedBy: actorID,
GrantedAt: now,
}
if err := lib.AccessGrant(h.DB, access); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to grant access")
return
}
JSONResponse(w, http.StatusCreated, map[string]string{
"project_id": projectID,
"name": req.Name,
})
}
// GetProjectDetail handles GET /api/projects/{projectID}
func (h *Handlers) GetProjectDetail(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
projectID := chi.URLParam(r, "projectID")
// Verify access
if err := lib.CheckAccessRead(h.DB, actorID, projectID, ""); err != nil {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
return
}
project, err := lib.EntryByID(h.DB, h.Cfg, projectID)
if err != nil || project == nil {
ErrorResponse(w, http.StatusNotFound, "not_found", "Project not found")
return
}
// Get workstreams
workstreams, err := lib.EntriesByParent(h.DB, h.Cfg, projectID)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read workstreams")
return
}
JSONResponse(w, http.StatusOK, map[string]any{
"project": project,
"workstreams": workstreams,
})
}
// CreateWorkstream handles POST /api/projects/{projectID}/workstreams
func (h *Handlers) CreateWorkstream(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
projectID := chi.URLParam(r, "projectID")
if err := lib.CheckAccessWrite(h.DB, actorID, projectID, ""); err != nil {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
return
}
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid_json", "Invalid request body")
return
}
if req.Name == "" {
ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Name required")
return
}
entry := &lib.Entry{
ProjectID: projectID,
ParentID: projectID,
Type: lib.TypeWorkstream,
Depth: 1,
SummaryText: req.Name,
DataText: `{"name":"` + req.Name + `"}`,
Stage: lib.StagePreDataroom,
}
if err := lib.EntryWrite(h.DB, h.Cfg, actorID, entry); err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create workstream")
return
}
JSONResponse(w, http.StatusCreated, entry)
}
// UploadObject handles POST /api/projects/{projectID}/objects (file upload)
func (h *Handlers) UploadObject(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
projectID := chi.URLParam(r, "projectID")
if err := lib.CheckAccessWrite(h.DB, actorID, projectID, ""); err != nil {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
return
}
r.Body = http.MaxBytesReader(w, r.Body, 50<<20) // 50MB max
if err := r.ParseMultipartForm(50 << 20); err != nil {
ErrorResponse(w, http.StatusBadRequest, "file_too_large", "File too large (max 50MB)")
return
}
file, header, err := r.FormFile("file")
if err != nil {
ErrorResponse(w, http.StatusBadRequest, "missing_file", "No file provided")
return
}
defer file.Close()
data, err := io.ReadAll(file)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to read file")
return
}
objectID, err := h.Store.Write(projectID, data)
if err != nil {
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to store file")
return
}
JSONResponse(w, http.StatusCreated, map[string]string{
"object_id": objectID,
"filename": header.Filename,
})
}
// DownloadObject handles GET /api/projects/{projectID}/objects/{objectID}
func (h *Handlers) DownloadObject(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
projectID := chi.URLParam(r, "projectID")
objectID := chi.URLParam(r, "objectID")
if err := lib.CheckAccessRead(h.DB, actorID, projectID, ""); err != nil {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
return
}
data, err := h.Store.Read(projectID, objectID)
if err != nil {
ErrorResponse(w, http.StatusNotFound, "not_found", "Object not found")
return
}
user, _ := lib.UserByID(h.DB, actorID)
userName := "Unknown"
if user != nil {
userName = user.Name
}
// Add watermark header for PDFs
filename := r.URL.Query().Get("filename")
if filename == "" {
filename = objectID
}
w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"")
w.Header().Set("X-Watermark", userName+" - "+time.Now().Format("2006-01-02 15:04")+" - CONFIDENTIAL")
w.Header().Set("Content-Type", "application/octet-stream")
w.Write(data)
}
// ---------------------------------------------------------------------------
// Template serving handlers
// ---------------------------------------------------------------------------
func (h *Handlers) serveTemplate(w http.ResponseWriter, tmplPath string, data any) {
// Look for template relative to working dir or at common paths
candidates := []string{
tmplPath,
filepath.Join("portal/templates", tmplPath),
filepath.Join("/opt/dealspace/portal/templates", tmplPath),
}
var tmpl *template.Template
var err error
for _, p := range candidates {
if _, statErr := os.Stat(p); statErr == nil {
tmpl, err = template.ParseFiles(p)
if err == nil {
break
}
}
}
if tmpl == nil {
http.Error(w, "Template not found: "+tmplPath, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
tmpl.Execute(w, data)
}
// ServeLogin serves the login page
func (h *Handlers) ServeLogin(w http.ResponseWriter, r *http.Request) {
h.serveTemplate(w, "auth/login.html", nil)
}
// ServeAppTasks serves the tasks page
func (h *Handlers) ServeAppTasks(w http.ResponseWriter, r *http.Request) {
h.serveTemplate(w, "app/tasks.html", nil)
}
// ServeAppProjects serves the projects page
func (h *Handlers) ServeAppProjects(w http.ResponseWriter, r *http.Request) {
h.serveTemplate(w, "app/projects.html", nil)
}
// ServeAppProject serves a single project page
func (h *Handlers) ServeAppProject(w http.ResponseWriter, r *http.Request) {
h.serveTemplate(w, "app/project.html", nil)
}
// ServeAppRequest serves a request detail page
func (h *Handlers) ServeAppRequest(w http.ResponseWriter, r *http.Request) {
h.serveTemplate(w, "app/request.html", nil)
}
// GetRequestDetail handles GET /api/requests/{requestID}
func (h *Handlers) GetRequestDetail(w http.ResponseWriter, r *http.Request) {
actorID := UserIDFromContext(r.Context())
requestID := chi.URLParam(r, "requestID")
entry, err := lib.EntryByID(h.DB, h.Cfg, requestID)
if err != nil || entry == nil {
ErrorResponse(w, http.StatusNotFound, "not_found", "Request not found")
return
}
// Check access
if err := lib.CheckAccessRead(h.DB, actorID, entry.ProjectID, ""); err != nil {
ErrorResponse(w, http.StatusForbidden, "access_denied", "Access denied")
return
}
// Get children (answers, comments)
children, err := lib.EntriesByParent(h.DB, h.Cfg, requestID)
if err != nil {
children = []lib.Entry{}
}
JSONResponse(w, http.StatusOK, map[string]any{
"request": entry,
"children": children,
})
}
func generateToken() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}