Add M0 server skeleton with SQLite store, SSE events, worktrees, and tailscale service
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
"g.fc.run/misc/byre/internal/git"
|
||||
"g.fc.run/misc/byre/internal/store"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
store *store.Store
|
||||
hub *hub
|
||||
worktreeRoot string
|
||||
startedAt time.Time
|
||||
spa fs.FS
|
||||
|
||||
usersMu sync.Mutex
|
||||
users map[string]string
|
||||
}
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
func New(dataDir string, spa fs.FS) (*Server, error) {
|
||||
err := os.MkdirAll(dataDir, 0o755)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
hub: newHub(),
|
||||
worktreeRoot: filepath.Join(dataDir, "worktrees"),
|
||||
startedAt: time.Now().UTC(),
|
||||
spa: spa,
|
||||
users: map[string]string{},
|
||||
}
|
||||
|
||||
s.store, err = store.Open(filepath.Join(dataDir, "byre.db"), s.hub.publish)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) Close() error {
|
||||
return s.store.Close()
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
api.HandlerWithOptions(s, api.StdHTTPServerOptions{
|
||||
BaseRouter: mux,
|
||||
Middlewares: []api.MiddlewareFunc{s.identify},
|
||||
ErrorHandlerFunc: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
},
|
||||
})
|
||||
mux.Handle("/", http.FileServerFS(s.spa))
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) identify(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusForbidden, err)
|
||||
return
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
writeError(w, http.StatusForbidden, fmt.Errorf("request from %s did not arrive via tailscale serve", host))
|
||||
return
|
||||
}
|
||||
|
||||
login := r.Header.Get("Tailscale-User-Login")
|
||||
name := r.Header.Get("Tailscale-User-Name")
|
||||
if login == "" || name == "" {
|
||||
writeError(w, http.StatusUnauthorized, errors.New("missing Tailscale-User-Login or Tailscale-User-Name"))
|
||||
return
|
||||
}
|
||||
|
||||
err = s.ensureUser(r.Context(), login, name)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, login)))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) ensureUser(ctx context.Context, login, name string) error {
|
||||
s.usersMu.Lock()
|
||||
defer s.usersMu.Unlock()
|
||||
|
||||
if s.users[login] == name {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := s.store.EnsureUser(ctx, login, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.users[login] = name
|
||||
return nil
|
||||
}
|
||||
|
||||
func actor(r *http.Request) string {
|
||||
return r.Context().Value(ctxKey{}).(string)
|
||||
}
|
||||
|
||||
func (s *Server) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
u, err := s.store.GetUser(r.Context(), actor(r))
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (s *Server) UpdateMe(w http.ResponseWriter, r *http.Request) {
|
||||
req := api.UserUpdate{}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.GitName) == "" || !strings.Contains(req.GitEmail, "@") {
|
||||
writeError(w, http.StatusBadRequest, errors.New("git_name and a valid git_email are required"))
|
||||
return
|
||||
}
|
||||
|
||||
u, err := s.store.UpdateUserGit(r.Context(), actor(r), req.GitName, req.GitEmail)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
func (s *Server) ListProjects(w http.ResponseWriter, r *http.Request) {
|
||||
projects, err := s.store.ListProjects(r.Context())
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, projects)
|
||||
}
|
||||
|
||||
func (s *Server) CreateProject(w http.ResponseWriter, r *http.Request) {
|
||||
req := api.ProjectCreate{}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
|
||||
info, err := git.Inspect(req.RepoPath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
p := api.Project{
|
||||
Id: store.NewID(),
|
||||
Name: req.Name,
|
||||
RepoPath: info.Root,
|
||||
DefaultBranch: info.DefaultBranch,
|
||||
Remote: info.Remote,
|
||||
CreatedBy: actor(r),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
err = s.store.CreateProject(r.Context(), p)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, p)
|
||||
}
|
||||
|
||||
func (s *Server) GetProject(w http.ResponseWriter, r *http.Request, projectID string) {
|
||||
p, err := s.store.GetProject(r.Context(), projectID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
func (s *Server) ListTasks(w http.ResponseWriter, r *http.Request, projectID string) {
|
||||
_, err := s.store.GetProject(r.Context(), projectID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
tasks, err := s.store.ListTasks(r.Context(), projectID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tasks)
|
||||
}
|
||||
|
||||
func (s *Server) CreateTask(w http.ResponseWriter, r *http.Request, projectID string) {
|
||||
req := api.TaskCreate{}
|
||||
if !readJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(req.Title) == "" {
|
||||
writeError(w, http.StatusBadRequest, errors.New("title is required"))
|
||||
return
|
||||
}
|
||||
|
||||
p, err := s.store.GetProject(r.Context(), projectID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
id := store.NewID()
|
||||
t := api.Task{
|
||||
Id: id,
|
||||
ProjectId: p.Id,
|
||||
Title: req.Title,
|
||||
Branch: "byre/" + id,
|
||||
WorktreePath: filepath.Join(s.worktreeRoot, p.Id, id),
|
||||
Status: api.Active,
|
||||
CreatedBy: actor(r),
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
t.BaseRef, err = git.AddWorktree(p.RepoPath, t.WorktreePath, t.Branch, p.DefaultBranch)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = s.store.CreateTask(r.Context(), t)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
func (s *Server) GetTask(w http.ResponseWriter, r *http.Request, taskID string) {
|
||||
t, err := s.store.GetTask(r.Context(), taskID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) CompleteTask(w http.ResponseWriter, r *http.Request, taskID string) {
|
||||
t, err := s.store.GetTask(r.Context(), taskID)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if t.Status != api.Active {
|
||||
writeError(w, http.StatusConflict, fmt.Errorf("task %s is %s", t.Id, t.Status))
|
||||
return
|
||||
}
|
||||
|
||||
p, err := s.store.GetProject(r.Context(), t.ProjectId)
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = git.RemoveWorktree(p.RepoPath, t.WorktreePath, t.Branch)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
t, err = s.store.CompleteTask(r.Context(), taskID, actor(r))
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (s *Server) GetStatus(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := s.store.Counts(r.Context())
|
||||
if err != nil {
|
||||
writeStoreError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
dbBytes := int64(0)
|
||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||
fi, err := os.Stat(s.store.Path() + suffix)
|
||||
if err == nil {
|
||||
dbBytes += fi.Size()
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, api.Status{
|
||||
StartedAt: s.startedAt,
|
||||
DbBytes: dbBytes,
|
||||
LastEventSeq: c.LastEventSeq,
|
||||
Projects: c.Projects,
|
||||
ActiveTasks: c.ActiveTasks,
|
||||
SseClients: s.hub.count(),
|
||||
})
|
||||
}
|
||||
|
||||
func readJSON(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
err := dec.Decode(v)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, fmt.Errorf("decode request: %w", err))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
err := json.NewEncoder(w).Encode(v)
|
||||
if err != nil {
|
||||
slog.Warn("write response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, err error) {
|
||||
writeJSON(w, status, api.Error{Message: err.Error()})
|
||||
}
|
||||
|
||||
func writeStoreError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, err)
|
||||
}
|
||||
Reference in New Issue
Block a user