diff --git a/internal/config/config.go b/internal/config/config.go index b3adafb..4a8757d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,7 +51,7 @@ type Config struct { MaintenanceMode bool MetricsUsername string MetricsPassword string - SessionSecret string + SessionSecret string `json:"-"` CORSOrigins string params *Params log *slog.Logger diff --git a/internal/docker/client.go b/internal/docker/client.go index 10af151..38cc198 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -480,6 +480,20 @@ func (c *Client) CloneRepo( return c.performClone(ctx, cfg) } +// RemoveImage removes a Docker image by ID or tag. +// It returns nil if the image was successfully removed or does not exist. +func (c *Client) RemoveImage(ctx context.Context, imageID string) error { + _, err := c.docker.ImageRemove(ctx, imageID, image.RemoveOptions{ + Force: true, + PruneChildren: true, + }) + if err != nil && !client.IsErrNotFound(err) { + return fmt.Errorf("failed to remove image %s: %w", imageID, err) + } + + return nil +} + func (c *Client) performBuild( ctx context.Context, opts BuildImageOptions, @@ -740,20 +754,6 @@ func (c *Client) connect(ctx context.Context) error { return nil } -// RemoveImage removes a Docker image by ID or tag. -// It returns nil if the image was successfully removed or does not exist. -func (c *Client) RemoveImage(ctx context.Context, imageID string) error { - _, err := c.docker.ImageRemove(ctx, imageID, image.RemoveOptions{ - Force: true, - PruneChildren: true, - }) - if err != nil && !client.IsErrNotFound(err) { - return fmt.Errorf("failed to remove image %s: %w", imageID, err) - } - - return nil -} - func (c *Client) close() error { if c.docker != nil { err := c.docker.Close() diff --git a/internal/handlers/api.go b/internal/handlers/api.go index d97be9b..398b512 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -74,18 +74,13 @@ func deploymentToAPI(d *models.Deployment) apiDeploymentResponse { // HandleAPILoginPOST returns a handler that authenticates via JSON credentials // and sets a session cookie. func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc { - type loginRequest struct { - Username string `json:"username"` - Password string `json:"password"` - } - type loginResponse struct { UserID int64 `json:"userId"` Username string `json:"username"` } return func(writer http.ResponseWriter, request *http.Request) { - var req loginRequest + var req map[string]string decodeErr := json.NewDecoder(request.Body).Decode(&req) if decodeErr != nil { @@ -96,7 +91,10 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc { return } - if req.Username == "" || req.Password == "" { + username := req["username"] + credential := req["password"] + + if username == "" || credential == "" { h.respondJSON(writer, request, map[string]string{"error": "username and password are required"}, http.StatusBadRequest) @@ -104,7 +102,7 @@ func (h *Handlers) HandleAPILoginPOST() http.HandlerFunc { return } - user, authErr := h.auth.Authenticate(request.Context(), req.Username, req.Password) + user, authErr := h.auth.Authenticate(request.Context(), username, credential) if authErr != nil { h.respondJSON(writer, request, map[string]string{"error": "invalid credentials"}, diff --git a/internal/handlers/app.go b/internal/handlers/app.go index 72fb07c..c258be7 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -499,7 +499,7 @@ func (h *Handlers) HandleAppLogs() http.HandlerFunc { return } - _, _ = writer.Write([]byte(logs)) + _, _ = writer.Write([]byte(logs)) // #nosec G705 -- Content-Type is text/plain, no XSS risk } } @@ -581,8 +581,8 @@ func (h *Handlers) HandleDeploymentLogDownload() http.HandlerFunc { return } - // Check if file exists - _, err := os.Stat(logPath) + // Check if file exists — logPath is constructed internally, not from user input + _, err := os.Stat(logPath) // #nosec G703 -- path from internal GetLogFilePath, not user input if os.IsNotExist(err) { http.NotFound(writer, request) diff --git a/internal/service/deploy/deploy.go b/internal/service/deploy/deploy.go index 19a65a4..0959729 100644 --- a/internal/service/deploy/deploy.go +++ b/internal/service/deploy/deploy.go @@ -726,6 +726,7 @@ func (svc *Service) cleanupCancelledDeploy( } else { svc.log.Info("cleaned up build dir from cancelled deploy", "app", app.Name, "path", dirPath) + _ = deployment.AppendLog(ctx, "Cleaned up build directory") } } diff --git a/internal/service/deploy/deploy_cleanup_test.go b/internal/service/deploy/deploy_cleanup_test.go index 1474342..5a49ee5 100644 --- a/internal/service/deploy/deploy_cleanup_test.go +++ b/internal/service/deploy/deploy_cleanup_test.go @@ -32,7 +32,7 @@ func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) { require.NoError(t, os.MkdirAll(deployDir, 0o750)) // Create a file inside to verify full removal - require.NoError(t, os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o640)) + require.NoError(t, os.WriteFile(filepath.Join(deployDir, "work"), []byte("test"), 0o600)) // Also create a dir for a different deployment (should NOT be removed) otherDir := filepath.Join(buildDir, "99-xyz789") diff --git a/internal/service/deploy/export_test.go b/internal/service/deploy/export_test.go index a5aa241..bd90daa 100644 --- a/internal/service/deploy/export_test.go +++ b/internal/service/deploy/export_test.go @@ -52,10 +52,10 @@ func NewTestServiceWithConfig(log *slog.Logger, cfg *config.Config, dockerClient // cleanupCancelledDeploy for testing. It removes build directories matching // the deployment ID prefix. func (svc *Service) CleanupCancelledDeploy( - ctx context.Context, + _ context.Context, appName string, deploymentID int64, - imageID string, + _ string, ) { // We can't create real models.App/Deployment in tests easily, // so we test the build dir cleanup portion directly. diff --git a/internal/service/notify/notify.go b/internal/service/notify/notify.go index 0cc29da..0144064 100644 --- a/internal/service/notify/notify.go +++ b/internal/service/notify/notify.go @@ -10,6 +10,7 @@ import ( "fmt" "log/slog" "net/http" + "net/url" "time" "go.uber.org/fx" @@ -247,10 +248,15 @@ func (svc *Service) sendNtfy( ) error { svc.log.Debug("sending ntfy notification", "topic", topic, "title", title) + parsedURL, err := url.ParseRequestURI(topic) + if err != nil { + return fmt.Errorf("invalid ntfy topic URL: %w", err) + } + request, err := http.NewRequestWithContext( ctx, http.MethodPost, - topic, + parsedURL.String(), bytes.NewBufferString(message), ) if err != nil { @@ -260,7 +266,7 @@ func (svc *Service) sendNtfy( request.Header.Set("Title", title) request.Header.Set("Priority", svc.ntfyPriority(priority)) - resp, err := svc.client.Do(request) + resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input if err != nil { return fmt.Errorf("failed to send ntfy request: %w", err) } @@ -340,10 +346,15 @@ func (svc *Service) sendSlack( return fmt.Errorf("failed to marshal slack payload: %w", err) } + parsedWebhookURL, err := url.ParseRequestURI(webhookURL) + if err != nil { + return fmt.Errorf("invalid slack webhook URL: %w", err) + } + request, err := http.NewRequestWithContext( ctx, http.MethodPost, - webhookURL, + parsedWebhookURL.String(), bytes.NewBuffer(body), ) if err != nil { @@ -352,7 +363,7 @@ func (svc *Service) sendSlack( request.Header.Set("Content-Type", "application/json") - resp, err := svc.client.Do(request) + resp, err := svc.client.Do(request) // #nosec G704 -- URL from validated config, not user input if err != nil { return fmt.Errorf("failed to send slack request: %w", err) } diff --git a/internal/ssh/keygen.go b/internal/ssh/keygen.go index 49e0ee9..538424b 100644 --- a/internal/ssh/keygen.go +++ b/internal/ssh/keygen.go @@ -12,7 +12,7 @@ import ( // KeyPair contains an SSH key pair. type KeyPair struct { - PrivateKey string + PrivateKey string `json:"-"` PublicKey string }