745 lines
21 KiB
Go
745 lines
21 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"html/template"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/mish/dealspace/lib"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Login handles POST /api/auth/login
|
|
func (h *Handlers) Login(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
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 == "" || req.Password == "" {
|
|
ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Email and password required")
|
|
return
|
|
}
|
|
|
|
user, err := lib.UserByEmail(h.DB, req.Email)
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Login failed")
|
|
return
|
|
}
|
|
if user == nil {
|
|
ErrorResponse(w, http.StatusUnauthorized, "invalid_credentials", "Invalid email or password")
|
|
return
|
|
}
|
|
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
|
|
ErrorResponse(w, http.StatusUnauthorized, "invalid_credentials", "Invalid email or password")
|
|
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
|
|
}
|
|
|
|
JSONResponse(w, http.StatusOK, map[string]any{
|
|
"token": token,
|
|
"user": map[string]string{
|
|
"id": user.UserID,
|
|
"name": user.Name,
|
|
"email": user.Email,
|
|
"role": "ib_admin", // simplified for now
|
|
},
|
|
})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
JSONResponse(w, http.StatusOK, map[string]string{
|
|
"id": user.UserID,
|
|
"name": user.Name,
|
|
"email": user.Email,
|
|
})
|
|
}
|
|
|
|
// Setup handles POST /api/setup (first-run admin creation)
|
|
func (h *Handlers) Setup(w http.ResponseWriter, r *http.Request) {
|
|
count, err := lib.UserCount(h.DB)
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to check users")
|
|
return
|
|
}
|
|
if count > 0 {
|
|
ErrorResponse(w, http.StatusForbidden, "setup_complete", "Setup already completed")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
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.Name == "" || req.Email == "" || req.Password == "" {
|
|
ErrorResponse(w, http.StatusBadRequest, "missing_fields", "Name, email, and password required")
|
|
return
|
|
}
|
|
if len(req.Password) < 8 {
|
|
ErrorResponse(w, http.StatusBadRequest, "weak_password", "Password must be at least 8 characters")
|
|
return
|
|
}
|
|
|
|
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to hash password")
|
|
return
|
|
}
|
|
|
|
now := time.Now().UnixMilli()
|
|
user := &lib.User{
|
|
UserID: uuid.New().String(),
|
|
Email: req.Email,
|
|
Name: req.Name,
|
|
Password: string(hashed),
|
|
OrgID: "admin",
|
|
OrgName: "Dealspace",
|
|
Active: true,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
if err := lib.UserCreate(h.DB, user); err != nil {
|
|
ErrorResponse(w, http.StatusInternalServerError, "internal", "Failed to create user")
|
|
return
|
|
}
|
|
|
|
JSONResponse(w, http.StatusCreated, map[string]string{
|
|
"status": "ok",
|
|
"user_id": user.UserID,
|
|
"message": "Admin account created. You can now log in.",
|
|
})
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// ServeSetup serves the setup page (only if no users exist)
|
|
func (h *Handlers) ServeSetup(w http.ResponseWriter, r *http.Request) {
|
|
count, _ := lib.UserCount(h.DB)
|
|
if count > 0 {
|
|
http.Redirect(w, r, "/app/login", http.StatusFound)
|
|
return
|
|
}
|
|
h.serveTemplate(w, "auth/setup.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)
|
|
}
|