Add tm_server with SQLite, Svelte UI, and task-master TUI sync.
Introduces the web backend (auth, RBAC, project tokens, revisions), YouTrack-or-taskmaster backend choice in the TUI, and a first-pass embedded Svelte UI. The frontend is functional but needs a major rework. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
body.Username = strings.TrimSpace(body.Username)
|
||||
u, err := s.DB.GetUserByUsername(body.Username)
|
||||
if err != nil || !sqlite.CheckPassword(body.Password, u.PasswordHash) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
token, exp, err := s.DB.CreateSession(u.ID, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "session failed")
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w, token, exp)
|
||||
u.PasswordHash = ""
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
|
||||
_ = s.DB.DeleteSession(c.Value)
|
||||
}
|
||||
s.clearSessionCookie(w)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := s.DB.ListUsers()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
u, err := s.DB.CreateUser(body.Username, body.Password, body.IsAdmin)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
u.PasswordHash = ""
|
||||
writeJSON(w, http.StatusCreated, u)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"task/internal/project"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// ImportJSONDataDir copies projects/tickets/time from a local JSON DataDir into SQLite.
|
||||
func (s *Server) ImportJSONDataDir(dataDir string, ownerUserID string) error {
|
||||
ps := project.NewDirStore(dataDir)
|
||||
ts := ticket.NewJSONStore(dataDir)
|
||||
timeStore := ticket.NewJSONTimeStore(dataDir)
|
||||
|
||||
projects, err := ps.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range projects {
|
||||
if err := s.Projects.Ensure(p.ID); err != nil {
|
||||
return fmt.Errorf("project %s: %w", p.ID, err)
|
||||
}
|
||||
if ownerUserID != "" {
|
||||
_ = s.DB.SetMember(p.ID, ownerUserID, "owner")
|
||||
}
|
||||
meta, err := ps.LoadMeta(p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if meta.YouTrack != nil && meta.YouTrack.Configured() {
|
||||
if err := s.Projects.SaveMeta(p.ID, meta); err != nil {
|
||||
return fmt.Errorf("meta %s: %w", p.ID, err)
|
||||
}
|
||||
}
|
||||
tickets, err := ts.List(p.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range tickets {
|
||||
if _, err := s.Tickets.Get(t.ID); err == nil {
|
||||
continue // already imported
|
||||
}
|
||||
if _, err := s.Tickets.Create(t); err != nil {
|
||||
return fmt.Errorf("ticket %s: %w", t.ID, err)
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, p.ID, "import", ownerUserID, t)
|
||||
}
|
||||
}
|
||||
|
||||
entries, err := timeStore.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, _ := s.Times.List()
|
||||
have := map[string]bool{}
|
||||
for _, e := range existing {
|
||||
have[e.ID] = true
|
||||
}
|
||||
for _, e := range entries {
|
||||
if have[e.ID] {
|
||||
continue
|
||||
}
|
||||
if _, err := s.Times.Create(e); err != nil {
|
||||
return fmt.Errorf("time %s: %w", e.ID, err)
|
||||
}
|
||||
}
|
||||
if active, err := timeStore.LoadActiveTimer(); err == nil && active != nil {
|
||||
_ = s.Times.SaveActiveTimer(active)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/project"
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func TestImportJSONDataDir(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
ps := project.NewDirStore(dataDir)
|
||||
if err := ps.Ensure("inbox"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts := ticket.NewJSONStore(dataDir)
|
||||
created, err := ts.Create(ticket.Ticket{Title: "From JSON", ProjectID: "inbox"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "import.db")
|
||||
db, err := sqlite.Open(dbPath, []byte("sec"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
u, err := db.CreateUser("admin", "x", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := New(db)
|
||||
if err := srv.ImportJSONDataDir(dataDir, u.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := srv.Tickets.Get(created.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Title != "From JSON" {
|
||||
t.Fatalf("title=%q", got.Title)
|
||||
}
|
||||
role, _ := db.MemberRole("inbox", u.ID)
|
||||
if role != sqlite.RoleOwner {
|
||||
t.Fatalf("role=%s", role)
|
||||
}
|
||||
// second import is idempotent
|
||||
if err := srv.ImportJSONDataDir(dataDir, u.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.RemoveAll(dataDir) // ensure we don't rely on JSON after import for this test cleanup
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func readJSON(r *http.Request, dest any) error {
|
||||
defer r.Body.Close()
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
return dec.Decode(dest)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
userKey ctxKey = 1
|
||||
tokenAuthKey ctxKey = 2
|
||||
)
|
||||
|
||||
func (s *Server) currentUser(r *http.Request) (sqlite.User, bool) {
|
||||
u, ok := r.Context().Value(userKey).(sqlite.User)
|
||||
return u, ok
|
||||
}
|
||||
|
||||
func (s *Server) currentTokenAuth(r *http.Request) (sqlite.TokenAuth, bool) {
|
||||
t, ok := r.Context().Value(tokenAuthKey).(sqlite.TokenAuth)
|
||||
return t, ok
|
||||
}
|
||||
|
||||
func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, ok := s.authenticate(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
next(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) authenticate(r *http.Request) (context.Context, bool) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(strings.ToLower(auth), "bearer ") {
|
||||
raw := strings.TrimSpace(auth[len("Bearer "):])
|
||||
ta, err := s.DB.ResolveBearerToken(raw)
|
||||
if err != nil {
|
||||
return r.Context(), false
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), userKey, ta.User)
|
||||
ctx = context.WithValue(ctx, tokenAuthKey, ta)
|
||||
return ctx, true
|
||||
}
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil || c.Value == "" {
|
||||
return r.Context(), false
|
||||
}
|
||||
u, err := s.DB.UserFromSession(c.Value)
|
||||
if err != nil {
|
||||
return r.Context(), false
|
||||
}
|
||||
u.PasswordHash = ""
|
||||
return context.WithValue(r.Context(), userKey, u), true
|
||||
}
|
||||
|
||||
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
|
||||
return s.requireAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, tok := s.currentTokenAuth(r); tok {
|
||||
writeError(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
u, _ := s.currentUser(r)
|
||||
if !u.IsAdmin {
|
||||
writeError(w, http.StatusForbidden, "admin required")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) requireProjectRole(projectID string, minRole string, r *http.Request) (sqlite.User, bool, int) {
|
||||
u, ok := s.currentUser(r)
|
||||
if !ok {
|
||||
return sqlite.User{}, false, http.StatusUnauthorized
|
||||
}
|
||||
if ta, ok := s.currentTokenAuth(r); ok {
|
||||
if ta.ProjectID != projectID {
|
||||
return u, false, http.StatusForbidden
|
||||
}
|
||||
need := sqlite.TokenScopeRead
|
||||
if minRole == sqlite.RoleMember || minRole == sqlite.RoleAdmin || minRole == sqlite.RoleOwner {
|
||||
need = sqlite.TokenScopeWrite
|
||||
}
|
||||
// viewer → read; member+ → write
|
||||
if minRole == sqlite.RoleViewer {
|
||||
need = sqlite.TokenScopeRead
|
||||
}
|
||||
if !sqlite.TokenAllows(ta.Scope, need) {
|
||||
return u, false, http.StatusForbidden
|
||||
}
|
||||
return u, true, 0
|
||||
}
|
||||
if u.IsAdmin {
|
||||
return u, true, 0
|
||||
}
|
||||
role, err := s.DB.MemberRole(projectID, u.ID)
|
||||
if err != nil || role == "" {
|
||||
return u, false, http.StatusForbidden
|
||||
}
|
||||
if !sqlite.RoleAtLeast(role, minRole) {
|
||||
return u, false, http.StatusForbidden
|
||||
}
|
||||
return u, true, 0
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func (s *Server) handleListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
projects, err := s.Projects.ListIDsForUser(u.ID, u.IsAdmin)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, projects)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
body.Name = strings.TrimSpace(body.Name)
|
||||
if body.Name == "" {
|
||||
writeError(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
p, err := s.Projects.Create("", body.Name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.DB.SetMember(p.ID, u.ID, sqlite.RoleOwner)
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetProject(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
p, err := s.Projects.Get(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchProject(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if err := s.Projects.Rename(id, body.Name); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
p, _ := s.Projects.Get(id)
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleOwner, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
_ = s.Times.DeleteByProject(id)
|
||||
if err := s.Projects.Delete(id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
members, err := s.DB.ListMembers(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, members)
|
||||
}
|
||||
|
||||
func (s *Server) handleSetMember(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
userID := r.PathValue("userId")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if err := s.DB.SetMember(id, userID, body.Role); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
userID := r.PathValue("userId")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
if err := s.DB.RemoveMember(id, userID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func (s *Server) handleProjectReport(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
groupBy := ticket.GroupBy(r.URL.Query().Get("groupBy"))
|
||||
if groupBy == "" {
|
||||
groupBy = ticket.GroupByTicket
|
||||
}
|
||||
valid := false
|
||||
for _, g := range ticket.AllGroupBys {
|
||||
if g == groupBy {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
writeError(w, http.StatusBadRequest, "invalid groupBy")
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := s.Times.ListByProject(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
tickets, err := s.Tickets.List(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
byID := make(map[string]ticket.Ticket, len(tickets))
|
||||
for _, t := range tickets {
|
||||
byID[t.ID] = t
|
||||
}
|
||||
rows := ticket.Aggregate(entries, byID, groupBy)
|
||||
total := ticket.TotalDuration(entries)
|
||||
|
||||
type rowJSON struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
DurationNs int64 `json:"durationNs"`
|
||||
}
|
||||
out := make([]rowJSON, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, rowJSON{
|
||||
Key: row.Key,
|
||||
Label: row.Label,
|
||||
DurationNs: int64(row.Duration),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"groupBy": groupBy,
|
||||
"totalDurationNs": int64(total),
|
||||
"rows": out,
|
||||
"entryCount": len(entries),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
// SeedDemo populates sample users/projects/tickets when the DB has no projects yet.
|
||||
// No external backends (YouTrack etc.) — the web UI talks only to this tm_server.
|
||||
func (s *Server) SeedDemo() error {
|
||||
projects, err := s.Projects.List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(projects) > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
users, err := s.DB.ListUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(users) == 0 {
|
||||
return fmt.Errorf("seed: no users (bootstrap admin first)")
|
||||
}
|
||||
admin := users[0]
|
||||
for _, u := range users {
|
||||
if u.IsAdmin {
|
||||
admin = u
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
member, err := s.DB.CreateUser("alice", "alice", false)
|
||||
if err != nil {
|
||||
member, err = s.DB.GetUserByUsername("alice")
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed alice: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
type seedTicket struct {
|
||||
Title, Desc string
|
||||
Status ticket.Status
|
||||
}
|
||||
demos := []struct {
|
||||
Name string
|
||||
Tickets []seedTicket
|
||||
}{
|
||||
{
|
||||
Name: "Test",
|
||||
Tickets: []seedTicket{
|
||||
{Title: "Board & Login prüfen", Desc: "Mit admin/admin einloggen, Board öffnen, Status ändern.\n\n- [ ] Login\n- [ ] Ticket editieren\n- [ ] Timer starten", Status: ticket.StatusInProgress},
|
||||
{Title: "Zeit auf Ticket buchen", Desc: "Im Ticket-Detail Timer oder manuelle Minuten buchen, dann Report prüfen.", Status: ticket.StatusTodo},
|
||||
{Title: "Members & Rechte", Desc: "alice als Member ist schon angelegt — Memberships-Tab ansehen.", Status: ticket.StatusTodo},
|
||||
{Title: "TUI anbinden", Desc: "Settings → Token (write) erzeugen → in `tm` Projekt-Edit backend=taskmaster eintragen (URL, Token, diese Project-ID).", Status: ticket.StatusTodo},
|
||||
{Title: "Seed abgeschlossen", Desc: "Beispiel-Ticket im Status DONE.", Status: ticket.StatusDone},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Website Relaunch",
|
||||
Tickets: []seedTicket{
|
||||
{Title: "Landing Hero entwerfen", Desc: "Dunkles Layout, klare CTAs.\n\n- [ ] Moodboard\n- [ ] Wireframe", Status: ticket.StatusInProgress},
|
||||
{Title: "CMS-Inhalte migrieren", Desc: "Alte Seiten nach Markdown überführen.", Status: ticket.StatusTodo},
|
||||
{Title: "Impressum & Datenschutz", Desc: "Rechtstexte finalisieren.", Status: ticket.StatusDone},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "API Platform",
|
||||
Tickets: []seedTicket{
|
||||
{Title: "Auth-Middleware härten", Desc: "Session-Cookies dokumentieren.", Status: ticket.StatusInProgress},
|
||||
{Title: "Rate-Limits festlegen", Desc: "Pro User und pro IP.", Status: ticket.StatusTodo},
|
||||
{Title: "Health-Check", Desc: "Bereits live.", Status: ticket.StatusDone},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
for _, d := range demos {
|
||||
p, err := s.Projects.Create("", d.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = s.DB.SetMember(p.ID, admin.ID, sqlite.RoleOwner)
|
||||
_ = s.DB.SetMember(p.ID, member.ID, sqlite.RoleMember)
|
||||
// Explicit local-only sync (no remote backend in the test environment).
|
||||
_ = s.Projects.SetSyncBackend(sqlite.SyncBackend{
|
||||
ProjectID: p.ID,
|
||||
Kind: "none",
|
||||
})
|
||||
|
||||
var firstID string
|
||||
for i, st := range d.Tickets {
|
||||
t, err := s.Tickets.Create(ticket.Ticket{
|
||||
Title: st.Title,
|
||||
Description: st.Desc,
|
||||
Status: st.Status,
|
||||
ProjectID: p.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, p.ID, "seed", admin.ID, t)
|
||||
if i == 0 {
|
||||
firstID = t.ID
|
||||
}
|
||||
}
|
||||
if firstID != "" {
|
||||
_, err := s.Times.Create(ticket.TimeEntry{
|
||||
TicketID: firstID,
|
||||
ProjectID: p.ID,
|
||||
Start: now.Add(-2 * time.Hour),
|
||||
End: now.Add(-90 * time.Minute),
|
||||
Duration: 30 * time.Minute,
|
||||
Source: ticket.SourceManual,
|
||||
Note: "seed",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "seeded demo: Test + sample projects (users %s + alice/alice), sync=none\n", admin.Username)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func TestSeedDemo(t *testing.T) {
|
||||
db, err := sqlite.Open(filepath.Join(t.TempDir(), "seed.db"), []byte("s"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.CreateUser("admin", "admin", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := New(db)
|
||||
if err := srv.SeedDemo(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projects, err := srv.Projects.List()
|
||||
if err != nil || len(projects) < 3 {
|
||||
t.Fatalf("projects=%v err=%v", projects, err)
|
||||
}
|
||||
var testID string
|
||||
for _, p := range projects {
|
||||
if p.Name == "Test" {
|
||||
testID = p.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if testID == "" {
|
||||
t.Fatal("missing Test project")
|
||||
}
|
||||
sync, err := srv.Projects.GetSyncBackend(testID)
|
||||
if err != nil || sync.Kind != "none" {
|
||||
t.Fatalf("sync=%+v err=%v", sync, err)
|
||||
}
|
||||
tickets, err := srv.Tickets.List(testID)
|
||||
if err != nil || len(tickets) == 0 {
|
||||
t.Fatalf("tickets empty")
|
||||
}
|
||||
conns, err := db.ListConnections()
|
||||
if err != nil || len(conns) != 0 {
|
||||
t.Fatalf("expected no remote connections, got %v", conns)
|
||||
}
|
||||
// idempotent when projects already exist
|
||||
if err := srv.SeedDemo(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projects2, _ := srv.Projects.List()
|
||||
if len(projects2) != len(projects) {
|
||||
t.Fatalf("re-seed changed project count")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/syncbackend"
|
||||
"task/internal/webembed"
|
||||
)
|
||||
|
||||
// Server is the HTTP API + embedded UI.
|
||||
type Server struct {
|
||||
DB *sqlite.DB
|
||||
Projects *sqlite.ProjectStore
|
||||
Tickets *sqlite.TicketStore
|
||||
Times *sqlite.TimeStore
|
||||
Sync *syncbackend.Registry
|
||||
Secure bool // set Secure on cookies when behind TLS
|
||||
Static fs.FS
|
||||
}
|
||||
|
||||
// New constructs a server from an open DB.
|
||||
func New(db *sqlite.DB) *Server {
|
||||
return &Server{
|
||||
DB: db,
|
||||
Projects: sqlite.NewProjectStore(db),
|
||||
Tickets: sqlite.NewTicketStore(db),
|
||||
Times: sqlite.NewTimeStore(db),
|
||||
Sync: syncbackend.NewRegistry(),
|
||||
Static: webembed.FS(),
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns the root HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
s.routes(mux)
|
||||
return withCORS(mux)
|
||||
}
|
||||
|
||||
func (s *Server) routes(mux *http.ServeMux) {
|
||||
api := http.NewServeMux()
|
||||
api.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
api.HandleFunc("POST /auth/login", s.handleLogin)
|
||||
api.HandleFunc("POST /auth/logout", s.requireAuth(s.handleLogout))
|
||||
api.HandleFunc("GET /auth/me", s.requireAuth(s.handleMe))
|
||||
|
||||
api.HandleFunc("GET /users", s.requireAdmin(s.handleListUsers))
|
||||
api.HandleFunc("POST /users", s.requireAdmin(s.handleCreateUser))
|
||||
api.HandleFunc("PATCH /users/{id}", s.requireAdmin(s.handlePatchUser))
|
||||
api.HandleFunc("DELETE /users/{id}", s.requireAdmin(s.handleDeleteUser))
|
||||
|
||||
api.HandleFunc("GET /dashboard", s.requireAuth(s.handleDashboard))
|
||||
|
||||
api.HandleFunc("GET /projects", s.requireAuth(s.handleListProjects))
|
||||
api.HandleFunc("POST /projects", s.requireAuth(s.handleCreateProject))
|
||||
api.HandleFunc("GET /projects/{id}", s.requireAuth(s.handleGetProject))
|
||||
api.HandleFunc("PATCH /projects/{id}", s.requireAuth(s.handlePatchProject))
|
||||
api.HandleFunc("DELETE /projects/{id}", s.requireAuth(s.handleDeleteProject))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/members", s.requireAuth(s.handleListMembers))
|
||||
api.HandleFunc("PUT /projects/{id}/members/{userId}", s.requireAuth(s.handleSetMember))
|
||||
api.HandleFunc("DELETE /projects/{id}/members/{userId}", s.requireAuth(s.handleRemoveMember))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/tokens", s.requireAuth(s.handleListTokens))
|
||||
api.HandleFunc("POST /projects/{id}/tokens", s.requireAuth(s.handleCreateToken))
|
||||
api.HandleFunc("DELETE /projects/{id}/tokens/{tid}", s.requireAuth(s.handleRevokeToken))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/sync", s.requireAuth(s.handleGetSync))
|
||||
api.HandleFunc("PUT /projects/{id}/sync", s.requireAuth(s.handlePutSync))
|
||||
api.HandleFunc("POST /projects/{id}/sync/pull", s.requireAuth(s.handleSyncPull))
|
||||
api.HandleFunc("POST /projects/{id}/sync/push", s.requireAuth(s.handleSyncPush))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/tickets", s.requireAuth(s.handleListTickets))
|
||||
api.HandleFunc("POST /projects/{id}/tickets", s.requireAuth(s.handleCreateTicket))
|
||||
api.HandleFunc("GET /projects/{id}/tickets/{tid}", s.requireAuth(s.handleGetTicket))
|
||||
api.HandleFunc("PATCH /projects/{id}/tickets/{tid}", s.requireAuth(s.handlePatchTicket))
|
||||
api.HandleFunc("DELETE /projects/{id}/tickets/{tid}", s.requireAuth(s.handleDeleteTicket))
|
||||
api.HandleFunc("POST /projects/{id}/tickets/{tid}/move", s.requireAuth(s.handleMoveTicket))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/tickets/{tid}/revisions", s.requireAuth(s.handleListTicketRevisions))
|
||||
api.HandleFunc("POST /projects/{id}/tickets/{tid}/revisions/{rid}/restore", s.requireAuth(s.handleRestoreTicketRevision))
|
||||
|
||||
api.HandleFunc("GET /projects/{id}/report", s.requireAuth(s.handleProjectReport))
|
||||
api.HandleFunc("GET /projects/{id}/time", s.requireAuth(s.handleListProjectTime))
|
||||
api.HandleFunc("POST /projects/{id}/time", s.requireAuth(s.handleCreateTime))
|
||||
api.HandleFunc("PATCH /projects/{id}/time/{eid}", s.requireAuth(s.handleUpdateTime))
|
||||
api.HandleFunc("DELETE /projects/{id}/time/{eid}", s.requireAuth(s.handleDeleteTime))
|
||||
api.HandleFunc("GET /timer", s.requireAuth(s.handleGetTimer))
|
||||
api.HandleFunc("POST /timer/start", s.requireAuth(s.handleStartTimer))
|
||||
api.HandleFunc("POST /timer/stop", s.requireAuth(s.handleStopTimer))
|
||||
|
||||
api.HandleFunc("GET /connections", s.requireAdmin(s.handleListConnections))
|
||||
api.HandleFunc("POST /connections", s.requireAdmin(s.handleCreateConnection))
|
||||
api.HandleFunc("DELETE /connections/{id}", s.requireAdmin(s.handleDeleteConnection))
|
||||
api.HandleFunc("GET /connections/{id}/projects", s.requireAdmin(s.handleConnectionProjects))
|
||||
api.HandleFunc("POST /connections/{id}/import", s.requireAdmin(s.handleImportProject))
|
||||
|
||||
mux.Handle("/api/v1/", http.StripPrefix("/api/v1", api))
|
||||
mux.Handle("/", s.spaHandler())
|
||||
}
|
||||
|
||||
const sessionCookie = "tm_session"
|
||||
|
||||
func (s *Server) setSessionCookie(w http.ResponseWriter, token string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.Secure,
|
||||
Expires: expires,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: s.Secure,
|
||||
MaxAge: -1,
|
||||
Expires: time.Unix(0, 0),
|
||||
})
|
||||
}
|
||||
|
||||
func withCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) spaHandler() http.Handler {
|
||||
static := s.Static
|
||||
if static == nil {
|
||||
static = webembed.FS()
|
||||
}
|
||||
fileServer := http.FileServer(http.FS(static))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if path == "" {
|
||||
path = "index.html"
|
||||
}
|
||||
if f, err := static.Open(path); err == nil {
|
||||
_ = f.Close()
|
||||
fileServer.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// SPA fallback
|
||||
r2 := r.Clone(r.Context())
|
||||
r2.URL.Path = "/index.html"
|
||||
fileServer.ServeHTTP(w, r2)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func TestAPIAuthAndTickets(t *testing.T) {
|
||||
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.CreateUser("admin", "pass", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := New(db)
|
||||
handler := srv.Handler()
|
||||
|
||||
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
cookie := rec.Result().Cookies()
|
||||
if len(cookie) == 0 {
|
||||
t.Fatal("no session cookie")
|
||||
}
|
||||
|
||||
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie[0])
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create project %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var proj map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
|
||||
pid, _ := proj["id"].(string)
|
||||
if pid == "" {
|
||||
t.Fatal("expected auto project id")
|
||||
}
|
||||
|
||||
ticketBody, _ := json.Marshal(map[string]string{"title": "First", "description": "hi"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie[0])
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create ticket %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var ticket map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &ticket)
|
||||
tid, _ := ticket["id"].(string)
|
||||
if tid == "" {
|
||||
t.Fatal("no ticket id")
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets/"+tid+"/revisions", nil)
|
||||
req.AddCookie(cookie[0])
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("revisions %d", rec.Code)
|
||||
}
|
||||
var revs []any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &revs)
|
||||
if len(revs) < 1 {
|
||||
t.Fatal("expected revision")
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("health %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/syncbackend"
|
||||
)
|
||||
|
||||
func (s *Server) handleGetSync(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
b, err := s.Projects.GetSyncBackend(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, b)
|
||||
}
|
||||
|
||||
func (s *Server) handlePutSync(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body sqlite.SyncBackend
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
body.ProjectID = id
|
||||
if body.Kind == "" {
|
||||
body.Kind = "none"
|
||||
}
|
||||
if err := s.Projects.SetSyncBackend(body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
func (s *Server) projectSyncConfig(projectID string) (syncbackend.ProjectConfig, error) {
|
||||
b, err := s.Projects.GetSyncBackend(projectID)
|
||||
if err != nil {
|
||||
return syncbackend.ProjectConfig{}, err
|
||||
}
|
||||
cfg := syncbackend.ProjectConfig{
|
||||
Kind: b.Kind,
|
||||
BaseURL: b.BaseURL,
|
||||
ProjectKey: b.ProjectKey,
|
||||
Query: b.Query,
|
||||
}
|
||||
if b.ConnectionID != "" {
|
||||
conn, err := s.DB.GetConnection(b.ConnectionID)
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
cfg.Token = conn.Token
|
||||
if cfg.BaseURL == "" {
|
||||
cfg.BaseURL = conn.BaseURL
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncPull(w http.ResponseWriter, r *http.Request) {
|
||||
s.runSync(w, r, true)
|
||||
}
|
||||
|
||||
func (s *Server) handleSyncPush(w http.ResponseWriter, r *http.Request) {
|
||||
s.runSync(w, r, false)
|
||||
}
|
||||
|
||||
func (s *Server) runSync(w http.ResponseWriter, r *http.Request, pull bool) {
|
||||
id := r.PathValue("id")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
cfg, err := s.projectSyncConfig(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
backend, err := s.Sync.Get(cfg.Kind)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
var res syncbackend.Result
|
||||
if pull {
|
||||
res, err = backend.Pull(r.Context(), id, cfg, s.Tickets, s.Times)
|
||||
} else {
|
||||
res, err = backend.Push(r.Context(), id, cfg, s.Tickets, s.Times)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
op := "sync_push"
|
||||
if pull {
|
||||
op = "sync_pull"
|
||||
}
|
||||
tickets, _ := s.Tickets.List(id)
|
||||
for _, t := range tickets {
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, id, op, u.ID, t)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, res)
|
||||
}
|
||||
|
||||
func (s *Server) handleListConnections(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := s.DB.ListConnections()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateConnection(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
Scope string `json:"scope"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.Kind == "" {
|
||||
body.Kind = "youtrack"
|
||||
}
|
||||
c, err := s.DB.CreateConnection(body.Name, body.Kind, body.BaseURL, body.Scope, body.Token, u.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, c)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteConnection(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if err := s.DB.DeleteConnection(id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleConnectionProjects(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
conn, err := s.DB.GetConnection(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
backend, err := s.Sync.Get(conn.Kind)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
projects, err := backend.ListProjects(r.Context(), syncbackend.Connection{
|
||||
Kind: conn.Kind, BaseURL: conn.BaseURL, Token: conn.Token, Scope: conn.Scope,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, projects)
|
||||
}
|
||||
|
||||
func (s *Server) handleImportProject(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
connID := r.PathValue("id")
|
||||
var body struct {
|
||||
RemoteKey string `json:"remoteKey"`
|
||||
LocalID string `json:"localId"`
|
||||
Name string `json:"name"`
|
||||
Pull bool `json:"pull"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
body.RemoteKey = strings.TrimSpace(body.RemoteKey)
|
||||
if body.RemoteKey == "" {
|
||||
writeError(w, http.StatusBadRequest, "remoteKey required")
|
||||
return
|
||||
}
|
||||
conn, err := s.DB.GetConnection(connID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = body.RemoteKey
|
||||
}
|
||||
// LocalID optional; empty → auto UUID. Explicit id only for advanced clients.
|
||||
p, err := s.Projects.Create(strings.TrimSpace(body.LocalID), name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.DB.SetMember(p.ID, u.ID, sqlite.RoleOwner)
|
||||
_ = s.Projects.SetSyncBackend(sqlite.SyncBackend{
|
||||
ProjectID: p.ID,
|
||||
Kind: conn.Kind,
|
||||
BaseURL: conn.BaseURL,
|
||||
ProjectKey: body.RemoteKey,
|
||||
ConnectionID: conn.ID,
|
||||
})
|
||||
result := map[string]any{"project": p}
|
||||
if body.Pull {
|
||||
cfg, err := s.projectSyncConfig(p.ID)
|
||||
if err == nil {
|
||||
if backend, err := s.Sync.Get(cfg.Kind); err == nil {
|
||||
res, err := backend.Pull(r.Context(), p.ID, cfg, s.Tickets, s.Times)
|
||||
if err != nil {
|
||||
result["pullError"] = err.Error()
|
||||
} else {
|
||||
result["pull"] = res
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, result)
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func (s *Server) handleListTickets(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
list, err := s.Tickets.List(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status ticket.Status `json:"status"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Create(ticket.Ticket{
|
||||
Title: body.Title,
|
||||
Description: body.Description,
|
||||
Status: body.Status,
|
||||
ProjectID: id,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "create", u.ID, t)
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status *ticket.Status `json:"status"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.Title != nil {
|
||||
t.Title = *body.Title
|
||||
}
|
||||
if body.Description != nil {
|
||||
t.Description = *body.Description
|
||||
}
|
||||
if body.Status != nil {
|
||||
t.Status = *body.Status
|
||||
}
|
||||
if err := s.Tickets.Update(t); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
t, _ = s.Tickets.Get(tid)
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "update", u.ID, t)
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("ticket", t.ID, id, "delete", u.ID, t)
|
||||
if err := s.Tickets.Delete(tid); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleMoveTicket(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ToProjectID string `json:"toProjectId"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if _, ok, code := s.requireProjectRole(body.ToProjectID, sqlite.RoleMember, r); !ok {
|
||||
writeError(w, code, "forbidden target project")
|
||||
return
|
||||
}
|
||||
t, err := s.Tickets.Get(tid)
|
||||
if err != nil || t.ProjectID != id {
|
||||
writeError(w, http.StatusNotFound, "ticket not found")
|
||||
return
|
||||
}
|
||||
moved, err := s.Tickets.Move(tid, body.ToProjectID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.Times.RetargetTicket(tid, body.ToProjectID)
|
||||
_, _ = s.DB.AppendRevision("ticket", moved.ID, body.ToProjectID, "move", u.ID, moved)
|
||||
writeJSON(w, http.StatusOK, moved)
|
||||
}
|
||||
|
||||
func (s *Server) handleListTicketRevisions(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
revs, err := s.DB.ListRevisions("ticket", tid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, revs)
|
||||
}
|
||||
|
||||
func (s *Server) handleRestoreTicketRevision(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
rid := r.PathValue("rid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var revID int64
|
||||
if _, err := parseInt64(rid, &revID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid revision id")
|
||||
return
|
||||
}
|
||||
rev, err := s.DB.GetRevision(revID)
|
||||
if err != nil || rev.EntityID != tid {
|
||||
writeError(w, http.StatusNotFound, "revision not found")
|
||||
return
|
||||
}
|
||||
var snap ticket.Ticket
|
||||
if err := json.Unmarshal(rev.Snapshot, &snap); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "bad snapshot")
|
||||
return
|
||||
}
|
||||
snap.ID = tid
|
||||
snap.ProjectID = id
|
||||
if err := s.Tickets.Update(snap); err != nil {
|
||||
// recreate if deleted
|
||||
if _, cerr := s.Tickets.Create(snap); cerr != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
t, _ := s.Tickets.Get(tid)
|
||||
_, _ = s.DB.AppendRevision("ticket", tid, id, "restore", u.ID, t)
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func parseInt64(s string, dest *int64) (int64, error) {
|
||||
var n int64
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, errInvalid
|
||||
}
|
||||
n = n*10 + int64(c-'0')
|
||||
}
|
||||
*dest = n
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type invalidErr struct{}
|
||||
|
||||
func (invalidErr) Error() string { return "invalid" }
|
||||
|
||||
var errInvalid = invalidErr{}
|
||||
@@ -0,0 +1,197 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"task/internal/sqlite"
|
||||
"task/internal/ticket"
|
||||
)
|
||||
|
||||
func (s *Server) handleListProjectTime(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleViewer, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
list, err := s.Times.ListByProject(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateTime(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
TicketID string `json:"ticketId"`
|
||||
Duration int64 `json:"durationNs"`
|
||||
Note string `json:"note"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
e, err := s.Times.Create(ticket.TimeEntry{
|
||||
TicketID: body.TicketID,
|
||||
ProjectID: id,
|
||||
Duration: time.Duration(body.Duration),
|
||||
Note: body.Note,
|
||||
Source: body.Source,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("time_entry", e.ID, id, "create", u.ID, e)
|
||||
writeJSON(w, http.StatusCreated, e)
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateTime(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
eid := r.PathValue("eid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Duration int64 `json:"durationNs"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
entries, err := s.Times.ListByProject(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
var found *ticket.TimeEntry
|
||||
for i := range entries {
|
||||
if entries[i].ID == eid {
|
||||
found = &entries[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
if body.Duration > 0 {
|
||||
found.Duration = time.Duration(body.Duration)
|
||||
}
|
||||
found.Note = body.Note
|
||||
if err := s.Times.Update(*found); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
_, _ = s.DB.AppendRevision("time_entry", found.ID, id, "update", u.ID, found)
|
||||
writeJSON(w, http.StatusOK, found)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteTime(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
eid := r.PathValue("eid")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleMember, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
entries, _ := s.Times.ListByProject(id)
|
||||
for _, e := range entries {
|
||||
if e.ID == eid {
|
||||
_, _ = s.DB.AppendRevision("time_entry", e.ID, id, "delete", u.ID, e)
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := s.Times.Delete(eid); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTimer(w http.ResponseWriter, r *http.Request) {
|
||||
t, err := s.Times.LoadActiveTimer()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if t == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"timer": nil})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"timer": t})
|
||||
}
|
||||
|
||||
func (s *Server) handleStartTimer(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
var body struct {
|
||||
TicketID string `json:"ticketId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if _, ok, code := s.requireProjectRole(body.ProjectID, sqlite.RoleMember, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
t := &ticket.ActiveTimer{
|
||||
TicketID: body.TicketID,
|
||||
ProjectID: body.ProjectID,
|
||||
StartedAt: time.Now().UTC(),
|
||||
}
|
||||
if err := s.Times.SaveActiveTimer(t); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = u
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleStopTimer(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
active, err := s.Times.LoadActiveTimer()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if active == nil {
|
||||
writeError(w, http.StatusBadRequest, "no active timer")
|
||||
return
|
||||
}
|
||||
if _, ok, code := s.requireProjectRole(active.ProjectID, sqlite.RoleMember, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
dur := time.Since(active.StartedAt)
|
||||
if dur < time.Second {
|
||||
dur = time.Second
|
||||
}
|
||||
e, err := s.Times.Create(ticket.TimeEntry{
|
||||
TicketID: active.TicketID,
|
||||
ProjectID: active.ProjectID,
|
||||
Start: active.StartedAt,
|
||||
End: time.Now().UTC(),
|
||||
Duration: dur,
|
||||
Source: ticket.SourceTimer,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
_ = s.Times.SaveActiveTimer(nil)
|
||||
_, _ = s.DB.AppendRevision("time_entry", e.ID, active.ProjectID, "create", u.ID, e)
|
||||
writeJSON(w, http.StatusOK, e)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func (s *Server) handleListTokens(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
if _, tok := s.currentTokenAuth(r); tok {
|
||||
writeError(w, http.StatusForbidden, "session required")
|
||||
return
|
||||
}
|
||||
list, err := s.DB.ListProjectTokens(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
u, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r)
|
||||
if !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
if _, tok := s.currentTokenAuth(r); tok {
|
||||
writeError(w, http.StatusForbidden, "session required")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.Scope == "" {
|
||||
body.Scope = sqlite.TokenScopeWrite
|
||||
}
|
||||
t, err := s.DB.CreateProjectToken(id, body.Name, body.Scope, u.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevokeToken(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
tid := r.PathValue("tid")
|
||||
if _, ok, code := s.requireProjectRole(id, sqlite.RoleAdmin, r); !ok {
|
||||
writeError(w, code, "forbidden")
|
||||
return
|
||||
}
|
||||
if _, tok := s.currentTokenAuth(r); tok {
|
||||
writeError(w, http.StatusForbidden, "session required")
|
||||
return
|
||||
}
|
||||
if err := s.DB.RevokeProjectToken(id, tid); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
me, _ := s.currentUser(r)
|
||||
var body struct {
|
||||
IsAdmin *bool `json:"isAdmin"`
|
||||
}
|
||||
if err := readJSON(r, &body); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid json")
|
||||
return
|
||||
}
|
||||
if body.IsAdmin == nil {
|
||||
writeError(w, http.StatusBadRequest, "isAdmin required")
|
||||
return
|
||||
}
|
||||
if id == me.ID && !*body.IsAdmin {
|
||||
writeError(w, http.StatusBadRequest, "cannot remove your own admin flag")
|
||||
return
|
||||
}
|
||||
if err := s.DB.SetUserAdmin(id, *body.IsAdmin); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
u, err := s.DB.GetUserByID(id)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
u.PasswordHash = ""
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
me, _ := s.currentUser(r)
|
||||
if id == me.ID {
|
||||
writeError(w, http.StatusBadRequest, "cannot delete yourself")
|
||||
return
|
||||
}
|
||||
if err := s.DB.DeleteUser(id); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleDashboard returns a lightweight overview for the front page.
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := s.currentUser(r)
|
||||
if _, tok := s.currentTokenAuth(r); tok {
|
||||
writeError(w, http.StatusForbidden, "session required")
|
||||
return
|
||||
}
|
||||
projects, err := s.Projects.ListIDsForUser(u.ID, u.IsAdmin)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
type openTicket struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Project string `json:"projectName"`
|
||||
}
|
||||
type projSum struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OpenCount int `json:"openCount"`
|
||||
DurationNs int64 `json:"durationNs"`
|
||||
}
|
||||
open := make([]openTicket, 0)
|
||||
sums := make([]projSum, 0, len(projects))
|
||||
for _, p := range projects {
|
||||
tickets, err := s.Tickets.List(p.ID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
openCount := 0
|
||||
for _, t := range tickets {
|
||||
if string(t.Status) != "done" {
|
||||
openCount++
|
||||
if len(open) < 20 {
|
||||
open = append(open, openTicket{
|
||||
ID: t.ID, Title: t.Title, Status: string(t.Status),
|
||||
ProjectID: p.ID, Project: p.Name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
entries, _ := s.Times.ListByProject(p.ID)
|
||||
var dur int64
|
||||
for _, e := range entries {
|
||||
dur += int64(e.Duration)
|
||||
}
|
||||
sums = append(sums, projSum{ID: p.ID, Name: p.Name, OpenCount: openCount, DurationNs: dur})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"projects": sums,
|
||||
"openTickets": open,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"task/internal/sqlite"
|
||||
)
|
||||
|
||||
func TestProjectTokenBearerAuth(t *testing.T) {
|
||||
db, err := sqlite.Open(filepath.Join(t.TempDir(), "t.db"), []byte("secret"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
admin, err := db.CreateUser("admin", "pass", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := New(db)
|
||||
handler := srv.Handler()
|
||||
|
||||
loginBody, _ := json.Marshal(map[string]string{"username": "admin", "password": "pass"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
cookie := rec.Result().Cookies()[0]
|
||||
|
||||
createBody, _ := json.Marshal(map[string]string{"name": "Demo"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects", bytes.NewReader(createBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
var proj map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &proj)
|
||||
pid := proj["id"].(string)
|
||||
|
||||
tokBody, _ := json.Marshal(map[string]string{"name": "tui", "scope": "write"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tokens", bytes.NewReader(tokBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create token %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var tok map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &tok)
|
||||
raw := tok["token"].(string)
|
||||
if raw == "" {
|
||||
t.Fatal("expected raw token")
|
||||
}
|
||||
|
||||
ticketBody, _ := json.Marshal(map[string]string{"title": "Via token"})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+pid+"/tickets", bytes.NewReader(ticketBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("bearer create ticket %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/projects/"+pid+"/tickets", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+raw)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("bearer list %d", rec.Code)
|
||||
}
|
||||
|
||||
// delete other user
|
||||
_, _ = db.CreateUser("bob", "x", false)
|
||||
users, _ := db.ListUsers()
|
||||
var bobID string
|
||||
for _, u := range users {
|
||||
if u.Username == "bob" {
|
||||
bobID = u.ID
|
||||
}
|
||||
}
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/users/"+bobID, nil)
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("delete user %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
patch, _ := json.Marshal(map[string]bool{"isAdmin": false})
|
||||
req = httptest.NewRequest(http.MethodPatch, "/api/v1/users/"+admin.ID, bytes.NewReader(patch))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(cookie)
|
||||
rec = httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatal("should not demote self")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user