68eaee0b9f
Security: - Encrypt Gitea tokens at rest (AES-256-GCM with MH_SECRET) - Secure cookie flag when behind HTTPS (X-Forwarded-Proto) - Password complexity (min 8 chars) - TOTP: defer persist until verified (totp_pending column) - Audit log table + logging on login/rename/password change Features: - Rename files/folders (double-click in tree, /api/files/rename) - beforeunload warning for unsaved changes - Mobile hamburger menu - PWA icons (192px, 512px) - Max file size enforcement (10MB) - Shared file read access (cross-user with permission check) Polish: - Toast notifications replace all alert() calls - Keyboard shortcut help overlay (Ctrl+/) - File rename via double-click in FileTree
209 lines
6.5 KiB
Go
209 lines
6.5 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"markdownhub/internal/crypto"
|
|
"markdownhub/internal/files"
|
|
)
|
|
|
|
// ─── Build Jobs ──────────────────────────────────────────────────────────────
|
|
|
|
func (s *Server) handleBuildSubmit(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
FileID string `json:"file_id"`
|
|
Path string `json:"path"`
|
|
GiteaURL string `json:"gitea_url"`
|
|
GiteaToken string `json:"gitea_token"`
|
|
GiteaOrg string `json:"gitea_org"`
|
|
RepoName string `json:"repo_name"`
|
|
Model string `json:"model"`
|
|
}
|
|
if err := decodeBody(r, &req); err != nil || req.RepoName == "" {
|
|
writeJSON(w, 400, map[string]string{"error": "repo_name required"})
|
|
return
|
|
}
|
|
|
|
userID := getUserID(r)
|
|
|
|
// Read spec content from file
|
|
var specContent string
|
|
if req.Path != "" {
|
|
content, err := files.ReadFile(s.dataDir, userID, req.Path)
|
|
if err != nil {
|
|
writeJSON(w, 404, map[string]string{"error": "spec file not found"})
|
|
return
|
|
}
|
|
specContent = content
|
|
}
|
|
|
|
jobID := uuid.New().String()
|
|
// Encrypt Gitea token at rest
|
|
encToken := req.GiteaToken
|
|
if encToken != "" {
|
|
key := crypto.KeyFromSecret(s.secret)
|
|
if enc, err := crypto.EncryptString(encToken, key); err == nil {
|
|
encToken = enc
|
|
}
|
|
}
|
|
_, err := s.db.Exec(
|
|
`INSERT INTO build_jobs (id, user_id, status, spec_content, gitea_url, gitea_token, gitea_org, repo_name, model)
|
|
VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?)`,
|
|
jobID, userID, specContent, req.GiteaURL, encToken, req.GiteaOrg, req.RepoName, req.Model,
|
|
)
|
|
if err != nil {
|
|
writeJSON(w, 500, map[string]string{"error": "failed to create job"})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, 201, map[string]string{"job_id": jobID, "status": "pending"})
|
|
}
|
|
|
|
func (s *Server) handleBuildJobs(w http.ResponseWriter, r *http.Request) {
|
|
userID := getUserID(r)
|
|
rows, err := s.db.Query(
|
|
"SELECT id, status, repo_name, model, created_at, updated_at FROM build_jobs WHERE user_id = ? ORDER BY created_at DESC LIMIT 50",
|
|
userID,
|
|
)
|
|
if err != nil {
|
|
writeJSON(w, 500, map[string]string{"error": "query failed"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var jobs []map[string]interface{}
|
|
for rows.Next() {
|
|
var id, status, repoName, model, createdAt, updatedAt string
|
|
rows.Scan(&id, &status, &repoName, &model, &createdAt, &updatedAt)
|
|
jobs = append(jobs, map[string]interface{}{
|
|
"job_id": id, "status": status, "repo_name": repoName,
|
|
"model": model, "created_at": createdAt, "updated_at": updatedAt,
|
|
})
|
|
}
|
|
if jobs == nil {
|
|
jobs = []map[string]interface{}{}
|
|
}
|
|
writeJSON(w, 200, jobs)
|
|
}
|
|
|
|
func (s *Server) handleBuildStatus(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
JobID string `json:"job_id"`
|
|
}
|
|
if err := decodeBody(r, &req); err != nil || req.JobID == "" {
|
|
writeJSON(w, 400, map[string]string{"error": "job_id required"})
|
|
return
|
|
}
|
|
|
|
var status, repoName, log, updatedAt string
|
|
err := s.db.QueryRow(
|
|
"SELECT status, repo_name, log, updated_at FROM build_jobs WHERE id = ?", req.JobID,
|
|
).Scan(&status, &repoName, &log, &updatedAt)
|
|
if err != nil {
|
|
writeJSON(w, 404, map[string]string{"error": "job not found"})
|
|
return
|
|
}
|
|
|
|
writeJSON(w, 200, map[string]interface{}{
|
|
"job_id": req.JobID, "status": status, "repo_name": repoName,
|
|
"log": log, "updated_at": updatedAt,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleBuildCancel(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
JobID string `json:"job_id"`
|
|
}
|
|
if err := decodeBody(r, &req); err != nil || req.JobID == "" {
|
|
writeJSON(w, 400, map[string]string{"error": "job_id required"})
|
|
return
|
|
}
|
|
|
|
s.db.Exec("UPDATE build_jobs SET status = 'cancelled', updated_at = datetime('now') WHERE id = ? AND status = 'pending'", req.JobID)
|
|
writeJSON(w, 200, map[string]string{"status": "cancelled"})
|
|
}
|
|
|
|
// ─── Daemon Endpoints ────────────────────────────────────────────────────────
|
|
|
|
func (s *Server) handleDaemonPoll(w http.ResponseWriter, r *http.Request) {
|
|
var id, specContent, giteaURL, giteaToken, giteaOrg, repoName, model string
|
|
err := s.db.QueryRow(
|
|
`SELECT id, spec_content, gitea_url, gitea_token, gitea_org, repo_name, model
|
|
FROM build_jobs WHERE status = 'pending' ORDER BY created_at ASC LIMIT 1`,
|
|
).Scan(&id, &specContent, &giteaURL, &giteaToken, &giteaOrg, &repoName, &model)
|
|
if err != nil {
|
|
// No pending jobs
|
|
writeJSON(w, 200, map[string]interface{}{})
|
|
return
|
|
}
|
|
|
|
// Mark as picked up
|
|
s.db.Exec("UPDATE build_jobs SET status = 'running', updated_at = datetime('now') WHERE id = ?", id)
|
|
|
|
// Decrypt token
|
|
if giteaToken != "" {
|
|
key := crypto.KeyFromSecret(s.secret)
|
|
if dec, err := crypto.DecryptString(giteaToken, key); err == nil {
|
|
giteaToken = dec
|
|
}
|
|
}
|
|
|
|
writeJSON(w, 200, map[string]interface{}{
|
|
"job_id": id,
|
|
"spec_content": specContent,
|
|
"gitea_url": giteaURL,
|
|
"gitea_token": giteaToken,
|
|
"gitea_org": giteaOrg,
|
|
"repo_name": repoName,
|
|
"model": model,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleDaemonHeartbeat(w http.ResponseWriter, r *http.Request) {
|
|
s.db.Exec(
|
|
`INSERT INTO daemon_state (id, last_heartbeat, status) VALUES ('singleton', datetime('now'), 'online')
|
|
ON CONFLICT(id) DO UPDATE SET last_heartbeat = datetime('now'), status = 'online'`,
|
|
)
|
|
writeJSON(w, 200, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) handleDaemonReport(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
JobID string `json:"job_id"`
|
|
Status string `json:"status"`
|
|
Log string `json:"log"`
|
|
}
|
|
if err := decodeBody(r, &req); err != nil || req.JobID == "" {
|
|
writeJSON(w, 400, map[string]string{"error": "job_id required"})
|
|
return
|
|
}
|
|
|
|
s.db.Exec(
|
|
"UPDATE build_jobs SET status = ?, log = ?, updated_at = datetime('now') WHERE id = ?",
|
|
req.Status, req.Log, req.JobID,
|
|
)
|
|
writeJSON(w, 200, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
// ─── File Search ─────────────────────────────────────────────────────────────
|
|
|
|
func (s *Server) handleSearchFiles(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Query string `json:"query"`
|
|
}
|
|
if err := decodeBody(r, &req); err != nil || req.Query == "" {
|
|
writeJSON(w, 400, map[string]string{"error": "query required"})
|
|
return
|
|
}
|
|
|
|
userID := getUserID(r)
|
|
results, err := files.Search(s.dataDir, userID, req.Query)
|
|
if err != nil {
|
|
writeJSON(w, 500, map[string]string{"error": "search failed"})
|
|
return
|
|
}
|
|
writeJSON(w, 200, results)
|
|
}
|