Merge branch 'main' into fix/issue-1
This commit is contained in:
@@ -3,7 +3,9 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -158,6 +160,60 @@ func (d *Database) connect(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
// Backfill webhook_secret_hash for any rows that have a secret but no hash
|
||||
err = d.backfillWebhookSecretHashes(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to backfill webhook secret hashes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HashWebhookSecret returns the hex-encoded SHA-256 hash of a webhook secret.
|
||||
func HashWebhookSecret(secret string) string {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (d *Database) backfillWebhookSecretHashes(ctx context.Context) error {
|
||||
rows, err := d.database.QueryContext(ctx,
|
||||
"SELECT id, webhook_secret FROM apps WHERE webhook_secret_hash = '' AND webhook_secret != ''")
|
||||
if err != nil {
|
||||
return fmt.Errorf("querying apps for backfill: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
type row struct {
|
||||
id, secret string
|
||||
}
|
||||
|
||||
var toUpdate []row
|
||||
|
||||
for rows.Next() {
|
||||
var r row
|
||||
if scanErr := rows.Scan(&r.id, &r.secret); scanErr != nil {
|
||||
return fmt.Errorf("scanning app for backfill: %w", scanErr)
|
||||
}
|
||||
|
||||
toUpdate = append(toUpdate, r)
|
||||
}
|
||||
|
||||
if rowsErr := rows.Err(); rowsErr != nil {
|
||||
return fmt.Errorf("iterating apps for backfill: %w", rowsErr)
|
||||
}
|
||||
|
||||
for _, r := range toUpdate {
|
||||
hash := HashWebhookSecret(r.secret)
|
||||
|
||||
_, updateErr := d.database.ExecContext(ctx,
|
||||
"UPDATE apps SET webhook_secret_hash = ? WHERE id = ?", hash, r.id)
|
||||
if updateErr != nil {
|
||||
return fmt.Errorf("updating webhook_secret_hash for app %s: %w", r.id, updateErr)
|
||||
}
|
||||
|
||||
d.log.Info("backfilled webhook_secret_hash", "app_id", r.id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
28
internal/database/hash_test.go
Normal file
28
internal/database/hash_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
)
|
||||
|
||||
func TestHashWebhookSecret(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Known SHA-256 of "test-secret"
|
||||
hash := database.HashWebhookSecret("test-secret")
|
||||
assert.Equal(t,
|
||||
"9caf06bb4436cdbfa20af9121a626bc1093c4f54b31c0fa937957856135345b6",
|
||||
hash,
|
||||
)
|
||||
|
||||
// Different secrets produce different hashes
|
||||
hash2 := database.HashWebhookSecret("other-secret")
|
||||
assert.NotEqual(t, hash, hash2)
|
||||
|
||||
// Same secret always produces same hash (deterministic)
|
||||
hash3 := database.HashWebhookSecret("test-secret")
|
||||
assert.Equal(t, hash, hash3)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Add webhook_secret_hash column for constant-time secret lookup
|
||||
ALTER TABLE apps ADD COLUMN webhook_secret_hash TEXT NOT NULL DEFAULT '';
|
||||
@@ -29,8 +29,8 @@ const (
|
||||
func (h *Handlers) HandleAppNew() http.HandlerFunc {
|
||||
tmpl := templates.GetParsed()
|
||||
|
||||
return func(writer http.ResponseWriter, _ *http.Request) {
|
||||
data := h.addGlobals(map[string]any{})
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
data := h.addGlobals(map[string]any{}, request)
|
||||
|
||||
err := tmpl.ExecuteTemplate(writer, "app_new.html", data)
|
||||
if err != nil {
|
||||
@@ -57,12 +57,12 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc {
|
||||
branch := request.FormValue("branch")
|
||||
dockerfilePath := request.FormValue("dockerfile_path")
|
||||
|
||||
data := map[string]any{
|
||||
data := h.addGlobals(map[string]any{
|
||||
"Name": name,
|
||||
"RepoURL": repoURL,
|
||||
"Branch": branch,
|
||||
"DockerfilePath": dockerfilePath,
|
||||
}
|
||||
}, request)
|
||||
|
||||
if name == "" || repoURL == "" {
|
||||
data["Error"] = "Name and repository URL are required"
|
||||
@@ -150,7 +150,7 @@ func (h *Handlers) HandleAppDetail() http.HandlerFunc {
|
||||
"WebhookURL": webhookURL,
|
||||
"DeployKey": deployKey,
|
||||
"Success": request.URL.Query().Get("success"),
|
||||
})
|
||||
}, request)
|
||||
|
||||
err := tmpl.ExecuteTemplate(writer, "app_detail.html", data)
|
||||
if err != nil {
|
||||
@@ -183,7 +183,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc {
|
||||
|
||||
data := h.addGlobals(map[string]any{
|
||||
"App": application,
|
||||
})
|
||||
}, request)
|
||||
|
||||
err := tmpl.ExecuteTemplate(writer, "app_edit.html", data)
|
||||
if err != nil {
|
||||
@@ -241,10 +241,10 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc {
|
||||
if saveErr != nil {
|
||||
h.log.Error("failed to update app", "error", saveErr)
|
||||
|
||||
data := map[string]any{
|
||||
data := h.addGlobals(map[string]any{
|
||||
"App": application,
|
||||
"Error": "Failed to update app",
|
||||
}
|
||||
}, request)
|
||||
_ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
|
||||
|
||||
return
|
||||
@@ -337,7 +337,7 @@ func (h *Handlers) HandleAppDeployments() http.HandlerFunc {
|
||||
data := h.addGlobals(map[string]any{
|
||||
"App": application,
|
||||
"Deployments": deployments,
|
||||
})
|
||||
}, request)
|
||||
|
||||
err := tmpl.ExecuteTemplate(writer, "deployments.html", data)
|
||||
if err != nil {
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
func (h *Handlers) HandleLoginGET() http.HandlerFunc {
|
||||
tmpl := templates.GetParsed()
|
||||
|
||||
return func(writer http.ResponseWriter, _ *http.Request) {
|
||||
data := h.addGlobals(map[string]any{})
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
data := h.addGlobals(map[string]any{}, request)
|
||||
|
||||
err := tmpl.ExecuteTemplate(writer, "login.html", data)
|
||||
if err != nil {
|
||||
@@ -38,7 +38,7 @@ func (h *Handlers) HandleLoginPOST() http.HandlerFunc {
|
||||
|
||||
data := h.addGlobals(map[string]any{
|
||||
"Username": username,
|
||||
})
|
||||
}, request)
|
||||
|
||||
if username == "" || password == "" {
|
||||
data["Error"] = "Username and password are required"
|
||||
|
||||
@@ -67,7 +67,7 @@ func (h *Handlers) HandleDashboard() http.HandlerFunc {
|
||||
|
||||
data := h.addGlobals(map[string]any{
|
||||
"AppStats": appStats,
|
||||
})
|
||||
}, request)
|
||||
|
||||
execErr := tmpl.ExecuteTemplate(writer, "dashboard.html", data)
|
||||
if execErr != nil {
|
||||
|
||||
@@ -3,9 +3,11 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/csrf"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
@@ -64,11 +66,18 @@ func New(_ fx.Lifecycle, params Params) (*Handlers, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// addGlobals adds version info to template data map.
|
||||
func (h *Handlers) addGlobals(data map[string]any) map[string]any {
|
||||
// addGlobals adds version info and CSRF token to template data map.
|
||||
func (h *Handlers) addGlobals(
|
||||
data map[string]any,
|
||||
request *http.Request,
|
||||
) map[string]any {
|
||||
data["Version"] = h.globals.Version
|
||||
data["Appname"] = h.globals.Appname
|
||||
|
||||
if request != nil {
|
||||
data["CSRFField"] = template.HTML(csrf.TemplateField(request)) //nolint:gosec // csrf.TemplateField produces safe HTML
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ const (
|
||||
func (h *Handlers) HandleSetupGET() http.HandlerFunc {
|
||||
tmpl := templates.GetParsed()
|
||||
|
||||
return func(writer http.ResponseWriter, _ *http.Request) {
|
||||
data := h.addGlobals(map[string]any{})
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
data := h.addGlobals(map[string]any{}, request)
|
||||
|
||||
err := tmpl.ExecuteTemplate(writer, "setup.html", data)
|
||||
if err != nil {
|
||||
@@ -54,13 +54,14 @@ func validateSetupForm(formData setupFormData) string {
|
||||
func (h *Handlers) renderSetupError(
|
||||
tmpl *templates.TemplateExecutor,
|
||||
writer http.ResponseWriter,
|
||||
request *http.Request,
|
||||
username string,
|
||||
errorMsg string,
|
||||
) {
|
||||
data := h.addGlobals(map[string]any{
|
||||
"Username": username,
|
||||
"Error": errorMsg,
|
||||
})
|
||||
}, request)
|
||||
_ = tmpl.ExecuteTemplate(writer, "setup.html", data)
|
||||
}
|
||||
|
||||
@@ -83,7 +84,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
|
||||
}
|
||||
|
||||
if validationErr := validateSetupForm(formData); validationErr != "" {
|
||||
h.renderSetupError(tmpl, writer, formData.username, validationErr)
|
||||
h.renderSetupError(tmpl, writer, request, formData.username, validationErr)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -95,7 +96,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
|
||||
)
|
||||
if createErr != nil {
|
||||
h.log.Error("failed to create user", "error", createErr)
|
||||
h.renderSetupError(tmpl, writer, formData.username, "Failed to create user")
|
||||
h.renderSetupError(tmpl, writer, request, formData.username, "Failed to create user")
|
||||
|
||||
return
|
||||
}
|
||||
@@ -106,6 +107,7 @@ func (h *Handlers) HandleSetupPOST() http.HandlerFunc {
|
||||
h.renderSetupError(
|
||||
tmpl,
|
||||
writer,
|
||||
request,
|
||||
formData.username,
|
||||
"Failed to create session",
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/99designs/basicauth-go"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
"github.com/gorilla/csrf"
|
||||
"go.uber.org/fx"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/config"
|
||||
@@ -152,6 +153,15 @@ func (m *Middleware) SessionAuth() func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// CSRF returns CSRF protection middleware using gorilla/csrf.
|
||||
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
||||
return csrf.Protect(
|
||||
[]byte(m.params.Config.SessionSecret),
|
||||
csrf.Secure(false), // Allow HTTP for development; reverse proxy handles TLS
|
||||
csrf.Path("/"),
|
||||
)
|
||||
}
|
||||
|
||||
// SetupRequired returns middleware that redirects to setup if no user exists.
|
||||
func (m *Middleware) SetupRequired() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
|
||||
@@ -10,6 +10,12 @@ import (
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
)
|
||||
|
||||
// appColumns is the standard column list for app queries.
|
||||
const appColumns = `id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||
ssh_private_key, ssh_public_key, image_id, status,
|
||||
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash,
|
||||
created_at, updated_at`
|
||||
|
||||
// AppStatus represents the status of an app.
|
||||
type AppStatus string
|
||||
|
||||
@@ -31,8 +37,9 @@ type App struct {
|
||||
RepoURL string
|
||||
Branch string
|
||||
DockerfilePath string
|
||||
WebhookSecret string
|
||||
SSHPrivateKey string
|
||||
WebhookSecret string
|
||||
WebhookSecretHash string
|
||||
SSHPrivateKey string
|
||||
SSHPublicKey string
|
||||
ImageID sql.NullString
|
||||
Status AppStatus
|
||||
@@ -70,11 +77,8 @@ func (a *App) Delete(ctx context.Context) error {
|
||||
|
||||
// Reload refreshes the app from the database.
|
||||
func (a *App) Reload(ctx context.Context) error {
|
||||
row := a.db.QueryRow(ctx, `
|
||||
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||
ssh_private_key, ssh_public_key, image_id, status,
|
||||
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
||||
FROM apps WHERE id = ?`,
|
||||
row := a.db.QueryRow(ctx,
|
||||
"SELECT "+appColumns+" FROM apps WHERE id = ?",
|
||||
a.ID,
|
||||
)
|
||||
|
||||
@@ -136,13 +140,13 @@ func (a *App) insert(ctx context.Context) error {
|
||||
INSERT INTO apps (
|
||||
id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||
ssh_private_key, ssh_public_key, image_id, status,
|
||||
docker_network, ntfy_topic, slack_webhook
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
docker_network, ntfy_topic, slack_webhook, webhook_secret_hash
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
_, err := a.db.Exec(ctx, query,
|
||||
a.ID, a.Name, a.RepoURL, a.Branch, a.DockerfilePath, a.WebhookSecret,
|
||||
a.SSHPrivateKey, a.SSHPublicKey, a.ImageID, a.Status,
|
||||
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook,
|
||||
a.DockerNetwork, a.NtfyTopic, a.SlackWebhook, a.WebhookSecretHash,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -177,6 +181,7 @@ func (a *App) scan(row *sql.Row) error {
|
||||
&a.SSHPrivateKey, &a.SSHPublicKey,
|
||||
&a.ImageID, &a.Status,
|
||||
&a.DockerNetwork, &a.NtfyTopic, &a.SlackWebhook,
|
||||
&a.WebhookSecretHash,
|
||||
&a.CreatedAt, &a.UpdatedAt,
|
||||
)
|
||||
}
|
||||
@@ -193,6 +198,7 @@ func scanApps(appDB *database.Database, rows *sql.Rows) ([]*App, error) {
|
||||
&app.SSHPrivateKey, &app.SSHPublicKey,
|
||||
&app.ImageID, &app.Status,
|
||||
&app.DockerNetwork, &app.NtfyTopic, &app.SlackWebhook,
|
||||
&app.WebhookSecretHash,
|
||||
&app.CreatedAt, &app.UpdatedAt,
|
||||
)
|
||||
if scanErr != nil {
|
||||
@@ -221,11 +227,8 @@ func FindApp(
|
||||
app := NewApp(appDB)
|
||||
app.ID = appID
|
||||
|
||||
row := appDB.QueryRow(ctx, `
|
||||
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||
ssh_private_key, ssh_public_key, image_id, status,
|
||||
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
||||
FROM apps WHERE id = ?`,
|
||||
row := appDB.QueryRow(ctx,
|
||||
"SELECT "+appColumns+" FROM apps WHERE id = ?",
|
||||
appID,
|
||||
)
|
||||
|
||||
@@ -241,7 +244,8 @@ func FindApp(
|
||||
return app, nil
|
||||
}
|
||||
|
||||
// FindAppByWebhookSecret finds an app by webhook secret.
|
||||
// FindAppByWebhookSecret finds an app by webhook secret using a SHA-256 hash
|
||||
// lookup. This avoids SQL string comparison timing side-channels.
|
||||
//
|
||||
//nolint:nilnil // returning nil,nil is idiomatic for "not found" in Active Record
|
||||
func FindAppByWebhookSecret(
|
||||
@@ -250,13 +254,11 @@ func FindAppByWebhookSecret(
|
||||
secret string,
|
||||
) (*App, error) {
|
||||
app := NewApp(appDB)
|
||||
secretHash := database.HashWebhookSecret(secret)
|
||||
|
||||
row := appDB.QueryRow(ctx, `
|
||||
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||
ssh_private_key, ssh_public_key, image_id, status,
|
||||
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
||||
FROM apps WHERE webhook_secret = ?`,
|
||||
secret,
|
||||
row := appDB.QueryRow(ctx,
|
||||
"SELECT "+appColumns+" FROM apps WHERE webhook_secret_hash = ?",
|
||||
secretHash,
|
||||
)
|
||||
|
||||
err := app.scan(row)
|
||||
@@ -273,11 +275,8 @@ func FindAppByWebhookSecret(
|
||||
|
||||
// AllApps returns all apps ordered by name.
|
||||
func AllApps(ctx context.Context, appDB *database.Database) ([]*App, error) {
|
||||
rows, err := appDB.Query(ctx, `
|
||||
SELECT id, name, repo_url, branch, dockerfile_path, webhook_secret,
|
||||
ssh_private_key, ssh_public_key, image_id, status,
|
||||
docker_network, ntfy_topic, slack_webhook, created_at, updated_at
|
||||
FROM apps ORDER BY name`,
|
||||
rows, err := appDB.Query(ctx,
|
||||
"SELECT "+appColumns+" FROM apps ORDER BY name",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying all apps: %w", err)
|
||||
|
||||
@@ -297,6 +297,7 @@ func TestAllApps(t *testing.T) {
|
||||
app.Branch = testBranch
|
||||
app.DockerfilePath = "Dockerfile"
|
||||
app.WebhookSecret = "secret-" + strconv.Itoa(idx)
|
||||
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
|
||||
app.SSHPrivateKey = "private"
|
||||
app.SSHPublicKey = "public"
|
||||
|
||||
@@ -791,6 +792,7 @@ func createTestApp(t *testing.T, testDB *database.Database) *models.App {
|
||||
app.Branch = testBranch
|
||||
app.DockerfilePath = "Dockerfile"
|
||||
app.WebhookSecret = "secret-" + t.Name()
|
||||
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
|
||||
app.SSHPrivateKey = "private"
|
||||
app.SSHPublicKey = "public"
|
||||
|
||||
|
||||
@@ -37,18 +37,22 @@ func (s *Server) SetupRoutes() {
|
||||
http.FileServer(http.FS(static.Static)),
|
||||
))
|
||||
|
||||
// Public routes
|
||||
s.router.Get("/login", s.handlers.HandleLoginGET())
|
||||
s.router.Post("/login", s.handlers.HandleLoginPOST())
|
||||
s.router.Get("/setup", s.handlers.HandleSetupGET())
|
||||
s.router.Post("/setup", s.handlers.HandleSetupPOST())
|
||||
|
||||
// Webhook endpoint (uses secret for auth, not session)
|
||||
// Webhook endpoint (uses secret for auth, not session — no CSRF)
|
||||
s.router.Post("/webhook/{secret}", s.handlers.HandleWebhook())
|
||||
|
||||
// Protected routes (require session auth)
|
||||
// All HTML-serving routes get CSRF protection
|
||||
s.router.Group(func(r chi.Router) {
|
||||
r.Use(s.mw.SessionAuth())
|
||||
r.Use(s.mw.CSRF())
|
||||
|
||||
// Public routes
|
||||
r.Get("/login", s.handlers.HandleLoginGET())
|
||||
r.Post("/login", s.handlers.HandleLoginPOST())
|
||||
r.Get("/setup", s.handlers.HandleSetupGET())
|
||||
r.Post("/setup", s.handlers.HandleSetupPOST())
|
||||
|
||||
// Protected routes (require session auth)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(s.mw.SessionAuth())
|
||||
|
||||
// Dashboard
|
||||
r.Get("/", s.handlers.HandleDashboard())
|
||||
@@ -90,6 +94,7 @@ func (s *Server) SetupRoutes() {
|
||||
// Ports
|
||||
r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd())
|
||||
r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete())
|
||||
})
|
||||
})
|
||||
|
||||
// Metrics endpoint (optional, with basic auth)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/oklog/ulid/v2"
|
||||
|
||||
"go.uber.org/fx"
|
||||
|
||||
"git.eeqj.de/sneak/upaas/internal/database"
|
||||
@@ -82,6 +83,7 @@ func (svc *Service) CreateApp(
|
||||
}
|
||||
|
||||
app.WebhookSecret = uuid.New().String()
|
||||
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
|
||||
app.SSHPrivateKey = keyPair.PrivateKey
|
||||
app.SSHPublicKey = keyPair.PublicKey
|
||||
app.Status = models.AppStatusPending
|
||||
|
||||
@@ -91,6 +91,7 @@ func createTestApp(
|
||||
app.Branch = branch
|
||||
app.DockerfilePath = "Dockerfile"
|
||||
app.WebhookSecret = "webhook-secret-123"
|
||||
app.WebhookSecretHash = database.HashWebhookSecret(app.WebhookSecret)
|
||||
app.SSHPrivateKey = "private-key"
|
||||
app.SSHPublicKey = "public-key"
|
||||
app.Status = models.AppStatusPending
|
||||
|
||||
Reference in New Issue
Block a user