diff --git a/cmd/server/main.go b/cmd/server/main.go index b1066ca..601e0af 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -32,6 +32,7 @@ import ( "github.com/alexei/docker-watcher/internal/proxy" "github.com/alexei/docker-watcher/internal/registry" "github.com/alexei/docker-watcher/internal/stale" + "github.com/alexei/docker-watcher/internal/staticsite" "github.com/alexei/docker-watcher/internal/store" "github.com/alexei/docker-watcher/internal/webhook" ) @@ -280,8 +281,16 @@ func main() { } scheduleAutobackup(settings.BackupEnabled, settings.BackupIntervalHours) + // Initialize static site manager and health checker. + staticSiteMgr := staticsite.NewManager(db, dockerClient, proxyProvider, eventBus, encKey) + staticSiteHealth := staticsite.NewHealthChecker(db, dockerClient, staticSiteMgr) + if err := staticSiteHealth.Start("2m"); err != nil { + slog.Warn("failed to start static site health checker", "error", err) + } + // Build API server. apiServer := api.NewServer(db, dockerClient, npmClient, proxyProvider, dep, webhookHandler, eventBus, encKey) + apiServer.SetStaticSiteManager(staticSiteMgr) apiServer.SetStaleScanner(staleScanner) apiServer.SetBackupEngine(backupEngine) apiServer.SetDBPath(dbPath) @@ -341,6 +350,7 @@ func main() { // Stop accepting new work. cronScheduler.Stop() eventBus.Unsubscribe(notifySub) + staticSiteHealth.Stop() staleScanner.Stop() poller.Stop() diff --git a/go.mod b/go.mod index 54c9737..2b2bdb5 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/yuin/goldmark v1.8.2 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect go.opentelemetry.io/otel v1.35.0 // indirect diff --git a/go.sum b/go.sum index 4374b63..1bab368 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,8 @@ github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= diff --git a/internal/api/router.go b/internal/api/router.go index 0e38c49..2befa58 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -16,6 +16,7 @@ import ( "github.com/alexei/docker-watcher/internal/npm" "github.com/alexei/docker-watcher/internal/proxy" "github.com/alexei/docker-watcher/internal/stale" + "github.com/alexei/docker-watcher/internal/staticsite" "github.com/alexei/docker-watcher/internal/store" "github.com/alexei/docker-watcher/internal/webhook" ) @@ -42,6 +43,7 @@ type Server struct { dnsProvider dns.Provider onDNSProviderChanged DNSProviderChangedFunc + staticSiteManager *staticsite.Manager backupEngine *backup.Engine dbPath string shutdownFunc func() // called after restore to trigger graceful shutdown @@ -83,6 +85,11 @@ func NewServer( return s } +// SetStaticSiteManager sets the static site manager on the server. +func (s *Server) SetStaticSiteManager(mgr *staticsite.Manager) { + s.staticSiteManager = mgr +} + // SetStaleScanner sets the stale scanner on the server. // Called after both the API server and scanner are initialized. func (s *Server) SetStaleScanner(scanner *stale.Scanner) { @@ -243,6 +250,26 @@ func (s *Server) Router() chi.Router { r.Post("/volumes/{volId}/upload", s.uploadToVolume) }) }) + // Static sites. + r.Get("/sites", s.listStaticSites) + r.Route("/sites/{id}", func(r chi.Router) { + r.Get("/", s.getStaticSite) + r.Get("/secrets", s.listStaticSiteSecrets) + + // Admin-only mutations. + r.Group(func(r chi.Router) { + r.Use(auth.AdminOnly) + r.Put("/", s.updateStaticSite) + r.Delete("/", s.deleteStaticSite) + r.Post("/deploy", s.deployStaticSite) + r.Post("/stop", s.stopStaticSite) + r.Post("/start", s.startStaticSite) + r.Post("/secrets", s.createStaticSiteSecret) + r.Put("/secrets/{sid}", s.updateStaticSiteSecret) + r.Delete("/secrets/{sid}", s.deleteStaticSiteSecret) + }) + }) + r.Get("/deploys", s.listDeploys) r.Get("/deploys/{id}/logs", s.streamDeployLogs) r.Get("/events", s.streamEvents) @@ -294,6 +321,14 @@ func (s *Server) Router() chi.Router { // Project creation. r.Post("/projects", s.createProject) + // Static site creation and tools. + r.Post("/sites", s.createStaticSite) + r.Post("/sites/test-connection", s.testStaticSiteConnection) + r.Post("/sites/branches", s.listStaticSiteBranches) + r.Post("/sites/tree", s.listStaticSiteTree) + r.Post("/sites/detect-provider", s.detectStaticSiteProvider) + r.Post("/sites/repos", s.listStaticSiteRepos) + // Quick deploy endpoints. r.Post("/deploy/inspect", s.inspectImage) r.Post("/deploy/quick", s.quickDeploy) diff --git a/internal/api/static_sites.go b/internal/api/static_sites.go new file mode 100644 index 0000000..68ab8bb --- /dev/null +++ b/internal/api/static_sites.go @@ -0,0 +1,613 @@ +package api + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/go-chi/chi/v5" + + "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/store" +) + +// ── List / Get ───────────────────────────────────────────────────────── + +func (s *Server) listStaticSites(w http.ResponseWriter, r *http.Request) { + sites, err := s.store.GetAllStaticSites() + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list static sites") + return + } + + // Mask access tokens in response. + for i := range sites { + sites[i].AccessToken = maskToken(sites[i].AccessToken) + } + + respondJSON(w, http.StatusOK, sites) +} + +func (s *Server) getStaticSite(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + site, err := s.store.GetStaticSiteByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "static site") + return + } + respondError(w, http.StatusInternalServerError, "failed to get static site") + return + } + + site.AccessToken = maskToken(site.AccessToken) + respondJSON(w, http.StatusOK, site) +} + +// ── Create ────────────────────────────────────────────────────────── + +type createStaticSiteRequest struct { + Name string `json:"name"` + Provider string `json:"provider"` + GiteaURL string `json:"gitea_url"` + RepoOwner string `json:"repo_owner"` + RepoName string `json:"repo_name"` + Branch string `json:"branch"` + FolderPath string `json:"folder_path"` + AccessToken string `json:"access_token"` + Domain string `json:"domain"` + Mode string `json:"mode"` + RenderMarkdown bool `json:"render_markdown"` + SyncTrigger string `json:"sync_trigger"` + TagPattern string `json:"tag_pattern"` +} + +func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) { + var req createStaticSiteRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Name == "" || req.GiteaURL == "" || req.RepoOwner == "" || req.RepoName == "" { + respondError(w, http.StatusBadRequest, "name, gitea_url, repo_owner, and repo_name are required") + return + } + + if req.Branch == "" { + req.Branch = "main" + } + if req.Mode == "" { + req.Mode = "static" + } + if req.SyncTrigger == "" { + req.SyncTrigger = "manual" + } + + // Encrypt access token if provided. + encryptedToken := "" + if req.AccessToken != "" { + encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to encrypt access token") + return + } + encryptedToken = encrypted + } + + site := store.StaticSite{ + Name: req.Name, + Provider: req.Provider, + GiteaURL: strings.TrimRight(req.GiteaURL, "/"), + RepoOwner: req.RepoOwner, + RepoName: req.RepoName, + Branch: req.Branch, + FolderPath: req.FolderPath, + AccessToken: encryptedToken, + Domain: req.Domain, + Mode: req.Mode, + RenderMarkdown: req.RenderMarkdown, + SyncTrigger: req.SyncTrigger, + TagPattern: req.TagPattern, + Status: "idle", + } + + created, err := s.store.CreateStaticSite(site) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create static site: "+err.Error()) + return + } + + created.AccessToken = maskToken(created.AccessToken) + respondJSON(w, http.StatusCreated, created) +} + +// ── Update ────────────────────────────────────────────────────────── + +func (s *Server) updateStaticSite(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + existing, err := s.store.GetStaticSiteByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "static site") + return + } + respondError(w, http.StatusInternalServerError, "failed to get static site") + return + } + + var req createStaticSiteRequest + if !decodeJSON(w, r, &req) { + return + } + + // Update fields. + if req.Name != "" { + existing.Name = req.Name + } + if req.Provider != "" { + existing.Provider = req.Provider + } + if req.GiteaURL != "" { + existing.GiteaURL = strings.TrimRight(req.GiteaURL, "/") + } + if req.RepoOwner != "" { + existing.RepoOwner = req.RepoOwner + } + if req.RepoName != "" { + existing.RepoName = req.RepoName + } + if req.Branch != "" { + existing.Branch = req.Branch + } + if req.FolderPath != "" { + existing.FolderPath = req.FolderPath + } + if req.Domain != "" { + existing.Domain = req.Domain + } + if req.Mode != "" { + existing.Mode = req.Mode + } + if req.SyncTrigger != "" { + existing.SyncTrigger = req.SyncTrigger + } + existing.RenderMarkdown = req.RenderMarkdown + existing.TagPattern = req.TagPattern + + // Update access token only if a new one is provided. + if req.AccessToken != "" { + encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to encrypt access token") + return + } + existing.AccessToken = encrypted + } + + if err := s.store.UpdateStaticSite(existing); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update static site: "+err.Error()) + return + } + + existing.AccessToken = maskToken(existing.AccessToken) + respondJSON(w, http.StatusOK, existing) +} + +// ── Delete ────────────────────────────────────────────────────────── + +func (s *Server) deleteStaticSite(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + // Remove container and proxy route first. + if s.staticSiteManager != nil { + if err := s.staticSiteManager.Remove(r.Context(), id); err != nil { + // Log but don't fail — still delete the DB record. + respondError(w, http.StatusInternalServerError, "failed to remove site resources: "+err.Error()) + return + } + } + + if err := s.store.DeleteStaticSite(id); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "static site") + return + } + respondError(w, http.StatusInternalServerError, "failed to delete static site") + return + } + + respondJSON(w, http.StatusOK, map[string]string{"deleted": id}) +} + +// ── Deploy ────────────────────────────────────────────────────────── + +func (s *Server) deployStaticSite(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + if s.staticSiteManager == nil { + respondError(w, http.StatusInternalServerError, "static site manager not initialized") + return + } + + // Trigger deploy asynchronously with a detached context + // (the HTTP request context is canceled when the response is sent). + // Manual deploys always force a full rebuild + proxy regeneration. + go func() { + ctx := context.Background() + if err := s.staticSiteManager.Deploy(ctx, id, true); err != nil { + // Error is already stored in the site status. + return + } + }() + + respondJSON(w, http.StatusAccepted, map[string]string{"status": "deploying"}) +} + +// ── Stop / Start ──────────────────────────────────────────────────── + +func (s *Server) stopStaticSite(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + if s.staticSiteManager == nil { + respondError(w, http.StatusInternalServerError, "static site manager not initialized") + return + } + + go func() { + ctx := context.Background() + _ = s.staticSiteManager.Stop(ctx, id) + }() + + respondJSON(w, http.StatusAccepted, map[string]string{"status": "stopping"}) +} + +func (s *Server) startStaticSite(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + if s.staticSiteManager == nil { + respondError(w, http.StatusInternalServerError, "static site manager not initialized") + return + } + + go func() { + ctx := context.Background() + _ = s.staticSiteManager.Start(ctx, id) + }() + + respondJSON(w, http.StatusAccepted, map[string]string{"status": "starting"}) +} + +// ── Test Connection ───────────────────────────────────────────────── + +type testConnectionRequest struct { + Provider string `json:"provider"` + GiteaURL string `json:"gitea_url"` + AccessToken string `json:"access_token"` + RepoOwner string `json:"repo_owner"` + RepoName string `json:"repo_name"` +} + +func (s *Server) testStaticSiteConnection(w http.ResponseWriter, r *http.Request) { + var req testConnectionRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.GiteaURL == "" || req.RepoOwner == "" || req.RepoName == "" { + respondError(w, http.StatusBadRequest, "gitea_url, repo_owner, and repo_name are required") + return + } + + // Encrypt token for the manager to decrypt (consistent handling). + encToken := "" + if req.AccessToken != "" { + encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to process token") + return + } + encToken = encrypted + } + + if s.staticSiteManager == nil { + respondError(w, http.StatusInternalServerError, "static site manager not initialized") + return + } + + if err := s.staticSiteManager.TestConnection(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName); err != nil { + respondError(w, http.StatusBadRequest, "connection failed: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, map[string]string{"status": "connected"}) +} + +// ── Branches ──────────────────────────────────────────────────────── + +type listBranchesRequest struct { + Provider string `json:"provider"` + GiteaURL string `json:"gitea_url"` + AccessToken string `json:"access_token"` + RepoOwner string `json:"repo_owner"` + RepoName string `json:"repo_name"` +} + +func (s *Server) listStaticSiteBranches(w http.ResponseWriter, r *http.Request) { + var req listBranchesRequest + if !decodeJSON(w, r, &req) { + return + } + + encToken := "" + if req.AccessToken != "" { + encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to process token") + return + } + encToken = encrypted + } + + if s.staticSiteManager == nil { + respondError(w, http.StatusInternalServerError, "static site manager not initialized") + return + } + + branches, err := s.staticSiteManager.ListBranches(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName) + if err != nil { + respondError(w, http.StatusBadRequest, "failed to list branches: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, branches) +} + +// ── Tree ──────────────────────────────────────────────────────────── + +type listTreeRequest struct { + Provider string `json:"provider"` + GiteaURL string `json:"gitea_url"` + AccessToken string `json:"access_token"` + RepoOwner string `json:"repo_owner"` + RepoName string `json:"repo_name"` + Branch string `json:"branch"` +} + +func (s *Server) listStaticSiteTree(w http.ResponseWriter, r *http.Request) { + var req listTreeRequest + if !decodeJSON(w, r, &req) { + return + } + + encToken := "" + if req.AccessToken != "" { + encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to process token") + return + } + encToken = encrypted + } + + if s.staticSiteManager == nil { + respondError(w, http.StatusInternalServerError, "static site manager not initialized") + return + } + + entries, err := s.staticSiteManager.ListTree(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName, req.Branch) + if err != nil { + respondError(w, http.StatusBadRequest, "failed to list tree: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, entries) +} + +// ── Secrets ───────────────────────────────────────────────────────── + +func (s *Server) listStaticSiteSecrets(w http.ResponseWriter, r *http.Request) { + siteID := chi.URLParam(r, "id") + secrets, err := s.store.GetStaticSiteSecretsBySiteID(siteID) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to list secrets") + return + } + + // Mask encrypted values. + for i := range secrets { + if secrets[i].Encrypted { + secrets[i].Value = "••••••••" + } + } + + respondJSON(w, http.StatusOK, secrets) +} + +type createSecretRequest struct { + Key string `json:"key"` + Value string `json:"value"` + Encrypted bool `json:"encrypted"` +} + +func (s *Server) createStaticSiteSecret(w http.ResponseWriter, r *http.Request) { + siteID := chi.URLParam(r, "id") + + var req createSecretRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Key == "" { + respondError(w, http.StatusBadRequest, "key is required") + return + } + + value := req.Value + if req.Encrypted && value != "" { + encrypted, err := crypto.Encrypt(s.encKey, value) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to encrypt secret") + return + } + value = encrypted + } + + secret := store.StaticSiteSecret{ + SiteID: siteID, + Key: req.Key, + Value: value, + Encrypted: req.Encrypted, + } + + created, err := s.store.CreateStaticSiteSecret(secret) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to create secret: "+err.Error()) + return + } + + if created.Encrypted { + created.Value = "••••••••" + } + respondJSON(w, http.StatusCreated, created) +} + +func (s *Server) updateStaticSiteSecret(w http.ResponseWriter, r *http.Request) { + secretID := chi.URLParam(r, "sid") + + existing, err := s.store.GetStaticSiteSecretByID(secretID) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "secret") + return + } + respondError(w, http.StatusInternalServerError, "failed to get secret") + return + } + + var req createSecretRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.Key != "" { + existing.Key = req.Key + } + existing.Encrypted = req.Encrypted + + if req.Value != "" { + value := req.Value + if req.Encrypted { + encrypted, err := crypto.Encrypt(s.encKey, value) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to encrypt secret") + return + } + value = encrypted + } + existing.Value = value + } + + if err := s.store.UpdateStaticSiteSecret(existing); err != nil { + respondError(w, http.StatusInternalServerError, "failed to update secret: "+err.Error()) + return + } + + if existing.Encrypted { + existing.Value = "••••••••" + } + respondJSON(w, http.StatusOK, existing) +} + +func (s *Server) deleteStaticSiteSecret(w http.ResponseWriter, r *http.Request) { + secretID := chi.URLParam(r, "sid") + + if err := s.store.DeleteStaticSiteSecret(secretID); err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "secret") + return + } + respondError(w, http.StatusInternalServerError, "failed to delete secret") + return + } + + respondJSON(w, http.StatusOK, map[string]string{"deleted": secretID}) +} + +// ── List Repos ────────────────────────────────────────────────────── + +type listReposRequest struct { + Provider string `json:"provider"` + GiteaURL string `json:"gitea_url"` + AccessToken string `json:"access_token"` + Query string `json:"query"` +} + +func (s *Server) listStaticSiteRepos(w http.ResponseWriter, r *http.Request) { + var req listReposRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.GiteaURL == "" { + respondError(w, http.StatusBadRequest, "gitea_url is required") + return + } + + encToken := "" + if req.AccessToken != "" { + encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to process token") + return + } + encToken = encrypted + } + + if s.staticSiteManager == nil { + respondError(w, http.StatusInternalServerError, "static site manager not initialized") + return + } + + repos, err := s.staticSiteManager.ListRepos(r.Context(), req.Provider, req.GiteaURL, encToken, req.Query) + if err != nil { + respondError(w, http.StatusBadRequest, "failed to list repos: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, repos) +} + +// ── Detect Provider ───────────────────────────────────────────────── + +type detectProviderRequest struct { + URL string `json:"url"` +} + +func (s *Server) detectStaticSiteProvider(w http.ResponseWriter, r *http.Request) { + var req detectProviderRequest + if !decodeJSON(w, r, &req) { + return + } + + if req.URL == "" { + respondError(w, http.StatusBadRequest, "url is required") + return + } + + if s.staticSiteManager == nil { + respondError(w, http.StatusInternalServerError, "static site manager not initialized") + return + } + + provider := s.staticSiteManager.DetectProvider(r.Context(), req.URL) + respondJSON(w, http.StatusOK, map[string]string{"provider": provider}) +} + +// maskToken returns a masked version of a token string for API responses. +func maskToken(token string) string { + if token == "" { + return "" + } + return "••••••••" +} diff --git a/internal/docker/build.go b/internal/docker/build.go new file mode 100644 index 0000000..56b95a8 --- /dev/null +++ b/internal/docker/build.go @@ -0,0 +1,95 @@ +package docker + +import ( + "archive/tar" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/moby/moby/client" +) + +// BuildImage builds a Docker image from a directory containing a Dockerfile. +// The directory is packaged as a tar archive and sent to the Docker daemon. +// The tag parameter is the image name:tag to apply (e.g., "dw-site-myapp:latest"). +func (c *Client) BuildImage(ctx context.Context, contextDir, tag string) error { + // Create tar archive of the build context. + pr, pw := io.Pipe() + + go func() { + tw := tar.NewWriter(pw) + err := filepath.Walk(contextDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(contextDir, path) + if err != nil { + return fmt.Errorf("rel path: %w", err) + } + relPath = filepath.ToSlash(relPath) + + if relPath == "." { + return nil + } + + header, err := tar.FileInfoHeader(info, "") + if err != nil { + return fmt.Errorf("tar header for %s: %w", relPath, err) + } + header.Name = relPath + + if err := tw.WriteHeader(header); err != nil { + return fmt.Errorf("write tar header for %s: %w", relPath, err) + } + + if info.IsDir() { + return nil + } + + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("open %s: %w", path, err) + } + defer file.Close() + + if _, err := io.Copy(tw, file); err != nil { + return fmt.Errorf("copy %s to tar: %w", relPath, err) + } + + return nil + }) + + if closeErr := tw.Close(); closeErr != nil && err == nil { + err = closeErr + } + pw.CloseWithError(err) + }() + + resp, err := c.api.ImageBuild(ctx, pr, client.ImageBuildOptions{ + Dockerfile: "Dockerfile", + Tags: []string{tag}, + Remove: true, + ForceRemove: true, + }) + if err != nil { + return fmt.Errorf("build image %s: %w", tag, err) + } + defer resp.Body.Close() + + // Read the build output to completion (required for the build to finish). + output, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read build output for %s: %w", tag, err) + } + + // Check for error in build output. + if strings.Contains(string(output), `"error"`) { + return fmt.Errorf("build image %s: build errors in output", tag) + } + + return nil +} diff --git a/internal/events/bus.go b/internal/events/bus.go index ecf92c8..3b7c4ef 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -21,6 +21,9 @@ const ( // EventLog is emitted for audit trail and operational log entries. EventLog EventType = "event_log" + + // EventStaticSiteStatus is emitted when a static site status changes. + EventStaticSiteStatus EventType = "static_site_status" ) // Event is a single event published on the bus. @@ -64,6 +67,13 @@ type EventLogPayload struct { CreatedAt string `json:"created_at"` } +// StaticSiteStatusPayload is the payload for EventStaticSiteStatus events. +type StaticSiteStatusPayload struct { + SiteID string `json:"site_id"` + Name string `json:"name"` + Status string `json:"status"` +} + // Subscriber is a channel that receives events. type Subscriber chan Event diff --git a/internal/proxy/none.go b/internal/proxy/none.go index 5fef827..1621249 100644 --- a/internal/proxy/none.go +++ b/internal/proxy/none.go @@ -17,6 +17,10 @@ func (n *NoneProvider) DeleteRoute(_ context.Context, _ string) error { return nil } +func (n *NoneProvider) RouteExists(_ context.Context, _ string) (bool, error) { + return true, nil +} + func (n *NoneProvider) ContainerLabels(_ string, _ int) map[string]string { return nil } diff --git a/internal/proxy/npm_provider.go b/internal/proxy/npm_provider.go index 938010d..e52a18a 100644 --- a/internal/proxy/npm_provider.go +++ b/internal/proxy/npm_provider.go @@ -101,6 +101,17 @@ func (p *NpmProvider) DeleteRoute(ctx context.Context, routeID string) error { return p.client.DeleteProxyHost(ctx, id) } +func (p *NpmProvider) RouteExists(ctx context.Context, domain string) (bool, error) { + if err := p.auth(ctx); err != nil { + return false, err + } + _, found, err := p.client.FindProxyHostByDomain(ctx, domain) + if err != nil { + return false, fmt.Errorf("find proxy host: %w", err) + } + return found, nil +} + func (p *NpmProvider) ContainerLabels(_ string, _ int) map[string]string { // NPM configures routing via its API, not Docker labels. return nil diff --git a/internal/proxy/provider.go b/internal/proxy/provider.go index cd2705f..5abb6c5 100644 --- a/internal/proxy/provider.go +++ b/internal/proxy/provider.go @@ -27,6 +27,10 @@ type Provider interface { // Does nothing if routeID is empty. DeleteRoute(ctx context.Context, routeID string) error + // RouteExists returns true if a proxy route exists for the given domain. + // Used for health checks during auto-sync to detect externally removed routes. + RouteExists(ctx context.Context, domain string) (bool, error) + // ContainerLabels returns Docker labels to set on containers at creation time. // Traefik uses labels for auto-discovery; NPM and None return nil. ContainerLabels(domain string, port int) map[string]string diff --git a/internal/proxy/traefik_provider.go b/internal/proxy/traefik_provider.go index 2a247eb..70af272 100644 --- a/internal/proxy/traefik_provider.go +++ b/internal/proxy/traefik_provider.go @@ -48,6 +48,12 @@ func (t *TraefikProvider) DeleteRoute(_ context.Context, _ string) error { return nil } +// RouteExists for Traefik is a no-op (always returns true) since routes are +// managed via container labels and don't need separate tracking. +func (t *TraefikProvider) RouteExists(_ context.Context, _ string) (bool, error) { + return true, nil +} + // ContainerLabels returns Docker labels for Traefik auto-discovery. func (t *TraefikProvider) ContainerLabels(domain string, port int) map[string]string { name := sanitizeDomain(domain) diff --git a/internal/staticsite/deno/template.go b/internal/staticsite/deno/template.go new file mode 100644 index 0000000..09385a1 --- /dev/null +++ b/internal/staticsite/deno/template.go @@ -0,0 +1,253 @@ +package deno + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "text/template" +) + +// RouteEntry represents a parsed API endpoint from a TypeScript file. +type RouteEntry struct { + Method string // GET, POST, PUT, DELETE, PATCH + Path string // e.g., "/api/weather/current" + ImportPath string // relative import path, e.g., "./api/weather.ts" + FunctionName string // original export name, e.g., "API_get_current" +} + +// validMethods lists the HTTP methods recognized by the convention. +var validMethods = map[string]bool{ + "get": true, "post": true, "put": true, "delete": true, "patch": true, +} + +// apiExportPattern matches "export async function API_..." or "export function API_..." +var apiExportPattern = regexp.MustCompile(`^export\s+(?:async\s+)?function\s+(API_\w+)`) + +// ScanRoutes scans all .ts files in the api/ subdirectory for API_ prefixed exports +// and returns a list of RouteEntry for each discovered endpoint. +func ScanRoutes(apiDir string) ([]RouteEntry, error) { + var routes []RouteEntry + + err := filepath.Walk(apiDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".ts" && ext != ".js" { + return nil + } + + // Derive the base route from file path relative to apiDir. + relPath, err := filepath.Rel(apiDir, path) + if err != nil { + return fmt.Errorf("rel path: %w", err) + } + // Convert "weather.ts" → "weather", "sub/weather.ts" → "sub/weather" + baseName := strings.TrimSuffix(relPath, filepath.Ext(relPath)) + baseName = filepath.ToSlash(baseName) + baseRoute := "/api/" + baseName + + // Import path relative to the generated router. + importPath := "./api/" + filepath.ToSlash(relPath) + + // Scan file for API_ exports. + fileRoutes, err := scanFileExports(path, baseRoute, importPath) + if err != nil { + return fmt.Errorf("scan %s: %w", relPath, err) + } + routes = append(routes, fileRoutes...) + + return nil + }) + + return routes, err +} + +// scanFileExports reads a TypeScript file and extracts API_ prefixed exports. +func scanFileExports(filePath, baseRoute, importPath string) ([]RouteEntry, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("open file: %w", err) + } + defer file.Close() + + var routes []RouteEntry + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + matches := apiExportPattern.FindStringSubmatch(line) + if len(matches) < 2 { + continue + } + + funcName := matches[1] // e.g., "API_get_current" + entry, ok := parseAPIFunctionName(funcName, baseRoute, importPath) + if ok { + routes = append(routes, entry) + } + } + + return routes, scanner.Err() +} + +// parseAPIFunctionName parses "API_get_current" into a RouteEntry. +// Convention: API_{method} → handles base route; API_{method}_{path} → handles sub-route. +func parseAPIFunctionName(funcName, baseRoute, importPath string) (RouteEntry, bool) { + // Strip "API_" prefix. + rest := strings.TrimPrefix(funcName, "API_") + if rest == "" { + return RouteEntry{}, false + } + + // Split on first "_" to extract method. + parts := strings.SplitN(rest, "_", 2) + method := strings.ToLower(parts[0]) + if !validMethods[method] { + return RouteEntry{}, false + } + + path := baseRoute + if len(parts) == 2 && parts[1] != "" { + path = baseRoute + "/" + parts[1] + } + + return RouteEntry{ + Method: strings.ToUpper(method), + Path: path, + ImportPath: importPath, + FunctionName: funcName, + }, true +} + +// routerTemplate is the Deno router entrypoint template. +var routerTemplate = template.Must(template.New("router").Parse(`// Auto-generated by Docker Watcher — do not edit manually. +import { serveDir } from "https://deno.land/std/http/file_server.ts"; + +{{- range .Imports}} +import { {{.FunctionName}} as {{.Alias}} } from "{{.Path}}"; +{{- end}} + +const routes: Array<{ method: string; path: string; handler: (req: Request) => Promise | Response }> = [ +{{- range .Routes}} + { method: "{{.Method}}", path: "{{.Path}}", handler: {{.Alias}} }, +{{- end}} +]; + +Deno.serve({ port: 8000 }, async (req: Request): Promise => { + const url = new URL(req.url); + const method = req.method.toUpperCase(); + + // Match API routes. + for (const route of routes) { + if (route.method === method && url.pathname === route.path) { + try { + return await route.handler(req); + } catch (e) { + console.error("Handler error:", e); + return new Response(JSON.stringify({ error: "Internal server error" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + } + } + + // Serve static files from /public. + return serveDir(req, { fsRoot: "/app/public", quiet: true }); +}); +`)) + +// ImportEntry represents a single aliased import. +type ImportEntry struct { + FunctionName string // original name, e.g., "API_get" + Alias string // unique alias, e.g., "time_API_get" + Path string // import path, e.g., "./api/time.ts" +} + +// routerData holds the data for the router template. +type routerData struct { + Imports []ImportEntry + Routes []routeWithAlias +} + +// routeWithAlias is a route entry using the aliased handler name. +type routeWithAlias struct { + Method string + Path string + Alias string +} + +// GenerateRouter generates the Deno router TypeScript source from route entries. +func GenerateRouter(routes []RouteEntry) (string, error) { + var imports []ImportEntry + var aliasedRoutes []routeWithAlias + + for _, r := range routes { + // Derive a unique alias from the file base name + function name. + // e.g., "./api/echo.ts" + "API_get" → "echo_API_get" + baseName := filepath.Base(r.ImportPath) + baseName = strings.TrimSuffix(baseName, filepath.Ext(baseName)) + alias := baseName + "_" + r.FunctionName + + imports = append(imports, ImportEntry{ + FunctionName: r.FunctionName, + Alias: alias, + Path: r.ImportPath, + }) + + aliasedRoutes = append(aliasedRoutes, routeWithAlias{ + Method: r.Method, + Path: r.Path, + Alias: alias, + }) + } + + data := routerData{Imports: imports, Routes: aliasedRoutes} + + var buf strings.Builder + if err := routerTemplate.Execute(&buf, data); err != nil { + return "", fmt.Errorf("execute router template: %w", err) + } + + return buf.String(), nil +} + +// GenerateDockerfile generates the Dockerfile for the Deno container. +func GenerateDockerfile() string { + return `FROM denoland/deno:latest + +WORKDIR /app + +# Copy static files. +COPY public/ /app/public/ + +# Copy API source files and generated router. +COPY api/ /app/api/ +COPY router.ts /app/router.ts + +# Cache dependencies. +RUN deno install --entrypoint router.ts + +EXPOSE 8000 + +CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-env", "router.ts"] +` +} + +// GenerateStaticDockerfile generates a minimal nginx Dockerfile for pure static sites. +func GenerateStaticDockerfile() string { + return `FROM nginx:alpine + +COPY . /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] +` +} diff --git a/internal/staticsite/gitea_content.go b/internal/staticsite/gitea_content.go new file mode 100644 index 0000000..df1b1b2 --- /dev/null +++ b/internal/staticsite/gitea_content.go @@ -0,0 +1,360 @@ +package staticsite + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// giteaTreeEntry represents a single entry in a Gitea git tree response. +type giteaTreeEntry struct { + Path string `json:"path"` + Type string `json:"type"` // "blob" or "tree" + SHA string `json:"sha"` + Size int64 `json:"size"` +} + +// giteaTreeResponse represents the Gitea git tree API response. +type giteaTreeResponse struct { + SHA string `json:"sha"` + Entries []giteaTreeEntry `json:"tree"` + Truncated bool `json:"truncated"` +} + +// giteaBranch represents a branch from the Gitea API. +type giteaBranch struct { + Name string `json:"name"` + Commit struct { + ID string `json:"id"` + } `json:"commit"` +} + +// giteaRef represents a git reference from the Gitea API. +type giteaRef struct { + Ref string `json:"ref"` + Object struct { + SHA string `json:"sha"` + } `json:"object"` +} + +// GiteaContentFetcher downloads folder contents from a Gitea repository. +type GiteaContentFetcher struct { + baseURL string + token string + httpClient *http.Client +} + +// NewGiteaContentFetcher creates a new content fetcher. +// token may be empty for public repositories. +func NewGiteaContentFetcher(baseURL, token string) *GiteaContentFetcher { + return &GiteaContentFetcher{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + httpClient: &http.Client{ + Timeout: 60 * time.Second, + }, + } +} + +// Name returns the provider identifier. +func (f *GiteaContentFetcher) Name() string { return "gitea" } + +// ListRepos returns repositories accessible with the current token. +func (f *GiteaContentFetcher) ListRepos(ctx context.Context, query string) ([]RepoInfo, error) { + var allRepos []RepoInfo + page := 1 + limit := 50 + + for { + url := fmt.Sprintf("%s/api/v1/repos/search?limit=%d&page=%d", f.baseURL, limit, page) + if query != "" { + url += "&q=" + query + } + if f.token != "" { + // When authenticated, include private repos. + url += "&private=true" + } + + body, err := f.doGet(ctx, url) + if err != nil { + return nil, fmt.Errorf("list repos: %w", err) + } + + var result struct { + Data []struct { + Owner struct { + Login string `json:"login"` + } `json:"owner"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + Private bool `json:"private"` + HTMLURL string `json:"html_url"` + } `json:"data"` + } + if err := json.Unmarshal(body, &result); err != nil { + // Gitea search wraps in {"data": [...]}, but some versions return a flat array. + var flat []struct { + Owner struct { + Login string `json:"login"` + } `json:"owner"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + Private bool `json:"private"` + HTMLURL string `json:"html_url"` + } + if err2 := json.Unmarshal(body, &flat); err2 != nil { + return nil, fmt.Errorf("decode repos: %w", err) + } + for _, r := range flat { + allRepos = append(allRepos, RepoInfo{ + Owner: r.Owner.Login, + Name: r.Name, + FullName: r.FullName, + Description: r.Description, + Private: r.Private, + HTMLURL: r.HTMLURL, + }) + } + if len(flat) < limit { + break + } + page++ + continue + } + + for _, r := range result.Data { + allRepos = append(allRepos, RepoInfo{ + Owner: r.Owner.Login, + Name: r.Name, + FullName: r.FullName, + Description: r.Description, + Private: r.Private, + HTMLURL: r.HTMLURL, + }) + } + + if len(result.Data) < limit { + break + } + page++ + } + + return allRepos, nil +} + +// ListBranches returns all branches for a repository. +func (f *GiteaContentFetcher) ListBranches(ctx context.Context, owner, repo string) ([]string, error) { + var allBranches []string + page := 1 + limit := 50 + + for { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/branches?page=%d&limit=%d", + f.baseURL, owner, repo, page, limit) + + body, err := f.doGet(ctx, url) + if err != nil { + return nil, fmt.Errorf("list branches: %w", err) + } + + var branches []giteaBranch + if err := json.Unmarshal(body, &branches); err != nil { + return nil, fmt.Errorf("decode branches: %w", err) + } + + for _, b := range branches { + allBranches = append(allBranches, b.Name) + } + + if len(branches) < limit { + break + } + page++ + } + + return allBranches, nil +} + +// GetLatestCommitSHA returns the latest commit SHA for a branch. +func (f *GiteaContentFetcher) GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error) { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/branches/%s", + f.baseURL, owner, repo, branch) + + body, err := f.doGet(ctx, url) + if err != nil { + return "", fmt.Errorf("get branch info: %w", err) + } + + var b giteaBranch + if err := json.Unmarshal(body, &b); err != nil { + return "", fmt.Errorf("decode branch: %w", err) + } + + return b.Commit.ID, nil +} + +// FolderEntry represents a file or directory in the repo tree. +type FolderEntry struct { + Path string `json:"path"` + IsDir bool `json:"is_dir"` +} + +// ListTree returns the full directory tree for a branch, useful for the folder picker. +func (f *GiteaContentFetcher) ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error) { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/git/trees/%s?recursive=true", + f.baseURL, owner, repo, branch) + + body, err := f.doGet(ctx, url) + if err != nil { + return nil, fmt.Errorf("list tree: %w", err) + } + + var tree giteaTreeResponse + if err := json.Unmarshal(body, &tree); err != nil { + return nil, fmt.Errorf("decode tree: %w", err) + } + + entries := make([]FolderEntry, 0, len(tree.Entries)) + for _, e := range tree.Entries { + entries = append(entries, FolderEntry{ + Path: e.Path, + IsDir: e.Type == "tree", + }) + } + + return entries, nil +} + +// DownloadFolder downloads all files from a specific folder path in the repo +// to a local temporary directory. Returns the path to the temp directory. +func (f *GiteaContentFetcher) DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error { + // Get the full tree. + url := fmt.Sprintf("%s/api/v1/repos/%s/%s/git/trees/%s?recursive=true", + f.baseURL, owner, repo, branch) + + body, err := f.doGet(ctx, url) + if err != nil { + return fmt.Errorf("fetch tree: %w", err) + } + + var tree giteaTreeResponse + if err := json.Unmarshal(body, &tree); err != nil { + return fmt.Errorf("decode tree: %w", err) + } + + // Normalize folder path. + folderPath = strings.TrimPrefix(folderPath, "/") + folderPath = strings.TrimSuffix(folderPath, "/") + prefix := folderPath + "/" + + // Download each file in the folder. + for _, entry := range tree.Entries { + if entry.Type != "blob" { + continue + } + + if !strings.HasPrefix(entry.Path, prefix) { + continue + } + + relativePath := strings.TrimPrefix(entry.Path, prefix) + localPath := filepath.Join(destDir, filepath.FromSlash(relativePath)) + + // Create parent directories. + if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil { + return fmt.Errorf("create directory for %s: %w", relativePath, err) + } + + // Download the file. + fileURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s", + f.baseURL, owner, repo, entry.Path, branch) + + if err := f.downloadFile(ctx, fileURL, localPath); err != nil { + return fmt.Errorf("download %s: %w", relativePath, err) + } + } + + return nil +} + +// TestConnection verifies that the repository is accessible. +func (f *GiteaContentFetcher) TestConnection(ctx context.Context, owner, repo string) error { + url := fmt.Sprintf("%s/api/v1/repos/%s/%s", f.baseURL, owner, repo) + _, err := f.doGet(ctx, url) + if err != nil { + return fmt.Errorf("test connection: %w", err) + } + return nil +} + +// doGet performs an authenticated GET request and returns the response body. +func (f *GiteaContentFetcher) doGet(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + if f.token != "" { + req.Header.Set("Authorization", "token "+f.token) + } + req.Header.Set("Accept", "application/json") + + resp, err := f.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + return body, nil +} + +// downloadFile downloads a URL to a local file path. +func (f *GiteaContentFetcher) downloadFile(ctx context.Context, url, localPath string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + + if f.token != "" { + req.Header.Set("Authorization", "token "+f.token) + } + + resp, err := f.httpClient.Do(req) + if err != nil { + return fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %d for %s", resp.StatusCode, url) + } + + file, err := os.Create(localPath) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer file.Close() + + if _, err := io.Copy(file, resp.Body); err != nil { + return fmt.Errorf("write file: %w", err) + } + + return nil +} diff --git a/internal/staticsite/github_provider.go b/internal/staticsite/github_provider.go new file mode 100644 index 0000000..7916dc0 --- /dev/null +++ b/internal/staticsite/github_provider.go @@ -0,0 +1,276 @@ +package staticsite + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "path/filepath" + "strings" + "time" +) + +// GitHubProvider implements GitProvider for GitHub repositories. +type GitHubProvider struct { + apiBase string // "https://api.github.com" for github.com + token string + httpClient *http.Client +} + +// NewGitHubProvider creates a new GitHub provider. +// baseURL should be "https://github.com" or a GitHub Enterprise URL. +func NewGitHubProvider(baseURL, token string) *GitHubProvider { + apiBase := "https://api.github.com" + base := strings.TrimRight(baseURL, "/") + if base != "https://github.com" && base != "http://github.com" { + // GitHub Enterprise: API is at {base}/api/v3 + apiBase = base + "/api/v3" + } + + return &GitHubProvider{ + apiBase: apiBase, + token: token, + httpClient: &http.Client{ + Timeout: 60 * time.Second, + }, + } +} + +func (g *GitHubProvider) Name() string { return "github" } + +func (g *GitHubProvider) ListRepos(ctx context.Context, query string) ([]RepoInfo, error) { + var allRepos []RepoInfo + + if query != "" { + // Use search API. + url := fmt.Sprintf("%s/search/repositories?q=%s&per_page=50", g.apiBase, query) + body, err := g.doGet(ctx, url) + if err != nil { + return nil, fmt.Errorf("search repos: %w", err) + } + + var result struct { + Items []struct { + Owner struct { + Login string `json:"login"` + } `json:"owner"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + Private bool `json:"private"` + HTMLURL string `json:"html_url"` + } `json:"items"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("decode search: %w", err) + } + for _, r := range result.Items { + allRepos = append(allRepos, RepoInfo{ + Owner: r.Owner.Login, Name: r.Name, FullName: r.FullName, + Description: r.Description, Private: r.Private, HTMLURL: r.HTMLURL, + }) + } + return allRepos, nil + } + + // List authenticated user's repos. + page := 1 + for { + url := fmt.Sprintf("%s/user/repos?per_page=100&page=%d&sort=updated", g.apiBase, page) + body, err := g.doGet(ctx, url) + if err != nil { + return nil, fmt.Errorf("list repos: %w", err) + } + + var repos []struct { + Owner struct { + Login string `json:"login"` + } `json:"owner"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + Private bool `json:"private"` + HTMLURL string `json:"html_url"` + } + if err := json.Unmarshal(body, &repos); err != nil { + return nil, fmt.Errorf("decode repos: %w", err) + } + for _, r := range repos { + allRepos = append(allRepos, RepoInfo{ + Owner: r.Owner.Login, Name: r.Name, FullName: r.FullName, + Description: r.Description, Private: r.Private, HTMLURL: r.HTMLURL, + }) + } + if len(repos) < 100 { + break + } + page++ + } + return allRepos, nil +} + +func (g *GitHubProvider) TestConnection(ctx context.Context, owner, repo string) error { + url := fmt.Sprintf("%s/repos/%s/%s", g.apiBase, owner, repo) + _, err := g.doGet(ctx, url) + return err +} + +func (g *GitHubProvider) ListBranches(ctx context.Context, owner, repo string) ([]string, error) { + var allBranches []string + page := 1 + + for { + url := fmt.Sprintf("%s/repos/%s/%s/branches?per_page=100&page=%d", + g.apiBase, owner, repo, page) + + body, err := g.doGet(ctx, url) + if err != nil { + return nil, fmt.Errorf("list branches: %w", err) + } + + var branches []struct { + Name string `json:"name"` + } + if err := json.Unmarshal(body, &branches); err != nil { + return nil, fmt.Errorf("decode branches: %w", err) + } + + for _, b := range branches { + allBranches = append(allBranches, b.Name) + } + + if len(branches) < 100 { + break + } + page++ + } + + return allBranches, nil +} + +func (g *GitHubProvider) GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error) { + url := fmt.Sprintf("%s/repos/%s/%s/branches/%s", g.apiBase, owner, repo, branch) + + body, err := g.doGet(ctx, url) + if err != nil { + return "", fmt.Errorf("get branch: %w", err) + } + + var result struct { + Commit struct { + SHA string `json:"sha"` + } `json:"commit"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("decode branch: %w", err) + } + + return result.Commit.SHA, nil +} + +func (g *GitHubProvider) ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error) { + url := fmt.Sprintf("%s/repos/%s/%s/git/trees/%s?recursive=1", + g.apiBase, owner, repo, branch) + + body, err := g.doGet(ctx, url) + if err != nil { + return nil, fmt.Errorf("list tree: %w", err) + } + + var tree struct { + Tree []struct { + Path string `json:"path"` + Type string `json:"type"` // "blob" or "tree" + } `json:"tree"` + } + if err := json.Unmarshal(body, &tree); err != nil { + return nil, fmt.Errorf("decode tree: %w", err) + } + + entries := make([]FolderEntry, 0, len(tree.Tree)) + for _, e := range tree.Tree { + entries = append(entries, FolderEntry{ + Path: e.Path, + IsDir: e.Type == "tree", + }) + } + + return entries, nil +} + +func (g *GitHubProvider) DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error { + // Get tree to find files in folder. + entries, err := g.ListTree(ctx, owner, repo, branch) + if err != nil { + return fmt.Errorf("list tree: %w", err) + } + + folderPath = strings.TrimPrefix(folderPath, "/") + folderPath = strings.TrimSuffix(folderPath, "/") + prefix := folderPath + "/" + + for _, entry := range entries { + if entry.IsDir { + continue + } + if !strings.HasPrefix(entry.Path, prefix) { + continue + } + + relativePath := strings.TrimPrefix(entry.Path, prefix) + localPath := filepath.Join(destDir, filepath.FromSlash(relativePath)) + + // GitHub raw content URL. + // For github.com: https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path} + // For GHE: {baseURL}/{owner}/{repo}/raw/{branch}/{path} + var fileURL string + if g.apiBase == "https://api.github.com" { + fileURL = fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/%s/%s", + owner, repo, branch, entry.Path) + } else { + // GHE: use API contents endpoint. + fileURL = fmt.Sprintf("%s/repos/%s/%s/contents/%s?ref=%s", + g.apiBase, owner, repo, entry.Path, branch) + } + + if err := downloadFileHTTP(ctx, g.httpClient, fileURL, localPath, g.setAuth); err != nil { + return fmt.Errorf("download %s: %w", relativePath, err) + } + } + + return nil +} + +func (g *GitHubProvider) doGet(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + g.setAuth(req) + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := g.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + return body, nil +} + +func (g *GitHubProvider) setAuth(req *http.Request) { + if g.token != "" { + req.Header.Set("Authorization", "Bearer "+g.token) + } +} diff --git a/internal/staticsite/gitlab_provider.go b/internal/staticsite/gitlab_provider.go new file mode 100644 index 0000000..8799fc8 --- /dev/null +++ b/internal/staticsite/gitlab_provider.go @@ -0,0 +1,254 @@ +package staticsite + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path/filepath" + "strings" + "time" +) + +// GitLabProvider implements GitProvider for GitLab repositories. +type GitLabProvider struct { + apiBase string // e.g., "https://gitlab.com/api/v4" + rawBase string // e.g., "https://gitlab.com" + token string + httpClient *http.Client +} + +// NewGitLabProvider creates a new GitLab provider. +// baseURL should be "https://gitlab.com" or a self-hosted GitLab URL. +func NewGitLabProvider(baseURL, token string) *GitLabProvider { + base := strings.TrimRight(baseURL, "/") + return &GitLabProvider{ + apiBase: base + "/api/v4", + rawBase: base, + token: token, + httpClient: &http.Client{ + Timeout: 60 * time.Second, + }, + } +} + +func (g *GitLabProvider) Name() string { return "gitlab" } + +// projectPath returns the URL-encoded project path (owner/repo → owner%2Frepo). +func projectPath(owner, repo string) string { + return url.PathEscape(owner + "/" + repo) +} + +func (g *GitLabProvider) ListRepos(ctx context.Context, query string) ([]RepoInfo, error) { + var allRepos []RepoInfo + page := 1 + + for { + apiURL := fmt.Sprintf("%s/projects?membership=true&per_page=100&page=%d&order_by=last_activity_at", g.apiBase, page) + if query != "" { + apiURL += "&search=" + url.QueryEscape(query) + } + + body, err := g.doGet(ctx, apiURL) + if err != nil { + return nil, fmt.Errorf("list repos: %w", err) + } + + var projects []struct { + PathWithNamespace string `json:"path_with_namespace"` + Name string `json:"name"` + Description string `json:"description"` + Visibility string `json:"visibility"` + WebURL string `json:"web_url"` + Namespace struct { + Path string `json:"path"` + } `json:"namespace"` + } + if err := json.Unmarshal(body, &projects); err != nil { + return nil, fmt.Errorf("decode repos: %w", err) + } + + for _, p := range projects { + allRepos = append(allRepos, RepoInfo{ + Owner: p.Namespace.Path, + Name: p.Name, + FullName: p.PathWithNamespace, + Description: p.Description, + Private: p.Visibility != "public", + HTMLURL: p.WebURL, + }) + } + + if len(projects) < 100 { + break + } + page++ + } + + return allRepos, nil +} + +func (g *GitLabProvider) TestConnection(ctx context.Context, owner, repo string) error { + apiURL := fmt.Sprintf("%s/projects/%s", g.apiBase, projectPath(owner, repo)) + _, err := g.doGet(ctx, apiURL) + return err +} + +func (g *GitLabProvider) ListBranches(ctx context.Context, owner, repo string) ([]string, error) { + var allBranches []string + page := 1 + + for { + apiURL := fmt.Sprintf("%s/projects/%s/repository/branches?per_page=100&page=%d", + g.apiBase, projectPath(owner, repo), page) + + body, err := g.doGet(ctx, apiURL) + if err != nil { + return nil, fmt.Errorf("list branches: %w", err) + } + + var branches []struct { + Name string `json:"name"` + } + if err := json.Unmarshal(body, &branches); err != nil { + return nil, fmt.Errorf("decode branches: %w", err) + } + + for _, b := range branches { + allBranches = append(allBranches, b.Name) + } + + if len(branches) < 100 { + break + } + page++ + } + + return allBranches, nil +} + +func (g *GitLabProvider) GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error) { + apiURL := fmt.Sprintf("%s/projects/%s/repository/branches/%s", + g.apiBase, projectPath(owner, repo), url.PathEscape(branch)) + + body, err := g.doGet(ctx, apiURL) + if err != nil { + return "", fmt.Errorf("get branch: %w", err) + } + + var result struct { + Commit struct { + ID string `json:"id"` + } `json:"commit"` + } + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("decode branch: %w", err) + } + + return result.Commit.ID, nil +} + +func (g *GitLabProvider) ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error) { + var allEntries []FolderEntry + page := 1 + + for { + apiURL := fmt.Sprintf("%s/projects/%s/repository/tree?ref=%s&recursive=true&per_page=100&page=%d", + g.apiBase, projectPath(owner, repo), url.QueryEscape(branch), page) + + body, err := g.doGet(ctx, apiURL) + if err != nil { + return nil, fmt.Errorf("list tree: %w", err) + } + + var entries []struct { + Path string `json:"path"` + Type string `json:"type"` // "blob" or "tree" + } + if err := json.Unmarshal(body, &entries); err != nil { + return nil, fmt.Errorf("decode tree: %w", err) + } + + for _, e := range entries { + allEntries = append(allEntries, FolderEntry{ + Path: e.Path, + IsDir: e.Type == "tree", + }) + } + + if len(entries) < 100 { + break + } + page++ + } + + return allEntries, nil +} + +func (g *GitLabProvider) DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error { + entries, err := g.ListTree(ctx, owner, repo, branch) + if err != nil { + return fmt.Errorf("list tree: %w", err) + } + + folderPath = strings.TrimPrefix(folderPath, "/") + folderPath = strings.TrimSuffix(folderPath, "/") + prefix := folderPath + "/" + + for _, entry := range entries { + if entry.IsDir { + continue + } + if !strings.HasPrefix(entry.Path, prefix) { + continue + } + + relativePath := strings.TrimPrefix(entry.Path, prefix) + localPath := filepath.Join(destDir, filepath.FromSlash(relativePath)) + + // GitLab raw file URL: {base}/{owner}/{repo}/-/raw/{branch}/{path} + fileURL := fmt.Sprintf("%s/%s/%s/-/raw/%s/%s", + g.rawBase, owner, repo, branch, entry.Path) + + if err := downloadFileHTTP(ctx, g.httpClient, fileURL, localPath, g.setAuth); err != nil { + return fmt.Errorf("download %s: %w", relativePath, err) + } + } + + return nil +} + +func (g *GitLabProvider) doGet(ctx context.Context, apiURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + g.setAuth(req) + req.Header.Set("Accept", "application/json") + + resp, err := g.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + return body, nil +} + +func (g *GitLabProvider) setAuth(req *http.Request) { + if g.token != "" { + req.Header.Set("PRIVATE-TOKEN", g.token) + } +} diff --git a/internal/staticsite/healthcheck.go b/internal/staticsite/healthcheck.go new file mode 100644 index 0000000..30caa2e --- /dev/null +++ b/internal/staticsite/healthcheck.go @@ -0,0 +1,111 @@ +package staticsite + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/alexei/docker-watcher/internal/docker" + "github.com/alexei/docker-watcher/internal/store" + "github.com/robfig/cron/v3" +) + +// HealthChecker periodically checks that deployed static site containers +// are still running. If a container has crashed, it updates the site status +// to "failed" and optionally triggers a redeploy. +type HealthChecker struct { + store *store.Store + docker *docker.Client + manager *Manager + + cron *cron.Cron + mu sync.Mutex + entryID cron.EntryID + running bool +} + +// NewHealthChecker creates a new static site health checker. +func NewHealthChecker(st *store.Store, dockerClient *docker.Client, mgr *Manager) *HealthChecker { + return &HealthChecker{ + store: st, + docker: dockerClient, + manager: mgr, + cron: cron.New(), + } +} + +// Start begins the periodic health check with the given interval (e.g., "5m", "1m"). +func (h *HealthChecker) Start(interval string) error { + h.mu.Lock() + defer h.mu.Unlock() + + duration, err := time.ParseDuration(interval) + if err != nil { + return fmt.Errorf("parse interval %q: %w", interval, err) + } + + if h.running { + h.cron.Remove(h.entryID) + } + + spec := fmt.Sprintf("@every %s", duration) + id, err := h.cron.AddFunc(spec, h.check) + if err != nil { + return fmt.Errorf("schedule health check: %w", err) + } + + h.entryID = id + h.running = true + h.cron.Start() + + slog.Info("static site health checker started", "interval", interval) + return nil +} + +// Stop stops the periodic health checker. +func (h *HealthChecker) Stop() { + h.mu.Lock() + defer h.mu.Unlock() + + if h.running { + h.cron.Stop() + h.running = false + slog.Info("static site health checker stopped") + } +} + +// check runs a single health check pass over all deployed static sites. +func (h *HealthChecker) check() { + sites, err := h.store.GetAllStaticSites() + if err != nil { + slog.Error("static site health check: failed to list sites", "error", err) + return + } + + ctx := context.Background() + + for _, site := range sites { + if site.Status != "deployed" || site.ContainerID == "" { + continue + } + + running, err := h.docker.IsContainerRunning(ctx, site.ContainerID) + if err != nil { + // Container might have been removed externally. + slog.Warn("static site health check: container inspect failed", + "site", site.Name, "container", site.ContainerID[:12], "error", err) + h.manager.updateStatus(site.ID, "failed", site.LastCommitSHA, "container not found") + h.manager.publishEvent(site.ID, site.Name, "failed: container not found") + continue + } + + if !running { + slog.Warn("static site health check: container not running", + "site", site.Name, "container", site.ContainerID[:12]) + h.manager.updateStatus(site.ID, "failed", site.LastCommitSHA, "container stopped unexpectedly") + h.manager.publishEvent(site.ID, site.Name, "failed: container stopped unexpectedly") + } + } +} diff --git a/internal/staticsite/manager.go b/internal/staticsite/manager.go new file mode 100644 index 0000000..ed7e957 --- /dev/null +++ b/internal/staticsite/manager.go @@ -0,0 +1,691 @@ +package staticsite + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/alexei/docker-watcher/internal/crypto" + "github.com/alexei/docker-watcher/internal/docker" + "github.com/alexei/docker-watcher/internal/events" + "github.com/alexei/docker-watcher/internal/proxy" + "github.com/alexei/docker-watcher/internal/staticsite/deno" + "github.com/alexei/docker-watcher/internal/store" +) + +// Manager orchestrates the static site deployment pipeline. +type Manager struct { + store *store.Store + docker *docker.Client + proxyProvider proxy.Provider + eventBus *events.Bus + encKey [32]byte +} + +// NewManager creates a new static site manager. +func NewManager( + st *store.Store, + dockerClient *docker.Client, + proxyProvider proxy.Provider, + eventBus *events.Bus, + encKey [32]byte, +) *Manager { + return &Manager{ + store: st, + docker: dockerClient, + proxyProvider: proxyProvider, + eventBus: eventBus, + encKey: encKey, + } +} + +// SetProxyProvider updates the proxy provider at runtime. +func (m *Manager) SetProxyProvider(provider proxy.Provider) { + m.proxyProvider = provider +} + +// Deploy fetches content from Gitea and deploys a static site container. +// If force is true, skips the "no changes" check and always rebuilds/redeploys. +func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error { + site, err := m.store.GetStaticSiteByID(siteID) + if err != nil { + return fmt.Errorf("get site: %w", err) + } + + // Decrypt access token if present. + token := "" + if site.AccessToken != "" { + decrypted, err := crypto.Decrypt(m.encKey, site.AccessToken) + if err != nil { + slog.Warn("static site: failed to decrypt access token", "site", site.Name, "error", err) + } else { + token = decrypted + } + } + + provider, err := NewGitProvider(ProviderType(site.Provider), site.GiteaURL, token) + if err != nil { + m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("create provider: %v", err)) + return fmt.Errorf("create provider: %w", err) + } + + // Check if there's a new commit. + latestSHA, err := provider.GetLatestCommitSHA(ctx, site.RepoOwner, site.RepoName, site.Branch) + if err != nil { + m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("fetch commit SHA: %v", err)) + return fmt.Errorf("get latest commit: %w", err) + } + + // Skip redeploy only if SHA matches, status is deployed, container is running, + // proxy route exists, AND force is false. Manual deploys always force a full rebuild. + if !force && latestSHA == site.LastCommitSHA && site.Status == "deployed" && site.ContainerID != "" { + running, _ := m.docker.IsContainerRunning(ctx, site.ContainerID) + if !running { + slog.Info("static site: container not running, forcing redeploy", "site", site.Name) + } else if site.Domain != "" { + // Also verify the proxy route still exists (it may have been deleted externally). + proxyOK, err := m.proxyProvider.RouteExists(ctx, site.Domain) + if err != nil { + slog.Warn("static site: proxy check failed, forcing redeploy", "site", site.Name, "error", err) + } else if !proxyOK { + slog.Info("static site: proxy route missing, forcing redeploy", "site", site.Name) + } else { + slog.Info("static site: no changes", "site", site.Name, "sha", latestSHA) + return nil + } + } else { + slog.Info("static site: no changes", "site", site.Name, "sha", latestSHA) + return nil + } + } + + // Update status to syncing. + m.updateStatus(site.ID, "syncing", site.LastCommitSHA, "") + m.publishEvent(site.ID, site.Name, "syncing") + + // Create temp directory for the build context. + buildDir, err := os.MkdirTemp("", "dw-site-"+site.Name+"-*") + if err != nil { + m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("create temp dir: %v", err)) + return fmt.Errorf("create temp dir: %w", err) + } + defer os.RemoveAll(buildDir) + + // Download folder contents. + if err := provider.DownloadFolder(ctx, site.RepoOwner, site.RepoName, site.Branch, site.FolderPath, buildDir); err != nil { + m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("download folder: %v", err)) + return fmt.Errorf("download folder: %w", err) + } + + // Render markdown if enabled. + if site.RenderMarkdown { + if err := RenderMarkdownFiles(buildDir); err != nil { + slog.Warn("static site: markdown rendering failed", "site", site.Name, "error", err) + } + } + + // Determine mode: check for api/ subdirectory. + mode := site.Mode + apiDir := filepath.Join(buildDir, "api") + hasAPI := false + if info, err := os.Stat(apiDir); err == nil && info.IsDir() { + hasAPI = true + } + if mode == "deno" && !hasAPI { + // Fallback to static if no api/ folder found. + mode = "static" + slog.Info("static site: no api/ folder found, falling back to static mode", "site", site.Name) + } + + // Prepare build context based on mode. + imageTag := fmt.Sprintf("dw-site-%s:latest", site.Name) + contextDir, err := os.MkdirTemp("", "dw-site-build-*") + if err != nil { + m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("create build context: %v", err)) + return fmt.Errorf("create build context dir: %w", err) + } + defer os.RemoveAll(contextDir) + + if mode == "deno" { + if err := m.prepareDenoBuild(buildDir, contextDir); err != nil { + m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("prepare deno build: %v", err)) + return fmt.Errorf("prepare deno build: %w", err) + } + } else { + if err := m.prepareStaticBuild(buildDir, contextDir); err != nil { + m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("prepare static build: %v", err)) + return fmt.Errorf("prepare static build: %w", err) + } + } + + // Build Docker image. + if err := m.docker.BuildImage(ctx, contextDir, imageTag); err != nil { + m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("build image: %v", err)) + return fmt.Errorf("build image: %w", err) + } + + // Prepare environment variables (secrets). + env, err := m.buildEnvVars(site.ID) + if err != nil { + m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("build env vars: %v", err)) + return fmt.Errorf("build env vars: %w", err) + } + + // Determine container port. + containerPort := "80" + if mode == "deno" { + containerPort = "8000" + } + + // Get network settings. + settings, err := m.store.GetSettings() + if err != nil { + m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("get settings: %v", err)) + return fmt.Errorf("get settings: %w", err) + } + + networkName := settings.Network + networkID, err := m.docker.EnsureNetwork(ctx, networkName) + if err != nil { + m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("ensure network: %v", err)) + return fmt.Errorf("ensure network: %w", err) + } + + containerName := fmt.Sprintf("dw-site-%s", site.Name) + + // Create and start new container. + containerID, err := m.docker.CreateContainer(ctx, docker.ContainerConfig{ + Name: containerName, + Image: imageTag, + Env: env, + ExposedPorts: []string{containerPort + "/tcp"}, + NetworkName: networkName, + NetworkID: networkID, + Labels: map[string]string{ + "docker-watcher.static-site": site.ID, + "docker-watcher.static-site-name": site.Name, + }, + Project: "static-site", + Stage: site.Name, + }) + if err != nil { + // Container might already exist — try to remove and recreate. + if site.ContainerID != "" { + m.docker.StopContainer(ctx, site.ContainerID, 10) + m.docker.RemoveContainer(ctx, site.ContainerID, true) + } + // Also try by name. + m.removeContainerByName(ctx, containerName) + + containerID, err = m.docker.CreateContainer(ctx, docker.ContainerConfig{ + Name: containerName, + Image: imageTag, + Env: env, + ExposedPorts: []string{containerPort + "/tcp"}, + NetworkName: networkName, + NetworkID: networkID, + Labels: map[string]string{ + "docker-watcher.static-site": site.ID, + "docker-watcher.static-site-name": site.Name, + }, + Project: "static-site", + Stage: site.Name, + }) + if err != nil { + m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("create container: %v", err)) + return fmt.Errorf("create container: %w", err) + } + } + + if err := m.docker.StartContainer(ctx, containerID); err != nil { + m.docker.RemoveContainer(ctx, containerID, true) + m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("start container: %v", err)) + return fmt.Errorf("start container: %w", err) + } + + // Brief health check: wait 3 seconds and verify container is still running. + time.Sleep(3 * time.Second) + running, err := m.docker.IsContainerRunning(ctx, containerID) + if err != nil || !running { + // Grab container logs for the error message. + logMsg := "container exited immediately after start" + if logs, logErr := m.docker.ContainerLogs(ctx, containerID, false, "20"); logErr == nil { + buf, _ := io.ReadAll(logs) + logs.Close() + if len(buf) > 0 { + logMsg = string(buf) + // Truncate to reasonable length. + if len(logMsg) > 500 { + logMsg = logMsg[:500] + "..." + } + } + } + m.docker.RemoveContainer(ctx, containerID, true) + m.updateStatus(site.ID, "failed", latestSHA, logMsg) + return fmt.Errorf("container not running: %s", logMsg) + } + + // Determine proxy target: container name + internal port (default), + // or server IP + host port for NPM remote mode. + internalPort, _ := strconv.Atoi(containerPort) + forwardHost := containerName + forwardPort := internalPort + + if settings.NpmRemote && settings.ProxyProvider == "npm" { + if settings.ServerIP != "" { + hostPort, err := m.docker.InspectContainerPort(ctx, containerID, containerPort+"/tcp") + if err != nil { + slog.Warn("static site: could not get host port for remote NPM", "site", site.Name, "error", err) + } else { + forwardHost = settings.ServerIP + forwardPort = int(hostPort) + } + } + } + + // Configure proxy if domain is set. + proxyRouteID := site.ProxyRouteID + if site.Domain != "" { + // Remove old proxy route if exists. + if site.ProxyRouteID != "" { + m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID) + } + + routeID, err := m.proxyProvider.ConfigureRoute(ctx, site.Domain, forwardHost, forwardPort, proxy.RouteOptions{ + SSLCertificateID: settings.SSLCertificateID, + }) + if err != nil { + slog.Warn("static site: failed to configure proxy", "site", site.Name, "domain", site.Domain, "target", fmt.Sprintf("%s:%d", forwardHost, forwardPort), "error", err) + } else { + proxyRouteID = routeID + slog.Info("static site: proxy configured", "site", site.Name, "domain", site.Domain, "target", fmt.Sprintf("%s:%d", forwardHost, forwardPort), "routeID", routeID) + } + } + + // Remove old container if different. + if site.ContainerID != "" && site.ContainerID != containerID { + m.docker.StopContainer(ctx, site.ContainerID, 10) + m.docker.RemoveContainer(ctx, site.ContainerID, true) + } + + // Update site status. + if err := m.store.UpdateStaticSiteContainer(site.ID, containerID, proxyRouteID); err != nil { + slog.Error("static site: failed to update container info", "site", site.Name, "error", err) + } + m.updateStatus(site.ID, "deployed", latestSHA, "") + m.publishEvent(site.ID, site.Name, "deployed") + + slog.Info("static site deployed", "site", site.Name, "sha", latestSHA[:8], "mode", mode) + return nil +} + +// Remove stops and removes a static site's container and proxy route. +func (m *Manager) Remove(ctx context.Context, siteID string) error { + site, err := m.store.GetStaticSiteByID(siteID) + if err != nil { + return fmt.Errorf("get site: %w", err) + } + + // Remove proxy route (best effort). + if site.ProxyRouteID != "" { + if err := m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID); err != nil { + slog.Warn("static site: failed to remove proxy route", "site", site.Name, "error", err) + } + } + + // Stop and remove container (best effort). + if site.ContainerID != "" { + m.docker.StopContainer(ctx, site.ContainerID, 10) + if err := m.docker.RemoveContainer(ctx, site.ContainerID, true); err != nil { + slog.Warn("static site: failed to remove container", "site", site.Name, "error", err) + } + } + + return nil +} + +// Stop stops a running static site container and removes its proxy route. +// The container is kept (not removed) so Start can bring it back without a full rebuild. +func (m *Manager) Stop(ctx context.Context, siteID string) error { + site, err := m.store.GetStaticSiteByID(siteID) + if err != nil { + return fmt.Errorf("get site: %w", err) + } + + // Remove proxy route first (best effort). + if site.ProxyRouteID != "" { + if err := m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID); err != nil { + slog.Warn("static site: failed to remove proxy route", "site", site.Name, "error", err) + } + } + + // Stop container. + if site.ContainerID != "" { + if err := m.docker.StopContainer(ctx, site.ContainerID, 10); err != nil { + slog.Warn("static site: failed to stop container", "site", site.Name, "error", err) + } + } + + // Clear proxy route ID; keep container ID. + if err := m.store.UpdateStaticSiteContainer(site.ID, site.ContainerID, ""); err != nil { + slog.Error("static site: failed to clear proxy route", "site", site.Name, "error", err) + } + m.updateStatus(site.ID, "stopped", site.LastCommitSHA, "") + m.publishEvent(site.ID, site.Name, "stopped") + + slog.Info("static site stopped", "site", site.Name) + return nil +} + +// Start starts a previously stopped static site container and reconfigures the proxy. +// If the container no longer exists, it triggers a full redeploy. +func (m *Manager) Start(ctx context.Context, siteID string) error { + site, err := m.store.GetStaticSiteByID(siteID) + if err != nil { + return fmt.Errorf("get site: %w", err) + } + + // If no container exists, do a full deploy. + if site.ContainerID == "" { + return m.Deploy(ctx, siteID, true) + } + + // Try to start the existing container. + if err := m.docker.StartContainer(ctx, site.ContainerID); err != nil { + slog.Warn("static site: failed to start container, falling back to redeploy", "site", site.Name, "error", err) + return m.Deploy(ctx, siteID, true) + } + + // Verify it's running after a brief wait. + time.Sleep(2 * time.Second) + running, _ := m.docker.IsContainerRunning(ctx, site.ContainerID) + if !running { + return m.Deploy(ctx, siteID, true) + } + + // Reconfigure proxy if domain is set. + settings, err := m.store.GetSettings() + if err == nil && site.Domain != "" { + containerPort := "80" + if site.Mode == "deno" { + containerPort = "8000" + } + internalPort, _ := strconv.Atoi(containerPort) + containerName := fmt.Sprintf("dw-site-%s", site.Name) + forwardHost := containerName + forwardPort := internalPort + + if settings.NpmRemote && settings.ProxyProvider == "npm" && settings.ServerIP != "" { + if hp, err := m.docker.InspectContainerPort(ctx, site.ContainerID, containerPort+"/tcp"); err == nil { + forwardHost = settings.ServerIP + forwardPort = int(hp) + } + } + + routeID, err := m.proxyProvider.ConfigureRoute(ctx, site.Domain, forwardHost, forwardPort, proxy.RouteOptions{ + SSLCertificateID: settings.SSLCertificateID, + }) + if err != nil { + slog.Warn("static site: failed to reconfigure proxy on start", "site", site.Name, "error", err) + } else { + m.store.UpdateStaticSiteContainer(site.ID, site.ContainerID, routeID) + } + } + + m.updateStatus(site.ID, "deployed", site.LastCommitSHA, "") + m.publishEvent(site.ID, site.Name, "deployed") + + slog.Info("static site started", "site", site.Name) + return nil +} + +// TestConnection tests connectivity to a Git repository. +func (m *Manager) TestConnection(ctx context.Context, providerType, baseURL, accessToken, owner, repo string) error { + provider, err := m.createProvider(providerType, baseURL, accessToken) + if err != nil { + return err + } + return provider.TestConnection(ctx, owner, repo) +} + +// ListBranches returns branches for a Git repository. +func (m *Manager) ListBranches(ctx context.Context, providerType, baseURL, accessToken, owner, repo string) ([]string, error) { + provider, err := m.createProvider(providerType, baseURL, accessToken) + if err != nil { + return nil, err + } + return provider.ListBranches(ctx, owner, repo) +} + +// ListTree returns the repository tree for the folder picker. +func (m *Manager) ListTree(ctx context.Context, providerType, baseURL, accessToken, owner, repo, branch string) ([]FolderEntry, error) { + provider, err := m.createProvider(providerType, baseURL, accessToken) + if err != nil { + return nil, err + } + return provider.ListTree(ctx, owner, repo, branch) +} + +// ListRepos returns repositories from a Git server. +func (m *Manager) ListRepos(ctx context.Context, providerType, baseURL, accessToken, query string) ([]RepoInfo, error) { + provider, err := m.createProvider(providerType, baseURL, accessToken) + if err != nil { + return nil, err + } + return provider.ListRepos(ctx, query) +} + +// DetectProvider autodetects the Git provider from a URL, with API probing. +func (m *Manager) DetectProvider(ctx context.Context, baseURL string) string { + return string(DetectProviderWithProbe(ctx, baseURL)) +} + +// createProvider builds a GitProvider from encrypted credentials. +func (m *Manager) createProvider(providerType, baseURL, accessToken string) (GitProvider, error) { + token := "" + if accessToken != "" { + decrypted, err := crypto.Decrypt(m.encKey, accessToken) + if err != nil { + token = accessToken // might be plaintext + } else { + token = decrypted + } + } + return NewGitProvider(ProviderType(providerType), baseURL, token) +} + +// prepareDenoBuild sets up the build context for a Deno container. +func (m *Manager) prepareDenoBuild(srcDir, contextDir string) error { + // Move api/ to context. + apiSrc := filepath.Join(srcDir, "api") + apiDst := filepath.Join(contextDir, "api") + if err := os.Rename(apiSrc, apiDst); err != nil { + return fmt.Errorf("move api dir: %w", err) + } + + // Move remaining files to public/. + publicDir := filepath.Join(contextDir, "public") + if err := os.Rename(srcDir, publicDir); err != nil { + // If rename fails (cross-device), use copy. + if err := copyDir(srcDir, publicDir); err != nil { + return fmt.Errorf("copy public dir: %w", err) + } + } + + // Scan routes and generate router. + routes, err := deno.ScanRoutes(apiDst) + if err != nil { + return fmt.Errorf("scan routes: %w", err) + } + + routerSrc, err := deno.GenerateRouter(routes) + if err != nil { + return fmt.Errorf("generate router: %w", err) + } + + if err := os.WriteFile(filepath.Join(contextDir, "router.ts"), []byte(routerSrc), 0o644); err != nil { + return fmt.Errorf("write router.ts: %w", err) + } + + // Generate Dockerfile. + dockerfile := deno.GenerateDockerfile() + if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil { + return fmt.Errorf("write Dockerfile: %w", err) + } + + return nil +} + +// prepareStaticBuild sets up the build context for a static nginx container. +func (m *Manager) prepareStaticBuild(srcDir, contextDir string) error { + // Copy all files to context directory. + if err := copyDir(srcDir, contextDir); err != nil { + return fmt.Errorf("copy files: %w", err) + } + + // Generate Dockerfile. + dockerfile := deno.GenerateStaticDockerfile() + if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil { + return fmt.Errorf("write Dockerfile: %w", err) + } + + return nil +} + +// buildEnvVars decrypts secrets and builds environment variable list. +func (m *Manager) buildEnvVars(siteID string) ([]string, error) { + secrets, err := m.store.GetStaticSiteSecretsBySiteID(siteID) + if err != nil { + return nil, fmt.Errorf("get secrets: %w", err) + } + + env := make([]string, 0, len(secrets)) + for _, s := range secrets { + value := s.Value + if s.Encrypted { + decrypted, err := crypto.Decrypt(m.encKey, value) + if err != nil { + return nil, fmt.Errorf("decrypt secret %s: %w", s.Key, err) + } + value = decrypted + } + env = append(env, s.Key+"="+value) + } + + return env, nil +} + +// removeContainerByName removes a container by its name (best effort). +func (m *Manager) removeContainerByName(ctx context.Context, name string) { + containers, err := m.docker.ListContainers(ctx, nil) + if err != nil { + return + } + for _, c := range containers { + if c.Name == name { + m.docker.StopContainer(ctx, c.ID, 10) + m.docker.RemoveContainer(ctx, c.ID, true) + return + } + } +} + +// updateStatus updates the site status in the database. +// On failure, it also publishes an event to the event log. +func (m *Manager) updateStatus(id, status, commitSHA, errMsg string) { + if err := m.store.UpdateStaticSiteStatus(id, status, commitSHA, errMsg); err != nil { + slog.Error("static site: failed to update status", "id", id, "status", status, "error", err) + } + + // Persist failures to event log automatically. + if status == "failed" { + site, err := m.store.GetStaticSiteByID(id) + siteName := id + if err == nil { + siteName = site.Name + } + m.publishEvent(id, siteName, "failed: "+errMsg) + } +} + +// publishEvent publishes a static site status event on the event bus +// and persists it to the event log for the dashboard. +func (m *Manager) publishEvent(siteID, siteName, status string) { + m.eventBus.Publish(events.Event{ + Type: events.EventStaticSiteStatus, + Payload: events.StaticSiteStatusPayload{ + SiteID: siteID, + Name: siteName, + Status: status, + }, + }) + + // Persist to event log. + severity := "info" + message := fmt.Sprintf("Static site \"%s\": %s", siteName, status) + if status == "failed" { + severity = "error" + } + metadata := fmt.Sprintf(`{"site_id":"%s","site_name":"%s","status":"%s"}`, siteID, siteName, status) + + evt, err := m.store.InsertEvent(store.EventLog{ + Source: "static_site", + Severity: severity, + Message: message, + Metadata: metadata, + }) + if err != nil { + slog.Error("static site: failed to persist event log", "error", err) + return + } + + // Publish the persisted event for SSE clients. + m.eventBus.Publish(events.Event{ + Type: events.EventLog, + Payload: events.EventLogPayload{ + ID: evt.ID, + Source: "static_site", + Severity: severity, + Message: message, + Metadata: metadata, + CreatedAt: evt.CreatedAt, + }, + }) +} + +// copyDir recursively copies a directory. +func copyDir(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(src, path) + if err != nil { + return err + } + + dstPath := filepath.Join(dst, relPath) + + if info.IsDir() { + return os.MkdirAll(dstPath, 0o755) + } + + data, err := os.ReadFile(path) + if err != nil { + return err + } + + return os.WriteFile(dstPath, data, info.Mode()) + }) +} + +// hostPortStr converts a uint16 port to a string for proxy configuration. +func hostPortStr(port uint16) string { + return strconv.FormatUint(uint64(port), 10) +} diff --git a/internal/staticsite/markdown.go b/internal/staticsite/markdown.go new file mode 100644 index 0000000..1d5c96e --- /dev/null +++ b/internal/staticsite/markdown.go @@ -0,0 +1,83 @@ +package staticsite + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/yuin/goldmark" +) + +// RenderMarkdownFiles walks the directory and converts all .md files to .html. +// The original .md file is kept alongside the generated .html file. +func RenderMarkdownFiles(dir string) error { + md := goldmark.New() + + return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + ext := strings.ToLower(filepath.Ext(path)) + if ext != ".md" && ext != ".markdown" { + return nil + } + + src, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + + var buf bytes.Buffer + if err := md.Convert(src, &buf); err != nil { + return fmt.Errorf("render %s: %w", path, err) + } + + html := wrapHTML(extractTitle(src), buf.String()) + + htmlPath := strings.TrimSuffix(path, filepath.Ext(path)) + ".html" + if err := os.WriteFile(htmlPath, []byte(html), 0o644); err != nil { + return fmt.Errorf("write %s: %w", htmlPath, err) + } + + return nil + }) +} + +// extractTitle finds the first # heading in markdown content. +func extractTitle(src []byte) string { + for _, line := range strings.Split(string(src), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "# ") { + return strings.TrimPrefix(trimmed, "# ") + } + } + return "Page" +} + +// wrapHTML wraps rendered markdown in a minimal HTML document. +func wrapHTML(title, body string) string { + return fmt.Sprintf(` + + + + +%s + + + +%s + +`, title, body) +} diff --git a/internal/staticsite/provider.go b/internal/staticsite/provider.go new file mode 100644 index 0000000..9ba24d8 --- /dev/null +++ b/internal/staticsite/provider.go @@ -0,0 +1,171 @@ +package staticsite + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// RepoInfo represents a repository returned by the provider's list/search API. +type RepoInfo struct { + Owner string `json:"owner"` + Name string `json:"name"` + FullName string `json:"full_name"` // "owner/name" + Description string `json:"description"` + Private bool `json:"private"` + HTMLURL string `json:"html_url"` +} + +// GitProvider abstracts Git hosting API operations. +// Implementations exist for Gitea/Forgejo/Gogs, GitHub, and GitLab. +type GitProvider interface { + // Name returns the provider identifier (e.g., "gitea", "github", "gitlab"). + Name() string + + // TestConnection verifies that the repository is accessible. + TestConnection(ctx context.Context, owner, repo string) error + + // ListRepos returns repositories accessible with the current token. + // If query is non-empty, results are filtered by name. + ListRepos(ctx context.Context, query string) ([]RepoInfo, error) + + // ListBranches returns all branch names for a repository. + ListBranches(ctx context.Context, owner, repo string) ([]string, error) + + // GetLatestCommitSHA returns the latest commit SHA for a branch. + GetLatestCommitSHA(ctx context.Context, owner, repo, branch string) (string, error) + + // ListTree returns the full directory tree for a branch. + ListTree(ctx context.Context, owner, repo, branch string) ([]FolderEntry, error) + + // DownloadFolder downloads all files from a folder path to a local directory. + DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error +} + +// ProviderType identifies a Git hosting provider. +type ProviderType string + +const ( + ProviderGitea ProviderType = "gitea" // Also works for Forgejo and Gogs. + ProviderGitHub ProviderType = "github" + ProviderGitLab ProviderType = "gitlab" +) + +// ValidProviderTypes lists all supported provider types. +var ValidProviderTypes = []ProviderType{ProviderGitea, ProviderGitHub, ProviderGitLab} + +// NewGitProvider creates a GitProvider for the given type. +// If providerType is empty, it attempts autodetection from the baseURL. +func NewGitProvider(providerType ProviderType, baseURL, token string) (GitProvider, error) { + if providerType == "" { + providerType = DetectProvider(baseURL) + } + + switch providerType { + case ProviderGitea: + return NewGiteaContentFetcher(baseURL, token), nil + case ProviderGitHub: + return NewGitHubProvider(baseURL, token), nil + case ProviderGitLab: + return NewGitLabProvider(baseURL, token), nil + default: + return nil, fmt.Errorf("unsupported git provider: %s", providerType) + } +} + +// DetectProvider guesses the provider type from a base URL. +func DetectProvider(baseURL string) ProviderType { + lower := strings.ToLower(baseURL) + switch { + case strings.Contains(lower, "github.com"): + return ProviderGitHub + case strings.Contains(lower, "gitlab.com"): + return ProviderGitLab + default: + // Default to Gitea for self-hosted instances (Gitea/Forgejo/Gogs all share the same API). + return ProviderGitea + } +} + +// DetectProviderWithProbe tries to autodetect the provider by probing known API endpoints. +// Falls back to URL-based detection if probing fails. +func DetectProviderWithProbe(ctx context.Context, baseURL string) ProviderType { + // First try URL-based detection for well-known hosts. + urlBased := DetectProvider(baseURL) + if urlBased == ProviderGitHub || urlBased == ProviderGitLab { + return urlBased + } + + // For unknown hosts, probe for Gitea/GitLab API signatures. + client := &http.Client{Timeout: 5 * time.Second} + base := strings.TrimRight(baseURL, "/") + + // Try Gitea/Forgejo API. + if resp, err := httpGet(ctx, client, base+"/api/v1/version"); err == nil && resp == http.StatusOK { + return ProviderGitea + } + + // Try GitLab API. + if resp, err := httpGet(ctx, client, base+"/api/v4/version"); err == nil && resp == http.StatusOK { + return ProviderGitLab + } + + // Default to Gitea. + return ProviderGitea +} + +// httpGet performs a simple GET and returns the status code. +func httpGet(ctx context.Context, client *http.Client, url string) (int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return 0, err + } + resp, err := client.Do(req) + if err != nil { + return 0, err + } + resp.Body.Close() + return resp.StatusCode, nil +} + +// downloadFileHTTP is a shared helper for downloading a file from a URL. +func downloadFileHTTP(ctx context.Context, client *http.Client, url, localPath string, authHeader func(r *http.Request)) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + if authHeader != nil { + authHeader(req) + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %d for %s", resp.StatusCode, url) + } + + if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + + file, err := os.Create(localPath) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer file.Close() + + if _, err := io.Copy(file, resp.Body); err != nil { + return fmt.Errorf("write file: %w", err) + } + + return nil +} diff --git a/internal/store/models.go b/internal/store/models.go index 495a2ae..17c6275 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -195,6 +195,43 @@ type Volume struct { UpdatedAt string `json:"updated_at"` } +// StaticSite represents a static site deployed from a Git repository folder. +type StaticSite struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` // "gitea", "github", "gitlab"; empty = autodetect + GiteaURL string `json:"gitea_url"` // base URL, e.g. https://git.example.com + RepoOwner string `json:"repo_owner"` + RepoName string `json:"repo_name"` + Branch string `json:"branch"` + FolderPath string `json:"folder_path"` // path within repo, e.g. "Pages" + AccessToken string `json:"access_token"` // encrypted; optional for public repos + Domain string `json:"domain"` // full domain for proxy + Mode string `json:"mode"` // "static" or "deno" + RenderMarkdown bool `json:"render_markdown"` + SyncTrigger string `json:"sync_trigger"` // "push", "tag", "manual" + TagPattern string `json:"tag_pattern"` // glob pattern for tag-based sync + ContainerID string `json:"container_id"` + ProxyRouteID string `json:"proxy_route_id"` + Status string `json:"status"` // idle, syncing, deployed, failed + LastSyncAt string `json:"last_sync_at"` + LastCommitSHA string `json:"last_commit_sha"` + Error string `json:"error"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +// StaticSiteSecret represents an encrypted environment variable for a static site's Deno backend. +type StaticSiteSecret struct { + ID string `json:"id"` + SiteID string `json:"site_id"` + Key string `json:"key"` + Value string `json:"value"` + Encrypted bool `json:"encrypted"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + // EventLog represents a persistent event log entry. type EventLog struct { ID int64 `json:"id"` diff --git a/internal/store/static_site_secrets.go b/internal/store/static_site_secrets.go new file mode 100644 index 0000000..2f659a7 --- /dev/null +++ b/internal/store/static_site_secrets.go @@ -0,0 +1,112 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +// CreateStaticSiteSecret inserts a new secret for a static site. +func (s *Store) CreateStaticSiteSecret(secret StaticSiteSecret) (StaticSiteSecret, error) { + secret.ID = uuid.New().String() + secret.CreatedAt = Now() + secret.UpdatedAt = secret.CreatedAt + + _, err := s.db.Exec( + `INSERT INTO static_site_secrets (id, site_id, key, value, encrypted, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + secret.ID, secret.SiteID, secret.Key, secret.Value, + BoolToInt(secret.Encrypted), secret.CreatedAt, secret.UpdatedAt, + ) + if err != nil { + return StaticSiteSecret{}, fmt.Errorf("insert static site secret: %w", err) + } + return secret, nil +} + +// GetStaticSiteSecretsBySiteID returns all secrets for a static site. +func (s *Store) GetStaticSiteSecretsBySiteID(siteID string) ([]StaticSiteSecret, error) { + rows, err := s.db.Query( + `SELECT id, site_id, key, value, encrypted, created_at, updated_at + FROM static_site_secrets WHERE site_id = ? ORDER BY key`, siteID, + ) + if err != nil { + return nil, fmt.Errorf("query static site secrets: %w", err) + } + defer rows.Close() + + secrets := []StaticSiteSecret{} + for rows.Next() { + secret, err := scanStaticSiteSecret(rows) + if err != nil { + return nil, err + } + secrets = append(secrets, secret) + } + return secrets, rows.Err() +} + +// GetStaticSiteSecretByID returns a single secret by ID. +func (s *Store) GetStaticSiteSecretByID(id string) (StaticSiteSecret, error) { + var secret StaticSiteSecret + var encrypted int + err := s.db.QueryRow( + `SELECT id, site_id, key, value, encrypted, created_at, updated_at + FROM static_site_secrets WHERE id = ?`, id, + ).Scan(&secret.ID, &secret.SiteID, &secret.Key, &secret.Value, &encrypted, + &secret.CreatedAt, &secret.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + return StaticSiteSecret{}, fmt.Errorf("static site secret %s: %w", id, ErrNotFound) + } + if err != nil { + return StaticSiteSecret{}, fmt.Errorf("query static site secret: %w", err) + } + secret.Encrypted = encrypted != 0 + return secret, nil +} + +// UpdateStaticSiteSecret updates an existing secret. +func (s *Store) UpdateStaticSiteSecret(secret StaticSiteSecret) error { + secret.UpdatedAt = Now() + result, err := s.db.Exec( + `UPDATE static_site_secrets SET key=?, value=?, encrypted=?, updated_at=? + WHERE id=?`, + secret.Key, secret.Value, BoolToInt(secret.Encrypted), secret.UpdatedAt, secret.ID, + ) + if err != nil { + return fmt.Errorf("update static site secret: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("static site secret %s: %w", secret.ID, ErrNotFound) + } + return nil +} + +// DeleteStaticSiteSecret removes a secret by ID. +func (s *Store) DeleteStaticSiteSecret(id string) error { + result, err := s.db.Exec(`DELETE FROM static_site_secrets WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete static site secret: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("static site secret %s: %w", id, ErrNotFound) + } + return nil +} + +// scanStaticSiteSecret scans a secret row from a *sql.Rows cursor. +func scanStaticSiteSecret(rows *sql.Rows) (StaticSiteSecret, error) { + var secret StaticSiteSecret + var encrypted int + err := rows.Scan(&secret.ID, &secret.SiteID, &secret.Key, &secret.Value, &encrypted, + &secret.CreatedAt, &secret.UpdatedAt) + if err != nil { + return StaticSiteSecret{}, fmt.Errorf("scan static site secret: %w", err) + } + secret.Encrypted = encrypted != 0 + return secret, nil +} diff --git a/internal/store/static_sites.go b/internal/store/static_sites.go new file mode 100644 index 0000000..9e49009 --- /dev/null +++ b/internal/store/static_sites.go @@ -0,0 +1,202 @@ +package store + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/google/uuid" +) + +// staticSiteCols is the column list for static_sites queries. +const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, branch, folder_path, + access_token, domain, mode, render_markdown, sync_trigger, tag_pattern, + container_id, proxy_route_id, status, last_sync_at, last_commit_sha, error, + created_at, updated_at` + +// CreateStaticSite inserts a new static site and returns it. +func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) { + site.ID = uuid.New().String() + site.CreatedAt = Now() + site.UpdatedAt = site.CreatedAt + + _, err := s.db.Exec( + `INSERT INTO static_sites (`+staticSiteCols+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName, + site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode, + BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern, + site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt, + site.LastCommitSHA, site.Error, site.CreatedAt, site.UpdatedAt, + ) + if err != nil { + return StaticSite{}, fmt.Errorf("insert static site: %w", err) + } + return site, nil +} + +// GetStaticSiteByID returns a single static site by its ID. +func (s *Store) GetStaticSiteByID(id string) (StaticSite, error) { + site, err := scanStaticSiteRow(s.db.QueryRow( + `SELECT `+staticSiteCols+` FROM static_sites WHERE id = ?`, id, + )) + if errors.Is(err, sql.ErrNoRows) { + return StaticSite{}, fmt.Errorf("static site %s: %w", id, ErrNotFound) + } + if err != nil { + return StaticSite{}, fmt.Errorf("query static site: %w", err) + } + return site, nil +} + +// GetAllStaticSites returns every static site ordered by name. +func (s *Store) GetAllStaticSites() ([]StaticSite, error) { + rows, err := s.db.Query( + `SELECT ` + staticSiteCols + ` FROM static_sites ORDER BY name`, + ) + if err != nil { + return nil, fmt.Errorf("query static sites: %w", err) + } + defer rows.Close() + + sites := []StaticSite{} + for rows.Next() { + site, err := scanStaticSiteRows(rows) + if err != nil { + return nil, err + } + sites = append(sites, site) + } + return sites, rows.Err() +} + +// GetStaticSitesByRepo returns all static sites for a given repo owner/name. +func (s *Store) GetStaticSitesByRepo(giteaURL, owner, name string) ([]StaticSite, error) { + rows, err := s.db.Query( + `SELECT `+staticSiteCols+` + FROM static_sites WHERE gitea_url = ? AND repo_owner = ? AND repo_name = ? + ORDER BY name`, + giteaURL, owner, name, + ) + if err != nil { + return nil, fmt.Errorf("query static sites by repo: %w", err) + } + defer rows.Close() + + sites := []StaticSite{} + for rows.Next() { + site, err := scanStaticSiteRows(rows) + if err != nil { + return nil, err + } + sites = append(sites, site) + } + return sites, rows.Err() +} + +// UpdateStaticSite updates an existing static site's configuration fields. +func (s *Store) UpdateStaticSite(site StaticSite) error { + site.UpdatedAt = Now() + result, err := s.db.Exec( + `UPDATE static_sites SET name=?, provider=?, gitea_url=?, repo_owner=?, repo_name=?, branch=?, + folder_path=?, access_token=?, domain=?, mode=?, render_markdown=?, + sync_trigger=?, tag_pattern=?, updated_at=? + WHERE id=?`, + site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName, site.Branch, + site.FolderPath, site.AccessToken, site.Domain, site.Mode, + BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern, + site.UpdatedAt, site.ID, + ) + if err != nil { + return fmt.Errorf("update static site: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("static site %s: %w", site.ID, ErrNotFound) + } + return nil +} + +// UpdateStaticSiteStatus updates the deployment status fields. +func (s *Store) UpdateStaticSiteStatus(id, status, commitSHA, errMsg string) error { + now := Now() + result, err := s.db.Exec( + `UPDATE static_sites SET status=?, last_commit_sha=?, last_sync_at=?, error=?, updated_at=? + WHERE id=?`, + status, commitSHA, now, errMsg, now, id, + ) + if err != nil { + return fmt.Errorf("update static site status: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("static site %s: %w", id, ErrNotFound) + } + return nil +} + +// UpdateStaticSiteContainer updates the container and proxy route IDs after deployment. +func (s *Store) UpdateStaticSiteContainer(id, containerID, proxyRouteID string) error { + now := Now() + result, err := s.db.Exec( + `UPDATE static_sites SET container_id=?, proxy_route_id=?, updated_at=? WHERE id=?`, + containerID, proxyRouteID, now, id, + ) + if err != nil { + return fmt.Errorf("update static site container: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("static site %s: %w", id, ErrNotFound) + } + return nil +} + +// DeleteStaticSite removes a static site by ID. Cascading deletes handle secrets. +func (s *Store) DeleteStaticSite(id string) error { + result, err := s.db.Exec(`DELETE FROM static_sites WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete static site: %w", err) + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("static site %s: %w", id, ErrNotFound) + } + return nil +} + +// scanStaticSiteRow scans a static site from a *sql.Row. +func scanStaticSiteRow(row *sql.Row) (StaticSite, error) { + var site StaticSite + var renderMarkdown int + err := row.Scan( + &site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName, + &site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode, + &renderMarkdown, &site.SyncTrigger, &site.TagPattern, + &site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt, + &site.LastCommitSHA, &site.Error, &site.CreatedAt, &site.UpdatedAt, + ) + if err != nil { + return StaticSite{}, err + } + site.RenderMarkdown = renderMarkdown != 0 + return site, nil +} + +// scanStaticSiteRows scans a static site from a *sql.Rows cursor. +func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) { + var site StaticSite + var renderMarkdown int + err := rows.Scan( + &site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName, + &site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode, + &renderMarkdown, &site.SyncTrigger, &site.TagPattern, + &site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt, + &site.LastCommitSHA, &site.Error, &site.CreatedAt, &site.UpdatedAt, + ) + if err != nil { + return StaticSite{}, fmt.Errorf("scan static site: %w", err) + } + site.RenderMarkdown = renderMarkdown != 0 + return site, nil +} diff --git a/internal/store/store.go b/internal/store/store.go index af38d83..ede79f6 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -123,6 +123,8 @@ func (s *Store) runMigrations() error { `ALTER TABLE settings ADD COLUMN public_ip TEXT NOT NULL DEFAULT ''`, // Image prune threshold (MB). Warn on dashboard when exceeded. 0 = disabled. `ALTER TABLE settings ADD COLUMN image_prune_threshold_mb INTEGER NOT NULL DEFAULT 1024`, + // Add provider column to static_sites (2026-04-11). + `ALTER TABLE static_sites ADD COLUMN provider TEXT NOT NULL DEFAULT ''`, } for _, m := range migrations { @@ -144,6 +146,7 @@ func (s *Store) runMigrations() error { `CREATE INDEX IF NOT EXISTS idx_event_log_source ON event_log(source)`, `CREATE INDEX IF NOT EXISTS idx_event_log_created_at ON event_log(created_at)`, `CREATE INDEX IF NOT EXISTS idx_dns_records_consumer ON dns_records(consumer_type, consumer_id)`, + `CREATE INDEX IF NOT EXISTS idx_static_site_secrets_site_id ON static_site_secrets(site_id)`, } for _, idx := range indexes { if _, err := s.db.Exec(idx); err != nil { @@ -361,6 +364,42 @@ CREATE TABLE IF NOT EXISTS backups ( backup_type TEXT NOT NULL DEFAULT 'manual', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); + +CREATE TABLE IF NOT EXISTS static_sites ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + provider TEXT NOT NULL DEFAULT '', + gitea_url TEXT NOT NULL DEFAULT '', + repo_owner TEXT NOT NULL DEFAULT '', + repo_name TEXT NOT NULL DEFAULT '', + branch TEXT NOT NULL DEFAULT 'main', + folder_path TEXT NOT NULL DEFAULT '', + access_token TEXT NOT NULL DEFAULT '', + domain TEXT NOT NULL DEFAULT '', + mode TEXT NOT NULL DEFAULT 'static', + render_markdown INTEGER NOT NULL DEFAULT 0, + sync_trigger TEXT NOT NULL DEFAULT 'manual', + tag_pattern TEXT NOT NULL DEFAULT '', + container_id TEXT NOT NULL DEFAULT '', + proxy_route_id TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'idle', + last_sync_at TEXT NOT NULL DEFAULT '', + last_commit_sha TEXT NOT NULL DEFAULT '', + error TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS static_site_secrets ( + id TEXT PRIMARY KEY, + site_id TEXT NOT NULL REFERENCES static_sites(id) ON DELETE CASCADE, + key TEXT NOT NULL, + value TEXT NOT NULL DEFAULT '', + encrypted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(site_id, key) +); ` // Now returns the current time formatted for SQLite storage. diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 71a62ea..feced09 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -604,4 +604,110 @@ export function fetchContainerStats( ); } +// ── Static Sites ────────────────────────────────────────────────────── + +import type { StaticSite, StaticSiteSecret, FolderEntry, GitProvider, RepoInfo } from './types'; + +export function listStaticSites(): Promise { + return get('/api/sites'); +} + +export function getStaticSite(id: string): Promise { + return get(`/api/sites/${id}`); +} + +export function createStaticSite(data: Partial): Promise { + return post('/api/sites', data); +} + +export function updateStaticSite(id: string, data: Partial): Promise { + return put(`/api/sites/${id}`, data); +} + +export function deleteStaticSite(id: string): Promise<{ deleted: string }> { + return del<{ deleted: string }>(`/api/sites/${id}`); +} + +export function deployStaticSite(id: string): Promise<{ status: string }> { + return post<{ status: string }>(`/api/sites/${id}/deploy`); +} + +export function stopStaticSite(id: string): Promise<{ status: string }> { + return post<{ status: string }>(`/api/sites/${id}/stop`); +} + +export function startStaticSite(id: string): Promise<{ status: string }> { + return post<{ status: string }>(`/api/sites/${id}/start`); +} + +export function listStaticSiteRepos(data: { + provider?: string; + gitea_url: string; + access_token?: string; + query?: string; +}): Promise { + return post('/api/sites/repos', data); +} + +export function detectStaticSiteProvider(url: string): Promise<{ provider: GitProvider }> { + return post<{ provider: GitProvider }>('/api/sites/detect-provider', { url }); +} + +export function testStaticSiteConnection(data: { + provider?: string; + gitea_url: string; + access_token?: string; + repo_owner: string; + repo_name: string; +}): Promise<{ status: string }> { + return post<{ status: string }>('/api/sites/test-connection', data); +} + +export function listStaticSiteBranches(data: { + provider?: string; + gitea_url: string; + access_token?: string; + repo_owner: string; + repo_name: string; +}): Promise { + return post('/api/sites/branches', data); +} + +export function listStaticSiteTree(data: { + provider?: string; + gitea_url: string; + access_token?: string; + repo_owner: string; + repo_name: string; + branch: string; +}): Promise { + return post('/api/sites/tree', data); +} + +export function listStaticSiteSecrets(siteId: string): Promise { + return get(`/api/sites/${siteId}/secrets`); +} + +export function createStaticSiteSecret( + siteId: string, + data: { key: string; value: string; encrypted?: boolean } +): Promise { + return post(`/api/sites/${siteId}/secrets`, data); +} + +export function updateStaticSiteSecret( + siteId: string, + secretId: string, + data: { key?: string; value?: string; encrypted?: boolean } +): Promise { + return put(`/api/sites/${siteId}/secrets/${secretId}`, data); +} + +export function deleteStaticSiteSecret( + siteId: string, + secretId: string +): Promise<{ deleted: string }> { + return del<{ deleted: string }>(`/api/sites/${siteId}/secrets/${secretId}`); +} + export { ApiError }; diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 2b570dc..7f3ec9f 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -17,7 +17,8 @@ "events": "Events", "settings": "Settings", "logout": "Log out", - "dns": "DNS Records" + "dns": "DNS Records", + "sites": "Sites" }, "dashboard": { "title": "Dashboard", @@ -539,6 +540,77 @@ }, "lastChecked": "Last checked" }, + "sites": { + "title": "Static Sites", + "addSite": "New Site", + "newSite": "New Static Site", + "createSite": "Create Site", + "noSites": "No static sites", + "noSitesDesc": "Deploy static content from a Git repository folder.", + "searchPlaceholder": "Search sites by name, domain, or repo...", + "noMatching": "No sites match your search.", + "name": "Name", + "domain": "Domain", + "mode": "Mode", + "status": "Status", + "lastSync": "Last Sync", + "deploy": "Deploy", + "stop": "Stop", + "start": "Start", + "openSite": "Open Site", + "confirmDelete": "Delete Site", + "confirmDeleteMsg": "This will permanently delete the site and remove its container", + "siteInfo": "Site Information", + "folder": "Folder", + "syncTrigger": "Sync Trigger", + "commitSha": "Commit SHA", + "secrets": "Secrets", + "addSecret": "Add Secret", + "noSecrets": "No secrets configured. Add secrets if your site needs server-side API keys.", + "secretKey": "Key", + "secretValue": "Value", + "encryptSecret": "Encrypt value", + "saveSecret": "Add Secret", + "step1Title": "1. Repository", + "step2Title": "2. Select Branch", + "step3Title": "3. Select Folder", + "step4Title": "4. Configuration", + "step5Title": "5. Review & Create", + "fullRepoUrl": "Repository URL", + "fullRepoUrlHelp": "Paste a full URL to auto-fill the fields below (e.g., https://git.example.com/owner/repo)", + "serverUrl": "Server URL", + "repoUrl": "Git Server URL", + "repoUrlHelp": "Paste a full repo URL or enter the server base URL (Gitea, Forgejo, Gogs)", + "repoOwner": "Owner", + "repoName": "Repository", + "accessToken": "Access Token", + "accessTokenPlaceholder": "Optional — for private repos", + "accessTokenHelp": "Personal access token with repo read permissions. Leave empty for public repos.", + "noToken": "None (public repo)", + "testConnection": "Test Connection", + "connectionSuccess": "Repository is accessible", + "loadingBranches": "Loading branches...", + "selectBranch": "Select a branch", + "chooseBranch": "Choose a branch...", + "branch": "Branch", + "loadingTree": "Loading repository tree...", + "selectFolder": "Select the folder containing your site files", + "selectedFolder": "Selected folder", + "siteName": "Site Name", + "domainHelp": "Public domain for the site. Proxy will be configured automatically.", + "modeStaticDesc": "HTML, CSS, JS, images served via Nginx", + "modeDenoDesc": "Static files + server-side API from api/ folder", + "triggerManual": "Manual", + "triggerPush": "On Push", + "triggerTag": "On Tag", + "tagPattern": "Tag Pattern", + "tagPatternHelp": "Glob pattern for matching tags (e.g., v*, pages-*)", + "renderMarkdown": "Render Markdown files to HTML", + "provider": "Git Provider", + "detectedProvider": "Auto-detected", + "browseRepos": "Browse repositories", + "selectRepo": "Select a repository" + }, "common": { "cancel": "Cancel", "confirm": "Confirm", @@ -556,7 +628,11 @@ "restart": "Restart", "remove": "Remove", "instance": "instance", - "instances": "instances" + "instances": "instances", + "next": "Next", + "yes": "Yes", + "no": "No", + "saving": "Saving..." }, "instance": { "stopConfirm": "This will stop the running container. The instance can be started again later.", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index dd2f5b1..784f70b 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -17,7 +17,8 @@ "events": "События", "settings": "Настройки", "logout": "Выйти", - "dns": "DNS-записи" + "dns": "DNS-записи", + "sites": "Сайты" }, "dashboard": { "title": "Панель управления", @@ -539,6 +540,77 @@ }, "lastChecked": "Последняя проверка" }, + "sites": { + "title": "Статические сайты", + "addSite": "Новый сайт", + "newSite": "Новый статический сайт", + "createSite": "Создать сайт", + "noSites": "Нет статических сайтов", + "noSitesDesc": "Разверните статический контент из папки Git-репозитория.", + "searchPlaceholder": "Поиск по имени, домену или репозиторию...", + "noMatching": "Нет сайтов, соответствующих поиску.", + "name": "Имя", + "domain": "Домен", + "mode": "Режим", + "status": "Статус", + "lastSync": "Последняя синхр.", + "deploy": "Развернуть", + "stop": "Остановить", + "start": "Запустить", + "openSite": "Открыть сайт", + "confirmDelete": "Удалить сайт", + "confirmDeleteMsg": "Это удалит сайт и его контейнер", + "siteInfo": "Информация о сайте", + "folder": "Папка", + "syncTrigger": "Триггер синхр.", + "commitSha": "Коммит SHA", + "secrets": "Секреты", + "addSecret": "Добавить секрет", + "noSecrets": "Секреты не настроены. Добавьте их, если сайту нужны серверные API-ключи.", + "secretKey": "Ключ", + "secretValue": "Значение", + "encryptSecret": "Шифровать значение", + "saveSecret": "Добавить секрет", + "step1Title": "1. Репозиторий", + "step2Title": "2. Выбор ветки", + "step3Title": "3. Выбор папки", + "step4Title": "4. Настройки", + "step5Title": "5. Проверка и создание", + "fullRepoUrl": "URL репозитория", + "fullRepoUrlHelp": "Вставьте полный URL для автозаполнения полей ниже (напр., https://git.example.com/owner/repo)", + "serverUrl": "URL сервера", + "repoUrl": "URL Git-сервера", + "repoUrlHelp": "Вставьте полный URL репозитория или базовый URL сервера (Gitea, Forgejo, Gogs)", + "repoOwner": "Владелец", + "repoName": "Репозиторий", + "accessToken": "Токен доступа", + "accessTokenPlaceholder": "Необязательно — для приватных репозиториев", + "accessTokenHelp": "Персональный токен с правами на чтение репозитория. Оставьте пустым для публичных.", + "noToken": "Нет (публичный репо)", + "testConnection": "Проверить соединение", + "connectionSuccess": "Репозиторий доступен", + "loadingBranches": "Загрузка веток...", + "selectBranch": "Выберите ветку", + "chooseBranch": "Выберите ветку...", + "branch": "Ветка", + "loadingTree": "Загрузка дерева репозитория...", + "selectFolder": "Выберите папку с файлами сайта", + "selectedFolder": "Выбранная папка", + "siteName": "Имя сайта", + "domainHelp": "Публичный домен сайта. Прокси будет настроен автоматически.", + "modeStaticDesc": "HTML, CSS, JS, изображения через Nginx", + "modeDenoDesc": "Статические файлы + серверный API из папки api/", + "triggerManual": "Вручную", + "triggerPush": "При пуше", + "triggerTag": "По тегу", + "tagPattern": "Паттерн тега", + "tagPatternHelp": "Glob-паттерн для тегов (напр., v*, pages-*)", + "renderMarkdown": "Рендерить Markdown-файлы в HTML", + "provider": "Git-провайдер", + "detectedProvider": "Автоопределён", + "browseRepos": "Обзор репозиториев", + "selectRepo": "Выберите репозиторий" + }, "common": { "cancel": "Отмена", "confirm": "Подтвердить", @@ -556,7 +628,11 @@ "restart": "Перезапустить", "remove": "Удалить", "instance": "экземпляр", - "instances": "экземпляров" + "instances": "экземпляров", + "next": "Далее", + "yes": "Да", + "no": "Нет", + "saving": "Сохранение..." }, "instance": { "stopConfirm": "Контейнер будет остановлен. Экземпляр можно будет запустить снова позже.", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 42c7e8f..1dd92ff 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -336,6 +336,63 @@ export interface StaleContainer { days_stale: number; } +/** A static site deployed from a Git repository folder. */ +export interface StaticSite { + id: string; + name: string; + provider: GitProvider; + gitea_url: string; + repo_owner: string; + repo_name: string; + branch: string; + folder_path: string; + access_token: string; + domain: string; + mode: 'static' | 'deno'; + render_markdown: boolean; + sync_trigger: 'push' | 'tag' | 'manual'; + tag_pattern: string; + container_id: string; + proxy_route_id: string; + status: StaticSiteStatus; + last_sync_at: string; + last_commit_sha: string; + error: string; + created_at: string; + updated_at: string; +} + +export type StaticSiteStatus = 'idle' | 'syncing' | 'deployed' | 'failed' | 'stopped'; + +export type GitProvider = '' | 'gitea' | 'github' | 'gitlab'; + +/** An encrypted environment variable for a static site's Deno backend. */ +export interface StaticSiteSecret { + id: string; + site_id: string; + key: string; + value: string; + encrypted: boolean; + created_at: string; + updated_at: string; +} + +/** A repository from the Git provider's API. */ +export interface RepoInfo { + owner: string; + name: string; + full_name: string; + description: string; + private: boolean; + html_url: string; +} + +/** A folder entry from the Gitea repo tree. */ +export interface FolderEntry { + path: string; + is_dir: boolean; +} + /** Container CPU and memory stats from the Docker stats API. */ export interface ContainerStats { cpu_percent: number; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 853016c..bc7fe9c 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -6,13 +6,14 @@ import Toast from '$lib/components/Toast.svelte'; import ThemeToggle from '$lib/components/ThemeToggle.svelte'; import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte'; - import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout } from '$lib/components/icons'; + import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconGlobe } from '$lib/components/icons'; import { goto } from '$app/navigation'; import { connectGlobalEvents, type SSEConnection } from '$lib/sse'; import { instanceStatusStore } from '$lib/stores/instance-status'; import { resolvedTheme, applyTheme } from '$lib/stores/theme'; import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth'; import { logout as apiLogout, getHealth } from '$lib/api'; + import { publishEventLog } from '$lib/stores/event-log-bus'; import type { DockerHealth, ProxyHealth } from '$lib/types'; import { t } from '$lib/i18n'; @@ -25,6 +26,7 @@ const navItems = [ { href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' }, { href: '/projects', labelKey: 'nav.projects', icon: 'projects' }, + { href: '/sites', labelKey: 'nav.sites', icon: 'globe' }, { href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' }, { href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' }, { href: '/events', labelKey: 'nav.events', icon: 'events' }, @@ -96,6 +98,9 @@ }, onDeployStatus(payload) { instanceStatusStore.notifyDeploy(payload); + }, + onEventLog(payload) { + publishEventLog(payload); } }); @@ -175,6 +180,8 @@ {:else if item.icon === 'projects'} + {:else if item.icon === 'globe'} + {:else if item.icon === 'deploy'} {:else if item.icon === 'proxies'} diff --git a/web/src/routes/sites/+page.svelte b/web/src/routes/sites/+page.svelte new file mode 100644 index 0000000..c1b09e6 --- /dev/null +++ b/web/src/routes/sites/+page.svelte @@ -0,0 +1,267 @@ + + + + {$t('sites.title')} - {$t('app.name')} + + +
+
+

{$t('sites.title')}

+ + + {$t('sites.addSite')} + +
+ + {#if loading} + + {:else if error} +
+

{error}

+ +
+ {:else if sites.length === 0} + { window.location.href = '/sites/new'; }} + /> + {:else} + +
+ + +
+ + {#if filteredSites.length === 0} +
+

{$t('sites.noMatching')}

+
+ {:else} +
+ + + + + + + + + + + + + {#each filteredSites as site (site.id)} + {@const status = statusBadge(site.status)} + {@const mode = modeBadge(site.mode)} + + + + + + + + + {/each} + +
{$t('sites.name')}{$t('sites.domain')}{$t('sites.mode')}{$t('sites.status')}{$t('sites.lastSync')}
+ + {site.name} + +

{site.repo_owner}/{site.repo_name}

+
+ {#if site.domain} + + {site.domain} + + {:else} + - + {/if} + + + {mode.text} + + + + {status.text} + + {#if site.error} +

{site.error}

+ {/if} +
+ {#if site.last_sync_at} + {new Date(site.last_sync_at).toLocaleString()} + {:else} + - + {/if} + +
+ + {#if site.status === 'stopped'} + + {:else if site.status === 'deployed'} + + {/if} + +
+
+
+ {/if} + {/if} +
+ +{#if confirmDelete} + { confirmDelete = null; }} + /> +{/if} diff --git a/web/src/routes/sites/[id]/+page.svelte b/web/src/routes/sites/[id]/+page.svelte new file mode 100644 index 0000000..8b96c85 --- /dev/null +++ b/web/src/routes/sites/[id]/+page.svelte @@ -0,0 +1,322 @@ + + + + {site?.name ?? $t('sites.title')} - {$t('app.name')} + + +
+ {#if loading} +
+ + {$t('common.loading')} +
+ {:else if error && !site} +
+

{error}

+
+ {:else if site} + +
+
+ + + +
+

{site.name}

+

{site.repo_owner}/{site.repo_name} · {site.branch}

+
+
+
+ + {#if site.status === 'stopped'} + + {:else if site.status === 'deployed'} + + {/if} + {#if site.domain} + + + {$t('sites.openSite')} + + {/if} + +
+
+ + {#if error} +
+

{error}

+
+ {/if} + + +
+ +
+

{$t('sites.siteInfo')}

+
+ {$t('sites.status')} + + {statusBadge(site.status).text} + + + {$t('sites.mode')} + {site.mode === 'deno' ? 'Deno (Static + API)' : 'Static (Nginx)'} + + {$t('sites.domain')} + {site.domain || '-'} + + {$t('sites.folder')} + {site.folder_path || '/ (root)'} + + {$t('sites.syncTrigger')} + {site.sync_trigger}{site.sync_trigger === 'tag' ? ` (${site.tag_pattern})` : ''} + + {$t('sites.lastSync')} + {site.last_sync_at ? new Date(site.last_sync_at).toLocaleString() : '-'} + + {$t('sites.commitSha')} + {site.last_commit_sha ? site.last_commit_sha.slice(0, 8) : '-'} +
+ + {#if site.error} +
+

{site.error}

+
+ {/if} +
+ + +
+
+

{$t('sites.secrets')}

+ +
+ + {#if showSecretForm} +
+ + + + +
+ {/if} + + {#if secrets.length === 0} +

{$t('sites.noSecrets')}

+ {:else} +
+ {#each secrets as secret (secret.id)} +
+
+ {#if secret.encrypted} + + {:else} + + {/if} + {secret.key} + {secret.value} +
+ +
+ {/each} +
+ {/if} +
+
+ {/if} +
+ +{#if confirmDelete} + { confirmDelete = false; }} + /> +{/if} diff --git a/web/src/routes/sites/new/+page.svelte b/web/src/routes/sites/new/+page.svelte new file mode 100644 index 0000000..c1443eb --- /dev/null +++ b/web/src/routes/sites/new/+page.svelte @@ -0,0 +1,671 @@ + + + + {$t('sites.newSite')} - {$t('app.name')} + + +
+ +
+ + + +

{$t('sites.newSite')}

+
+ + +
+ {#each Array(totalSteps) as _, i} +
+ {/each} +
+ +
+ + {#if step === 1} +

{$t('sites.step1Title')}

+ +
+ +
+ +
+ {#each providerOptions as opt} + + {/each} +
+ {#if provider === '' && detectedProvider} +

+ {$t('sites.detectedProvider')}: {providerOptions.find(o => o.value === detectedProvider)?.label ?? detectedProvider} +

+ {/if} +
+ + + { + const val = (e.target as HTMLInputElement).value; + if (val.includes('/') && val.startsWith('http')) { + parseRepoUrl(val); + autoDetectProvider(); + } + }} + /> + + + +
+ +
+
+ +
+ +
+
+ { showRepoPicker = false; }} + /> + + + {#if connectionError} +
+

{connectionError}

+
+ {/if} + {#if connectionTested} +
+ +

{$t('sites.connectionSuccess')}

+
+ {/if} +
+ +
+ + +
+ + + {:else if step === 2} +

{$t('sites.step2Title')}

+ + {#if branchesLoading} +
+ + {$t('sites.loadingBranches')} +
+ {:else} +
+

{$t('sites.selectBranch')}

+ + { selectedBranch = val; showBranchPicker = false; tree = []; }} + onclose={() => { showBranchPicker = false; }} + /> +
+ {/if} + +
+ + +
+ + + {:else if step === 3} +

{$t('sites.step3Title')}

+ + {#if treeLoading} +
+ + {$t('sites.loadingTree')} +
+ {:else} +

{$t('sites.selectFolder')}

+ + + + +
+ {#each getTopLevelFolders() as folder (folder.path)} + {@const isSelected = selectedFolder === folder.path} + {@const isExpanded = expandedDirs.has(folder.path)} + {@const children = getChildFolders(folder.path)} +
+
+ {#if children.length > 0} + + {:else} + + {/if} + +
+ {#if isExpanded} +
+ {#each children as child (child.path)} + {@const childSelected = selectedFolder === child.path} + + {/each} +
+ {/if} +
+ {/each} +
+ + {#if selectedFolder} +

{$t('sites.selectedFolder')}: {selectedFolder || '/'}

+ {/if} + {/if} + +
+ + +
+ + + {:else if step === 4} +

{$t('sites.step4Title')}

+ +
+
+ + +
+ + +
+ +
+ + +
+
+ + +
+ +
+ {#each [ + { value: 'manual', label: $t('sites.triggerManual') }, + { value: 'push', label: $t('sites.triggerPush') }, + { value: 'tag', label: $t('sites.triggerTag') } + ] as opt} + + {/each} +
+
+ + {#if syncTrigger === 'tag'} + + {/if} + + + +
+ +
+ + +
+ + + {:else if step === 5} +

{$t('sites.step5Title')}

+ +
+
+ {$t('sites.provider')} + {providerOptions.find(o => o.value === effectiveProvider)?.label ?? effectiveProvider} + + {$t('sites.repoUrl')} + {giteaUrl}/{repoOwner}/{repoName} + + {$t('sites.branch')} + {selectedBranch} + + {$t('sites.folder')} + {selectedFolder || '/ (root)'} + + {$t('sites.siteName')} + {siteName} + + {$t('sites.domain')} + {domain || '-'} + + {$t('sites.mode')} + {mode === 'deno' ? 'Deno (Static + API)' : 'Static (Nginx)'} + + {$t('sites.syncTrigger')} + {syncTrigger}{syncTrigger === 'tag' ? ` (${tagPattern})` : ''} + + {$t('sites.renderMarkdown')} + {renderMarkdown ? $t('common.yes') : $t('common.no')} + + {$t('sites.accessToken')} + {accessToken ? '••••••••' : $t('sites.noToken')} +
+
+ + {#if submitError} +
+

{submitError}

+
+ {/if} + +
+ + +
+ {/if} +
+