Git Repository
Stackyard
Stackyard scannt lokale Entwicklungsordner und bündelt Projekte, Git-Status, TODOs, Toolchains, Docker-Compose-Setups, Datei- und Launcher-Funktionen sowie Operations-Informationen in einer lokalen WebUI.
HTTPS
https://zanvex.de/git/stackyard.gitinternal/web/web.go
Zum Verzeichnispackage web
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html"
"html/template"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"unicode/utf8"
"stackyard/internal/app"
"stackyard/internal/archive"
"stackyard/internal/config"
"stackyard/internal/git"
"stackyard/internal/launcher"
"stackyard/internal/operations"
"stackyard/internal/project"
"stackyard/internal/storage"
"stackyard/internal/todo"
)
type Server struct {
cfg config.Config
projects *app.Service
gitClient git.Client
store *storage.Store
processes *operations.ProcessManager
}
type fileTreeNode struct {
Name string
Path string
IsDir bool
Children []fileTreeNode
}
type flashMessage struct {
Kind string
Text string
}
const maxEditorFileSize = 2 << 20
const maxFileTreeEntries = 2000
const maxFileTreeDepth = 6
type editorView struct {
Project project.Project
Files []fileTreeNode
SelectedPath string
Content string
Flash flashMessage
Protected bool
}
type dashboardProject struct {
project.Project
CollectionID int64
CollectionOptions []project.Collection
}
type dashboardCollection struct {
ID int64
Name string
Color string
SortOrder int
Projects []dashboardProject
}
type dashboardView struct {
Projects []dashboardProject
Collections []dashboardCollection
Unassigned []dashboardProject
Flash flashMessage
Query string
StateFilter string
FavoritesOnly bool
Sort string
View string
Page int
PrevPage int
NextPage int
HasPrev bool
HasNext bool
TotalProjects int
RepoCount int
DirtyCount int
WarningCount int
SnapshotName string
Warnings []dashboardWarning
}
type dashboardWarning struct {
ProjectID string
ProjectName string
Message string
Level string
Priority int
}
type operationsView struct {
Projects []project.Project
Ports []operations.PortStatus
Todos []operations.TodoItem
Snapshots []project.Snapshot
ScannerRules []project.ScannerRule
Dependencies []project.Dependency
Events []project.Event
Manifests map[string]operations.Manifest
ProjectNames map[string]string
Flash flashMessage
HealthRefreshSeconds int
}
type backupPayload struct {
Version int `json:"version"`
Projects []project.Project `json:"projects"`
Collections []project.Collection `json:"collections"`
Snapshots []project.Snapshot `json:"snapshots"`
ScannerRules []project.ScannerRule `json:"scanner_rules"`
Dependencies []project.Dependency `json:"dependencies"`
}
func NewServer(cfg config.Config, store *storage.Store) *Server {
if cfg.Monitoring.RefreshSeconds <= 0 {
cfg.Monitoring.RefreshSeconds = 10
}
return &Server{
cfg: cfg,
projects: app.NewService(cfg, store),
gitClient: git.NewClient(),
store: store,
processes: operations.NewProcessManager(),
}
}
func (s *Server) Close() {
if s != nil {
s.processes.StopAll()
}
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/", s.handleDashboard)
mux.HandleFunc("/projects", s.handleProjects)
mux.HandleFunc("/projects/rescan", s.handleRescan)
mux.HandleFunc("/projects/favorite", s.handleFavorite)
mux.HandleFunc("/projects/state", s.handleState)
mux.HandleFunc("/collections/create", s.handleCreateCollection)
mux.HandleFunc("/collections/delete", s.handleDeleteCollection)
mux.HandleFunc("/collections/assign", s.handleAssignCollection)
mux.HandleFunc("/collections/update", s.handleUpdateCollection)
mux.HandleFunc("/collections/move", s.handleMoveCollection)
mux.HandleFunc("/projects/metadata", s.handleProjectMetadata)
mux.HandleFunc("/operations", s.handleOperations)
mux.HandleFunc("/operations/health", s.handleOperationsHealth)
mux.HandleFunc("/operations/snapshots/create", s.handleCreateSnapshot)
mux.HandleFunc("/operations/snapshots/delete", s.handleDeleteSnapshot)
mux.HandleFunc("/operations/scanner/set", s.handleSetScannerRule)
mux.HandleFunc("/operations/scanner/delete", s.handleDeleteScannerRule)
mux.HandleFunc("/operations/scanner/preview", s.handleScannerPreview)
mux.HandleFunc("/operations/dependencies/set", s.handleSetDependency)
mux.HandleFunc("/operations/dependencies/delete", s.handleDeleteDependency)
mux.HandleFunc("/operations/actions/run", s.handleRunAction)
mux.HandleFunc("/operations/actions/start", s.handleStartAction)
mux.HandleFunc("/operations/actions/stop", s.handleStopAction)
mux.HandleFunc("/operations/actions/status", s.handleActionStatus)
mux.HandleFunc("/operations/backup", s.handleBackup)
mux.HandleFunc("/operations/backup/import", s.handleBackupImport)
mux.HandleFunc("/projects/", s.handleProject)
return csrfGuard(mux)
}
func (s *Server) handleOperations(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/operations" || r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
projects, err := s.projects.List(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
snapshots, err := s.store.ListSnapshots(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
rules, err := s.store.ListScannerRules(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
dependencies, err := s.store.ListDependencies(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
events, err := s.store.ListEvents(r.Context(), 50)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
manifests := make(map[string]operations.Manifest, len(projects))
projectNames := make(map[string]string, len(projects))
for _, current := range projects {
manifests[current.ID] = operations.LoadManifest(current.Path)
projectNames[current.ID] = current.Name
}
view := operationsView{Projects: projects, Ports: operations.CollectPorts(r.Context(), projects), Todos: operations.CollectTodos(projects), Snapshots: snapshots, ScannerRules: rules, Dependencies: dependencies, Events: events, Manifests: manifests, ProjectNames: projectNames, Flash: readFlash(r), HealthRefreshSeconds: s.cfg.Monitoring.RefreshSeconds}
if err := operationsTemplate.Execute(w, view); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Server) handleOperationsHealth(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
projects, err := s.projects.List(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := healthPanelTemplate.Execute(w, map[string]any{
"Ports": operations.CollectPorts(r.Context(), projects),
"RefreshSeconds": s.cfg.Monitoring.RefreshSeconds,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Server) handleCreateSnapshot(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
projectIDs := r.Form["project_id"]
if len(projectIDs) == 0 {
projects, err := s.projects.List(r.Context())
if err != nil {
redirectWithFlash(w, r, "/operations", "error", err.Error())
return
}
for _, current := range projects {
projectIDs = append(projectIDs, current.ID)
}
}
if err := s.store.CreateSnapshot(r.Context(), r.FormValue("name"), projectIDs); err != nil {
redirectWithFlash(w, r, "/operations", "error", err.Error())
return
}
redirectWithFlash(w, r, "/operations", "success", "Workspace-Snapshot gespeichert")
}
func (s *Server) handleDeleteSnapshot(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
if r.Method != http.MethodPost || err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
_ = s.store.DeleteSnapshot(r.Context(), id)
http.Redirect(w, r, "/operations", http.StatusSeeOther)
}
func (s *Server) handleSetScannerRule(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := s.store.SetScannerRule(r.Context(), r.FormValue("path"), r.FormValue("kind")); err != nil {
redirectWithFlash(w, r, "/operations", "error", err.Error())
return
}
redirectWithFlash(w, r, "/operations", "success", "Scanner-Regel gespeichert; beim nächsten Rescan aktiv")
}
func (s *Server) handleDeleteScannerRule(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
_ = s.store.DeleteScannerRule(r.Context(), r.FormValue("path"))
http.Redirect(w, r, "/operations", http.StatusSeeOther)
}
func (s *Server) handleScannerPreview(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
candidates, err := s.projects.PreviewScan(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<!doctype html><html lang="de"><meta charset="utf-8"><body style="font-family:system-ui;background:#080d18;color:#e7eef9;padding:2rem"><h1>Scan-Vorschau</h1><p>Diese Vorschau verändert die Datenbank nicht.</p><table style="width:100%;border-collapse:collapse"><tr><th>Name</th><th>Root</th><th>Typ</th><th>Pfad</th></tr>`)
for _, candidate := range candidates {
fmt.Fprintf(w, `<tr><td>%s</td><td>%s</td><td>%s</td><td><code>%s</code></td></tr>`, template.HTMLEscapeString(candidate.Name), template.HTMLEscapeString(candidate.RootName), template.HTMLEscapeString(candidate.PrimaryType), template.HTMLEscapeString(candidate.Path))
}
fmt.Fprint(w, `</table><p><a style="color:#7dd3fc" href="/operations">Zurück</a></p></body></html>`)
}
func (s *Server) handleSetDependency(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := s.store.SetDependency(r.Context(), r.FormValue("project_id"), r.FormValue("depends_on")); err != nil {
redirectWithFlash(w, r, "/operations", "error", err.Error())
return
}
http.Redirect(w, r, "/operations", http.StatusSeeOther)
}
func (s *Server) handleDeleteDependency(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
_ = s.store.DeleteDependency(r.Context(), r.FormValue("project_id"), r.FormValue("depends_on"))
http.Redirect(w, r, "/operations", http.StatusSeeOther)
}
func (s *Server) handleRunAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
current, ok, err := s.projects.Get(r.Context(), r.FormValue("project_id"))
returnTo := backTo(r, "/operations")
if err != nil || !ok {
redirectWithFlash(w, r, returnTo, "error", "Projekt nicht gefunden")
return
}
kind := r.FormValue("kind")
if operations.IsLongRunningAction(kind) {
redirectWithFlash(w, r, returnTo, "error", "Dauerhafte Aktion bitte mit Start ausführen")
return
}
manifest := operations.LoadManifest(current.Path)
if !manifestDeclaresAction(manifest, kind) || operations.ValidateAction(kind) != nil {
redirectWithFlash(w, r, returnTo, "error", "Aktion ist nicht sicher deklariert")
return
}
_ = s.store.AddEvent(r.Context(), "info", fmt.Sprintf("%s: Aktion %s gestartet", current.Name, kind))
go func() {
output, runErr := operations.RunAction(context.Background(), current.Path, kind)
level := "info"
message := fmt.Sprintf("%s: Aktion %s abgeschlossen", current.Name, kind)
if runErr != nil {
level = "error"
message = fmt.Sprintf("%s: %s fehlgeschlagen: %v\n%s", current.Name, kind, runErr, output)
}
_ = s.store.AddEvent(context.Background(), level, message)
}()
redirectWithFlash(w, r, returnTo, "success", "Aktion wurde gestartet")
}
func (s *Server) handleStartAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
current, ok, err := s.projects.Get(r.Context(), r.FormValue("project_id"))
returnTo := backTo(r, "/operations")
if err != nil || !ok {
redirectWithFlash(w, r, returnTo, "error", "Projekt nicht gefunden")
return
}
kind := r.FormValue("kind")
manifest := operations.LoadManifest(current.Path)
if !manifestDeclaresAction(manifest, kind) || !operations.IsLongRunningAction(kind) {
redirectWithFlash(w, r, returnTo, "error", "Aktion ist nicht als sicherer Dienst deklariert")
return
}
if err := s.processes.Start(current.ID, current.Path, kind); err != nil {
redirectWithFlash(w, r, returnTo, "error", err.Error())
return
}
_ = s.store.AddEvent(r.Context(), "info", fmt.Sprintf("%s: Aktion %s gestartet", current.Name, kind))
redirectWithFlash(w, r, returnTo, "success", "Dienst wurde gestartet")
}
func (s *Server) handleStopAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
projectID := strings.TrimSpace(r.FormValue("project_id"))
kind := strings.TrimSpace(r.FormValue("kind"))
returnTo := backTo(r, "/operations")
current, ok, err := s.projects.Get(r.Context(), projectID)
if err != nil || !ok || !manifestDeclaresAction(operations.LoadManifest(current.Path), kind) {
redirectWithFlash(w, r, returnTo, "error", "Deklarierte Aktion nicht gefunden")
return
}
if err := s.processes.Stop(projectID, kind); err != nil {
redirectWithFlash(w, r, returnTo, "error", err.Error())
return
}
_ = s.store.AddEvent(r.Context(), "info", fmt.Sprintf("%s: Aktion %s gestoppt", current.Name, kind))
redirectWithFlash(w, r, returnTo, "success", "Dienst wird gestoppt")
}
func (s *Server) handleActionStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
projectID := strings.TrimSpace(r.URL.Query().Get("project_id"))
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
current, ok, err := s.projects.Get(r.Context(), projectID)
if err != nil || !ok || !manifestDeclaresAction(operations.LoadManifest(current.Path), kind) {
http.NotFound(w, r)
return
}
if err := actionStatusTemplate.Execute(w, s.processes.Status(projectID, kind)); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func manifestDeclaresAction(manifest operations.Manifest, kind string) bool {
for _, action := range manifest.Actions {
if action.Kind == kind {
return true
}
}
return false
}
func (s *Server) handleBackup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
projects, _ := s.projects.List(r.Context())
collections, _ := s.store.ListCollections(r.Context())
snapshots, _ := s.store.ListSnapshots(r.Context())
rules, _ := s.store.ListScannerRules(r.Context())
dependencies, _ := s.store.ListDependencies(r.Context())
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="stackyard-backup.json"`)
_ = json.NewEncoder(w).Encode(map[string]any{"version": 1, "exported_at": time.Now().UTC(), "projects": projects, "collections": collections, "snapshots": snapshots, "scanner_rules": rules, "dependencies": dependencies})
}
func (s *Server) handleBackupImport(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var data []byte
if encoded := r.FormValue("payload"); encoded != "" {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
http.Error(w, "invalid backup payload", http.StatusBadRequest)
return
}
data = decoded
} else {
if err := r.ParseMultipartForm(6 << 20); err != nil {
http.Error(w, "invalid upload", http.StatusBadRequest)
return
}
file, _, err := r.FormFile("backup")
if err != nil {
http.Error(w, "backup file missing", http.StatusBadRequest)
return
}
defer file.Close()
data, err = io.ReadAll(io.LimitReader(file, 5<<20))
if err != nil {
http.Error(w, "read backup", http.StatusBadRequest)
return
}
}
var payload backupPayload
if err := json.Unmarshal(data, &payload); err != nil || payload.Version != 1 {
http.Error(w, "unsupported or invalid backup", http.StatusBadRequest)
return
}
if r.FormValue("mode") != "apply" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!doctype html><html lang="de"><meta charset="utf-8"><title>Import-Vorschau</title><body style="font-family:system-ui;background:#080d18;color:#e7eef9;padding:2rem"><h1>Import-Vorschau</h1><ul><li>%d Projekte mit Metadaten</li><li>%d Projektmappen</li><li>%d Snapshots</li><li>%d Scanner-Regeln</li><li>%d Abhängigkeiten</li></ul><p>Projektdateien und Repository-Inhalte werden nicht verändert.</p><form method="post" action="/operations/backup/import"><input type="hidden" name="mode" value="apply"><input type="hidden" name="payload" value="%s"><button style="padding:.7rem">Import anwenden</button> <a style="color:#7dd3fc" href="/operations">Abbrechen</a></form></body></html>`, len(payload.Projects), len(payload.Collections), len(payload.Snapshots), len(payload.ScannerRules), len(payload.Dependencies), template.HTMLEscapeString(base64.StdEncoding.EncodeToString(data)))
return
}
for _, saved := range payload.Projects {
if _, ok, _ := s.store.GetProject(r.Context(), saved.ID); ok {
_ = s.store.SetProjectMetadata(r.Context(), saved.ID, saved.Description, saved.Notes, strings.Join(saved.Labels, ","))
}
}
for _, saved := range payload.Collections {
_ = s.store.CreateCollection(r.Context(), saved.Name)
collections, _ := s.store.ListCollections(r.Context())
for _, created := range collections {
if strings.EqualFold(created.Name, saved.Name) {
_ = s.store.UpdateCollection(r.Context(), created.ID, created.Name, saved.Color)
for _, projectID := range saved.ProjectIDs {
_ = s.store.AssignProjectCollection(r.Context(), projectID, created.ID)
}
break
}
}
}
for _, saved := range payload.Snapshots {
_ = s.store.CreateSnapshot(r.Context(), saved.Name, saved.ProjectIDs)
}
for _, rule := range payload.ScannerRules {
_ = s.store.SetScannerRule(r.Context(), rule.Path, rule.Kind)
}
for _, dependency := range payload.Dependencies {
_ = s.store.SetDependency(r.Context(), dependency.ProjectID, dependency.DependsOnProjectID)
}
_ = s.store.AddEvent(r.Context(), "info", "Stackyard-Backup importiert")
redirectWithFlash(w, r, "/operations", "success", "Backup importiert")
}
func csrfGuard(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
source := r.Header.Get("Origin")
if source == "" {
source = r.Header.Get("Referer")
}
if source != "" {
parsed, err := url.Parse(source)
if err != nil || !strings.EqualFold(parsed.Host, r.Host) {
http.Error(w, "cross-site request rejected", http.StatusForbidden)
return
}
}
}
next.ServeHTTP(w, r)
})
}
func (s *Server) handleProjects(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/projects" {
http.NotFound(w, r)
return
}
s.handleDashboard(w, r)
}
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
projects, err := s.projects.List(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var snapshotName string
if snapshotID, parseErr := strconv.ParseInt(r.URL.Query().Get("snapshot"), 10, 64); parseErr == nil && snapshotID > 0 {
if snapshot, ok, snapshotErr := s.store.GetSnapshot(r.Context(), snapshotID); snapshotErr == nil && ok {
allowed := make(map[string]struct{}, len(snapshot.ProjectIDs))
for _, id := range snapshot.ProjectIDs {
allowed[id] = struct{}{}
}
filtered := projects[:0]
for _, current := range projects {
if _, include := allowed[current.ID]; include {
filtered = append(filtered, current)
}
}
projects = filtered
snapshotName = snapshot.Name
}
}
query := strings.TrimSpace(r.URL.Query().Get("q"))
stateFilter := strings.TrimSpace(r.URL.Query().Get("state"))
favoritesOnly := r.URL.Query().Get("favorites") == "1"
sortMode := strings.TrimSpace(r.URL.Query().Get("sort"))
viewMode := strings.TrimSpace(r.URL.Query().Get("view"))
if viewMode != "list" {
viewMode = "cards"
}
if query != "" {
projects = filterProjects(projects, query)
}
if stateFilter == "" {
projects = filterWithoutArchived(projects)
} else if stateFilter != "all" {
projects = filterByState(projects, stateFilter)
}
if favoritesOnly {
projects = filterFavorites(projects)
}
sortProjects(projects, sortMode)
statsProjects := append([]project.Project(nil), projects...)
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
const pageSize = 24
total := len(projects)
start := (page - 1) * pageSize
if start > total {
start = total
}
end := min(start+pageSize, total)
projects = projects[start:end]
collections, err := s.store.ListCollections(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
view := buildDashboardView(projects, collections)
view.Flash = readFlash(r)
view.Query = query
view.StateFilter = stateFilter
view.FavoritesOnly = favoritesOnly
view.Sort = sortMode
view.View = viewMode
view.Page = page
view.HasPrev = page > 1
view.PrevPage = page - 1
view.HasNext = end < total
view.NextPage = page + 1
view.SnapshotName = snapshotName
view.TotalProjects = len(statsProjects)
for _, current := range statsProjects {
if current.Git.IsRepo {
view.RepoCount++
}
if current.Git.Dirty {
view.DirtyCount++
}
warnings := dashboardWarnings(current)
if len(warnings) > 0 {
view.WarningCount++
}
view.Warnings = append(view.Warnings, warnings...)
}
slices.SortFunc(view.Warnings, func(a, b dashboardWarning) int {
if a.Priority != b.Priority {
return a.Priority - b.Priority
}
return strings.Compare(a.ProjectName, b.ProjectName)
})
if len(view.Warnings) > 8 {
view.Warnings = view.Warnings[:8]
}
if err := dashboardTemplate.Execute(w, view); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func dashboardWarnings(current project.Project) []dashboardWarning {
if _, err := os.Stat(current.Path); err != nil {
return []dashboardWarning{{ProjectID: current.ID, ProjectName: current.Name, Message: "Projektpfad nicht erreichbar", Level: "critical", Priority: 0}}
}
var warnings []dashboardWarning
if current.Git.IsRepo && current.Git.Dirty {
warnings = append(warnings, dashboardWarning{ProjectID: current.ID, ProjectName: current.Name, Message: "Nicht gespeicherte Git-Änderungen", Level: "warning", Priority: 1})
}
if current.ReadmePath == "" {
warnings = append(warnings, dashboardWarning{ProjectID: current.ID, ProjectName: current.Name, Message: "README fehlt", Level: "notice", Priority: 2})
}
if current.TodoPath == "" {
warnings = append(warnings, dashboardWarning{ProjectID: current.ID, ProjectName: current.Name, Message: "TODO fehlt", Level: "notice", Priority: 3})
}
return warnings
}
func sortProjects(projects []project.Project, mode string) {
slices.SortStableFunc(projects, func(a, b project.Project) int {
switch mode {
case "commit":
return b.Git.LastCommitAt.Compare(a.Git.LastCommitAt)
case "opened":
return b.LastOpenedAt.Compare(a.LastOpenedAt)
case "state":
if cmp := strings.Compare(string(a.State), string(b.State)); cmp != 0 {
return cmp
}
}
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
})
}
func buildDashboardView(projects []project.Project, collections []project.Collection) dashboardView {
assignment := make(map[string]int64)
groups := make([]dashboardCollection, len(collections))
groupIndex := make(map[int64]int, len(collections))
for i, collection := range collections {
groups[i] = dashboardCollection{ID: collection.ID, Name: collection.Name, Color: collection.Color, SortOrder: collection.SortOrder}
groupIndex[collection.ID] = i
for _, projectID := range collection.ProjectIDs {
assignment[projectID] = collection.ID
}
}
view := dashboardView{Collections: groups, Projects: make([]dashboardProject, 0, len(projects))}
for _, current := range projects {
item := dashboardProject{Project: current, CollectionID: assignment[current.ID], CollectionOptions: collections}
view.Projects = append(view.Projects, item)
if index, ok := groupIndex[item.CollectionID]; ok {
view.Collections[index].Projects = append(view.Collections[index].Projects, item)
} else {
view.Unassigned = append(view.Unassigned, item)
}
}
return view
}
func (s *Server) handleCreateCollection(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := s.store.CreateCollection(r.Context(), r.FormValue("name")); err != nil {
redirectWithFlash(w, r, "/", "error", err.Error())
return
}
redirectWithFlash(w, r, "/", "success", "Projektmappe angelegt")
}
func (s *Server) handleDeleteCollection(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
if err != nil || id <= 0 {
redirectWithFlash(w, r, "/", "error", "ungueltige Projektmappe")
return
}
if err := s.store.DeleteCollection(r.Context(), id); err != nil {
redirectWithFlash(w, r, "/", "error", err.Error())
return
}
redirectWithFlash(w, r, "/", "success", "Projektmappe entfernt; Projekte bleiben erhalten")
}
func (s *Server) handleAssignCollection(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
collectionID, err := strconv.ParseInt(r.FormValue("collection_id"), 10, 64)
if err != nil || collectionID < 0 {
redirectWithFlash(w, r, "/", "error", "ungueltige Projektmappe")
return
}
if err := s.store.AssignProjectCollection(r.Context(), strings.TrimSpace(r.FormValue("project_id")), collectionID); err != nil {
redirectWithFlash(w, r, "/", "error", err.Error())
return
}
redirectWithFlash(w, r, "/", "success", "Projektzuordnung aktualisiert")
}
func (s *Server) handleUpdateCollection(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
if err != nil || id <= 0 {
redirectWithFlash(w, r, "/", "error", "ungueltige Projektmappe")
return
}
if err := s.store.UpdateCollection(r.Context(), id, r.FormValue("name"), r.FormValue("color")); err != nil {
redirectWithFlash(w, r, "/", "error", err.Error())
return
}
redirectWithFlash(w, r, "/", "success", "Projektmappe aktualisiert")
}
func (s *Server) handleMoveCollection(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id, err := strconv.ParseInt(r.FormValue("id"), 10, 64)
direction, directionErr := strconv.Atoi(r.FormValue("direction"))
if err != nil || id <= 0 || directionErr != nil || (direction != -1 && direction != 1) {
redirectWithFlash(w, r, "/", "error", "ungueltige Sortierung")
return
}
if err := s.store.MoveCollection(r.Context(), id, direction); err != nil {
redirectWithFlash(w, r, "/", "error", err.Error())
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleProjectMetadata(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := strings.TrimSpace(r.FormValue("id"))
if err := s.store.SetProjectMetadata(r.Context(), id, r.FormValue("description"), r.FormValue("notes"), r.FormValue("labels")); err != nil {
redirectWithFlash(w, r, "/projects/"+id, "error", err.Error())
return
}
redirectWithFlash(w, r, "/projects/"+id, "success", "Projektinformationen gespeichert")
}
func (s *Server) handleRescan(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if _, err := s.projects.Refresh(r.Context()); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleFavorite(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
id := strings.TrimSpace(r.FormValue("id"))
favorite := r.FormValue("favorite") == "1"
if err := s.projects.SetFavorite(r.Context(), id, favorite); err != nil {
redirectWithFlash(w, r, "/", "error", err.Error())
return
}
redirectWithFlash(w, r, backTo(r, "/"), "success", "Favorit aktualisiert")
}
func (s *Server) handleState(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
id := strings.TrimSpace(r.FormValue("id"))
state := project.State(strings.TrimSpace(r.FormValue("state")))
if err := s.projects.SetState(r.Context(), id, state); err != nil {
redirectWithFlash(w, r, "/", "error", err.Error())
return
}
redirectWithFlash(w, r, backTo(r, "/"), "success", "Projektstatus aktualisiert")
}
func (s *Server) handleProject(w http.ResponseWriter, r *http.Request) {
trimmed := strings.Trim(strings.TrimPrefix(r.URL.Path, "/projects/"), "/")
if trimmed == "" {
http.NotFound(w, r)
return
}
parts := strings.Split(trimmed, "/")
projectKey := parts[0]
action := ""
if len(parts) == 2 {
action = parts[1]
}
current, ok, err := s.projects.Get(r.Context(), projectKey)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if !ok {
http.NotFound(w, r)
return
}
if r.Method == http.MethodPost {
switch action {
case "todo":
s.handleSaveTodo(w, r, current.ID, current.Path)
return
case "git", "git-branch":
s.handleBranchAction(w, r, current.ID)
return
case "open":
s.handleOpenProject(w, r, current.ID)
return
case "editor":
if r.FormValue("mode") == "create" {
s.handleProjectEditorCreate(w, r, current)
} else {
s.handleProjectEditorSave(w, r, current)
}
return
}
}
if r.Method == http.MethodGet && action != "" {
switch action {
case "git":
s.handleProjectGit(w, r, current)
return
case "todo":
s.handleProjectTodo(w, r, current)
return
case "files":
s.handleProjectFiles(w, r, current)
return
case "editor":
s.handleProjectEditor(w, r, current)
return
case "download":
s.handleProjectDownload(w, r, current)
return
}
}
if action != "" {
http.NotFound(w, r)
return
}
readmeBody := loadReadme(current.Path)
readmeHTML := renderMarkdown(readmeBody)
manifest := operations.LoadManifest(current.Path)
if err := projectTemplate.Execute(w, map[string]any{
"Project": current,
"Flash": readFlash(r),
"ReadmeBody": readmeBody,
"ReadmeHTML": template.HTML(readmeHTML),
"LabelsText": strings.Join(current.Labels, ", "),
"Manifest": manifest,
"Ports": operations.CollectPorts(r.Context(), []project.Project{current}),
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Server) handleProjectDownload(w http.ResponseWriter, r *http.Request, current project.Project) {
format := archive.Format(r.URL.Query().Get("format"))
extension := string(format)
contentType := "application/gzip"
if format == archive.FormatZIP {
contentType = "application/zip"
} else if format != archive.FormatTarGZ {
http.Error(w, "unsupported archive format", http.StatusBadRequest)
return
}
name := strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' {
return r
}
return '-'
}, current.Name)
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.%s"`, name, extension))
w.Header().Set("X-Content-Type-Options", "nosniff")
if err := archive.Write(w, current.Path, format, archive.Options{IgnoreDirs: s.cfg.Scanner.IgnoreDirs}); err != nil {
fmt.Fprintf(os.Stderr, "stackyard: archive %s: %v\n", current.Path, err)
}
}
func (s *Server) handleProjectEditor(w http.ResponseWriter, r *http.Request, current project.Project) {
selected := strings.TrimSpace(r.URL.Query().Get("path"))
content := ""
flash := readFlash(r)
if selected != "" {
var err error
content, err = readEditorFile(current.Path, selected)
if err != nil {
flash = flashMessage{Kind: "error", Text: err.Error()}
selected = ""
}
}
if err := editorTemplate.Execute(w, editorView{
Project: current, Files: loadFileTree(current.Path, s.cfg.Scanner.IgnoreDirs),
SelectedPath: selected, Content: content, Flash: flash, Protected: isProtectedEditorPath(selected),
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Server) handleProjectEditorSave(w http.ResponseWriter, r *http.Request, current project.Project) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
relPath := strings.TrimSpace(r.FormValue("path"))
if err := writeEditorFile(current.Path, relPath, r.FormValue("content")); err != nil {
redirectWithFlash(w, r, "/projects/"+current.ID+"/editor?path="+urlQueryEscape(filepath.ToSlash(relPath)), "error", err.Error())
return
}
target := "/projects/" + current.ID + "/editor?path=" + urlQueryEscape(filepath.ToSlash(relPath))
redirectWithFlash(w, r, target, "success", "Datei gespeichert")
}
func resolveEditorFile(projectDir, relativePath string) (string, error) {
relativePath = strings.TrimSpace(relativePath)
if relativePath == "" || filepath.IsAbs(relativePath) {
return "", errors.New("ungueltiger Dateipfad")
}
clean := filepath.Clean(filepath.FromSlash(relativePath))
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", errors.New("Datei liegt ausserhalb des Projekts")
}
root, err := filepath.EvalSymlinks(projectDir)
if err != nil {
return "", fmt.Errorf("Projektpfad aufloesen: %w", err)
}
target := filepath.Join(root, clean)
resolved, err := filepath.EvalSymlinks(target)
if err != nil {
return "", fmt.Errorf("Datei aufloesen: %w", err)
}
rel, err := filepath.Rel(root, resolved)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", errors.New("Datei liegt ausserhalb des Projekts")
}
info, err := os.Stat(resolved)
if err != nil {
return "", fmt.Errorf("Datei pruefen: %w", err)
}
if !info.Mode().IsRegular() {
return "", errors.New("nur regulaere Dateien koennen bearbeitet werden")
}
if info.Size() > maxEditorFileSize {
return "", errors.New("Datei ist groesser als 2 MiB")
}
return resolved, nil
}
func readEditorFile(projectDir, relativePath string) (string, error) {
path, err := resolveEditorFile(projectDir, relativePath)
if err != nil {
return "", err
}
data, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("Datei lesen: %w", err)
}
if !utf8.Valid(data) || strings.IndexByte(string(data), 0) >= 0 {
return "", errors.New("Binaerdateien koennen nicht bearbeitet werden")
}
return string(data), nil
}
func writeEditorFile(projectDir, relativePath, content string) error {
if isProtectedEditorPath(relativePath) {
return errors.New("diese sensible oder generierte Datei ist in der WebIDE schreibgeschuetzt")
}
path, err := resolveEditorFile(projectDir, relativePath)
if err != nil {
return err
}
if len(content) > maxEditorFileSize {
return errors.New("Inhalt ist groesser als 2 MiB")
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("Datei pruefen: %w", err)
}
if err := os.WriteFile(path, []byte(content), info.Mode().Perm()); err != nil {
return fmt.Errorf("Datei speichern: %w", err)
}
return nil
}
func isProtectedEditorPath(relativePath string) bool {
name := strings.ToLower(filepath.Base(filepath.Clean(relativePath)))
if name == ".env" || strings.HasPrefix(name, ".env.") || name == "credentials" || name == "credentials.json" {
return true
}
for _, suffix := range []string{".pem", ".key", ".pfx", ".p12", ".lock", "-lock.json", "go.sum"} {
if strings.HasSuffix(name, suffix) {
return true
}
}
return false
}
func (s *Server) handleProjectEditorCreate(w http.ResponseWriter, r *http.Request, current project.Project) {
relPath := strings.TrimSpace(filepath.ToSlash(r.FormValue("path")))
kind := r.FormValue("kind")
if relPath == "" || filepath.IsAbs(relPath) || relPath == ".." || strings.HasPrefix(filepath.Clean(relPath), ".."+string(filepath.Separator)) || isProtectedEditorPath(relPath) {
redirectWithFlash(w, r, "/projects/"+current.ID+"/editor", "error", "ungueltiger oder geschuetzter Pfad")
return
}
root, err := filepath.EvalSymlinks(current.Path)
if err != nil {
redirectWithFlash(w, r, "/projects/"+current.ID+"/editor", "error", err.Error())
return
}
target := filepath.Join(root, filepath.FromSlash(relPath))
parent, err := filepath.EvalSymlinks(filepath.Dir(target))
if err != nil {
redirectWithFlash(w, r, "/projects/"+current.ID+"/editor", "error", "übergeordneter Ordner existiert nicht")
return
}
inside, err := filepath.Rel(root, parent)
if err != nil || inside == ".." || strings.HasPrefix(inside, ".."+string(filepath.Separator)) {
redirectWithFlash(w, r, "/projects/"+current.ID+"/editor", "error", "Pfad liegt ausserhalb des Projekts")
return
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
redirectWithFlash(w, r, "/projects/"+current.ID+"/editor", "error", "Datei oder Ordner existiert bereits")
return
}
if kind == "folder" {
err = os.Mkdir(target, 0o755)
} else {
err = os.WriteFile(target, nil, 0o644)
}
if err != nil {
redirectWithFlash(w, r, "/projects/"+current.ID+"/editor", "error", fmt.Sprintf("anlegen: %v", err))
return
}
targetURL := "/projects/" + current.ID + "/editor"
if kind != "folder" {
targetURL += "?path=" + urlQueryEscape(relPath)
}
redirectWithFlash(w, r, targetURL, "success", "Eintrag angelegt")
}
func (s *Server) handleProjectGit(w http.ResponseWriter, r *http.Request, current project.Project) {
if !current.Git.IsRepo {
s.renderGitUnavailable(w, current, "Dieses Projekt ist kein Git-Repository.")
return
}
info, err := os.Stat(current.Path)
if err != nil {
s.renderGitUnavailable(w, current, "Der Projektpfad ist momentan nicht erreichbar. Bitte Laufwerk oder Netzwerkfreigabe prüfen und die Projekte neu scannen.")
return
}
if !info.IsDir() {
s.renderGitUnavailable(w, current, "Der gespeicherte Projektpfad ist kein Verzeichnis. Bitte die Projekte neu scannen.")
return
}
branches, currentBranch, err := s.gitClient.Branches(r.Context(), current.Path)
if err != nil {
s.renderGitUnavailable(w, current, "Git-Informationen konnten nicht geladen werden. Bitte den Projektpfad prüfen und die Projekte neu scannen.")
return
}
commits, err := s.gitClient.Log(r.Context(), current.Path, 20)
if err != nil {
s.renderGitUnavailable(w, current, "Git-Informationen konnten nicht geladen werden. Bitte das Repository prüfen.")
return
}
changedFiles, err := s.gitClient.ChangedFiles(r.Context(), current.Path)
if err != nil {
s.renderGitUnavailable(w, current, "Der Git-Arbeitsbaum konnte nicht gelesen werden. Bitte das Repository prüfen.")
return
}
diffSummary, err := s.gitClient.DiffSummary(r.Context(), current.Path)
if err != nil {
s.renderGitUnavailable(w, current, "Die Git-Diff-Zusammenfassung konnte nicht geladen werden. Bitte das Repository prüfen.")
return
}
if err := gitPanelTemplate.Execute(w, map[string]any{
"Project": current,
"Branches": branches,
"CurrentBranch": currentBranch,
"Commits": commits,
"ChangedFiles": changedFiles,
"DiffSummary": diffSummary,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Server) renderGitUnavailable(w http.ResponseWriter, current project.Project, message string) {
if err := gitUnavailableTemplate.Execute(w, map[string]any{
"Project": current,
"Message": message,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Server) handleProjectTodo(w http.ResponseWriter, r *http.Request, current project.Project) {
todoPath, todoBody, err := todo.Read(current.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := todoPanelTemplate.Execute(w, map[string]any{
"Project": current,
"TodoPath": todoPath,
"TodoBody": todoBody,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Server) handleProjectFiles(w http.ResponseWriter, r *http.Request, current project.Project) {
files := loadFileTree(current.Path, s.cfg.Scanner.IgnoreDirs)
if err := filesPanelTemplate.Execute(w, map[string]any{
"Files": files,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func (s *Server) handleSaveTodo(w http.ResponseWriter, r *http.Request, projectID, projectPath string) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if _, err := todo.Write(projectPath, r.FormValue("body")); err != nil {
redirectWithFlash(w, r, "/projects/"+projectID, "error", err.Error())
return
}
if _, err := s.projects.Refresh(r.Context()); err != nil {
redirectWithFlash(w, r, "/projects/"+projectID, "error", err.Error())
return
}
redirectWithFlash(w, r, "/projects/"+projectID, "success", "TODO gespeichert")
}
func (s *Server) handleBranchAction(w http.ResponseWriter, r *http.Request, projectID string) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
branch := strings.TrimSpace(r.FormValue("branch"))
mode := r.FormValue("mode")
var err error
switch mode {
case "checkout":
err = s.projects.CheckoutBranch(r.Context(), projectID, branch)
case "create":
err = s.projects.CreateBranch(r.Context(), projectID, branch)
default:
redirectWithFlash(w, r, "/projects/"+projectID, "error", "ungueltige Branch-Aktion")
return
}
if err != nil {
redirectWithFlash(w, r, "/projects/"+projectID, "error", err.Error())
return
}
message := fmt.Sprintf("Branch-Aktion erfolgreich: %s", branch)
if mode == "create" {
message = fmt.Sprintf("Branch erstellt: %s", branch)
}
redirectWithFlash(w, r, "/projects/"+projectID, "success", message)
}
func (s *Server) handleOpenProject(w http.ResponseWriter, r *http.Request, projectID string) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
action := launcher.Action(strings.TrimSpace(r.FormValue("action")))
if err := s.projects.Launch(r.Context(), projectID, action); err != nil {
redirectWithFlash(w, r, "/projects/"+projectID, "error", err.Error())
return
}
redirectWithFlash(w, r, "/projects/"+projectID, "success", fmt.Sprintf("Projekt geoeffnet: %s", action))
}
func loadReadme(projectDir string) string {
path := filepath.Join(projectDir, "README.md")
data, err := os.ReadFile(path)
if err != nil {
return ""
}
return string(data)
}
func loadFileTree(projectDir string, ignoreDirs []string) []fileTreeNode {
nodes, err := buildFileTree(projectDir, projectDir, makeIgnoreSet(ignoreDirs))
if err != nil {
return nil
}
return nodes
}
func buildFileTree(rootDir, currentDir string, ignoreDirs map[string]struct{}) ([]fileTreeNode, error) {
remaining := maxFileTreeEntries
return buildFileTreeDepth(rootDir, currentDir, ignoreDirs, 0, &remaining)
}
func buildFileTreeDepth(rootDir, currentDir string, ignoreDirs map[string]struct{}, depth int, remaining *int) ([]fileTreeNode, error) {
if depth > maxFileTreeDepth || *remaining <= 0 {
return nil, nil
}
entries, err := os.ReadDir(currentDir)
if err != nil {
return nil, err
}
nodes := make([]fileTreeNode, 0, len(entries))
for _, entry := range entries {
if *remaining <= 0 {
break
}
if _, ignored := ignoreDirs[entry.Name()]; ignored && entry.IsDir() {
continue
}
if entry.Type()&os.ModeSymlink != 0 {
continue
}
fullPath := filepath.Join(currentDir, entry.Name())
*remaining--
node := fileTreeNode{
Name: entry.Name(),
Path: strings.TrimPrefix(strings.TrimPrefix(fullPath, rootDir), string(filepath.Separator)),
IsDir: entry.IsDir(),
}
if entry.IsDir() {
children, err := buildFileTreeDepth(rootDir, fullPath, ignoreDirs, depth+1, remaining)
if err != nil {
return nil, err
}
node.Children = children
node.Name += "/"
}
nodes = append(nodes, node)
}
slices.SortFunc(nodes, func(a, b fileTreeNode) int {
if a.IsDir != b.IsDir {
if a.IsDir {
return -1
}
return 1
}
return strings.Compare(a.Name, b.Name)
})
return nodes, nil
}
func makeIgnoreSet(ignoreDirs []string) map[string]struct{} {
set := make(map[string]struct{}, len(ignoreDirs))
for _, dir := range ignoreDirs {
set[dir] = struct{}{}
}
return set
}
func readFlash(r *http.Request) flashMessage {
q := r.URL.Query()
kind := q.Get("flash_kind")
text := q.Get("flash")
if text == "" {
return flashMessage{}
}
if kind == "" {
kind = "info"
}
return flashMessage{Kind: kind, Text: text}
}
func redirectWithFlash(w http.ResponseWriter, r *http.Request, path, kind, text string) {
q := make([]string, 0, 2)
if kind != "" {
q = append(q, "flash_kind="+urlQueryEscape(kind))
}
if text != "" {
q = append(q, "flash="+urlQueryEscape(text))
}
target := path
if len(q) > 0 {
separator := "?"
if strings.Contains(target, "?") {
separator = "&"
}
target += separator + strings.Join(q, "&")
}
http.Redirect(w, r, target, http.StatusSeeOther)
}
func urlQueryEscape(value string) string {
replacer := strings.NewReplacer(
"%", "%25",
" ", "%20",
"!", "%21",
"\"", "%22",
"#", "%23",
"$", "%24",
"&", "%26",
"'", "%27",
"(", "%28",
")", "%29",
"+", "%2B",
",", "%2C",
"/", "%2F",
":", "%3A",
";", "%3B",
"=", "%3D",
"?", "%3F",
"@", "%40",
"[", "%5B",
"]", "%5D",
)
return replacer.Replace(value)
}
func renderMarkdown(input string) string {
lines := strings.Split(strings.ReplaceAll(input, "\r\n", "\n"), "\n")
var b strings.Builder
inList := false
inCode := false
closeList := func() {
if inList {
b.WriteString("</ul>")
inList = false
}
}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "```"):
closeList()
if !inCode {
b.WriteString("<pre><code>")
inCode = true
} else {
b.WriteString("</code></pre>")
inCode = false
}
case inCode:
b.WriteString(html.EscapeString(line))
b.WriteByte('\n')
case trimmed == "":
closeList()
case strings.HasPrefix(trimmed, "# "):
closeList()
b.WriteString("<h1>" + html.EscapeString(strings.TrimSpace(trimmed[2:])) + "</h1>")
case strings.HasPrefix(trimmed, "## "):
closeList()
b.WriteString("<h2>" + html.EscapeString(strings.TrimSpace(trimmed[3:])) + "</h2>")
case strings.HasPrefix(trimmed, "### "):
closeList()
b.WriteString("<h3>" + html.EscapeString(strings.TrimSpace(trimmed[4:])) + "</h3>")
case strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* "):
if !inList {
b.WriteString("<ul>")
inList = true
}
b.WriteString("<li>" + html.EscapeString(strings.TrimSpace(trimmed[2:])) + "</li>")
default:
closeList()
b.WriteString("<p>" + html.EscapeString(trimmed) + "</p>")
}
}
closeList()
if inCode {
b.WriteString("</code></pre>")
}
return b.String()
}
func filterProjects(projects []project.Project, query string) []project.Project {
query = strings.ToLower(strings.TrimSpace(query))
if query == "" {
return projects
}
filtered := make([]project.Project, 0, len(projects))
for _, p := range projects {
haystack := []string{
p.Name,
p.RootName,
p.Path,
p.PrimaryType,
p.Git.Branch,
strings.Join(p.MiseTools, " "),
strings.Join(p.ComposeFiles, " "),
p.Description,
strings.Join(p.Labels, " "),
}
for _, port := range p.Ports {
haystack = append(haystack, strconv.Itoa(port))
}
if strings.Contains(strings.ToLower(strings.Join(haystack, " ")), query) {
filtered = append(filtered, p)
}
}
return filtered
}
func filterByState(projects []project.Project, state string) []project.Project {
state = strings.ToLower(strings.TrimSpace(state))
filtered := make([]project.Project, 0, len(projects))
for _, p := range projects {
if strings.ToLower(string(p.State)) == state {
filtered = append(filtered, p)
}
}
return filtered
}
func filterFavorites(projects []project.Project) []project.Project {
filtered := make([]project.Project, 0, len(projects))
for _, p := range projects {
if p.Favorite {
filtered = append(filtered, p)
}
}
return filtered
}
func filterWithoutArchived(projects []project.Project) []project.Project {
filtered := make([]project.Project, 0, len(projects))
for _, p := range projects {
if p.State != project.StateArchived {
filtered = append(filtered, p)
}
}
return filtered
}
func backTo(r *http.Request, fallback string) string {
if target := strings.TrimSpace(r.FormValue("return_to")); target != "" {
parsed, err := url.Parse(target)
if err == nil && !parsed.IsAbs() && parsed.Host == "" && strings.HasPrefix(parsed.Path, "/") && !strings.HasPrefix(parsed.Path, "//") {
return target
}
}
return fallback
}
var gitPanelTemplate = template.Must(template.New("git-panel").Parse(`
<section class="card">
<div class="section-head">
<h2>Branches</h2>
<div class="section-note">Aktiv: {{if .CurrentBranch}}{{.CurrentBranch}}{{else}}-{{end}}</div>
</div>
<form class="inline-form" method="post" action="/projects/{{.Project.ID}}/git-branch" {{if .Project.Git.Dirty}}onsubmit="return confirm('Das Repository enthält ungespeicherte Änderungen. Branch-Aktion trotzdem ausführen?')"{{end}}>
<select name="branch"><option value="">Branch auswählen</option>{{range .Branches}}<option value="{{.}}" {{if eq . $.CurrentBranch}}selected{{end}}>{{.}}</option>{{end}}</select>
<button type="submit" name="mode" value="checkout">Wechseln</button>
</form>
<form class="inline-form" method="post" action="/projects/{{.Project.ID}}/git-branch" {{if .Project.Git.Dirty}}onsubmit="return confirm('Das Repository enthält ungespeicherte Änderungen. Branch trotzdem anlegen?')"{{end}}>
<input type="text" name="branch" placeholder="neuer-branch">
<button type="submit" name="mode" value="create">Anlegen</button>
</form>
</section>
{{if .ChangedFiles}}<section class="card"><div class="section-head"><h2>Geänderte Dateien</h2><div class="section-note">{{len .ChangedFiles}} Einträge</div></div><ul class="list">{{range .ChangedFiles}}<li><span class="pill">{{.Status}}</span> <span class="path">{{.Path}}</span></li>{{end}}</ul></section>{{end}}
{{if .DiffSummary}}<section class="card"><div class="section-head"><h2>Diff-Zusammenfassung</h2></div><pre>{{.DiffSummary}}</pre></section>{{end}}
<section class="card">
<div class="section-head">
<h2>Commits</h2>
<div class="section-note">Letzte 20 Eintraege</div>
</div>
<ul class="list">
{{range .Commits}}
<li>
<div class="commit-subject">{{.Subject}}</div>
<div class="muted">{{.Hash}} · {{.Committed.Format "2006-01-02 15:04"}}</div>
</li>
{{else}}
<li class="empty">Keine Commits verfuegbar.</li>
{{end}}
</ul>
</section>`))
var gitUnavailableTemplate = template.Must(template.New("git-unavailable").Parse(`
<section class="card">
<div class="section-head">
<h2>Git nicht verfügbar</h2>
<div class="section-note">{{.Project.Path}}</div>
</div>
<p class="empty">{{.Message}}</p>
</section>`))
var todoPanelTemplate = template.Must(template.New("todo-panel").Parse(`
<form method="post" action="/projects/{{.Project.ID}}/todo">
<div class="section-note" style="margin-bottom:0.75rem;">{{if .TodoPath}}{{.TodoPath}}{{else}}Neue Datei wird unter .stackyard/todo.md angelegt{{end}}</div>
<textarea name="body">{{.TodoBody}}</textarea>
<p><button type="submit">TODO speichern</button></p>
</form>`))
var filesPanelTemplate = template.Must(template.New("files-panel").Parse(`
{{define "tree"}}
<ul>
{{range .}}
<li>
{{if .Children}}
<details class="tree-dir" open>
<summary>{{.Name}}</summary>
{{template "tree" .Children}}
</details>
{{else}}
<span class="tree-file">{{.Name}}</span>
{{end}}
</li>
{{else}}
<li class="empty">Keine Dateien verfuegbar.</li>
{{end}}
</ul>
{{end}}
{{template "tree" .Files}}
`))