update gui

This commit is contained in:
2026-07-28 21:07:30 -05:00
parent f56ac6a2c4
commit 6c4ce8f6df
7 changed files with 164 additions and 6 deletions
+41
View File
@@ -787,6 +787,47 @@ func (s *Server) handleUpsertPackage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/portal/packages?notice=Package+saved", http.StatusSeeOther) http.Redirect(w, r, "/portal/packages?notice=Package+saved", http.StatusSeeOther)
} }
func (s *Server) handleSetPackageStatus(w http.ResponseWriter, r *http.Request) {
tech, _ := s.currentTechnician(r)
slug := strings.TrimSpace(r.FormValue("slug"))
if !validSlug(slug) {
http.Error(w, "invalid package ID", http.StatusBadRequest)
return
}
enabled := r.FormValue("enabled") == "1"
result, err := s.db.ExecContext(
r.Context(),
`UPDATE packages
SET enabled = ?,
updated_at = CURRENT_TIMESTAMP
WHERE slug = ?`,
enabled, slug,
)
if err != nil {
http.Error(w, "unable to update package status", http.StatusInternalServerError)
return
}
updated, err := result.RowsAffected()
if err != nil {
http.Error(w, "unable to confirm package status", http.StatusInternalServerError)
return
}
if updated == 0 {
http.Error(w, "package not found", http.StatusNotFound)
return
}
eventType := "package_disabled"
notice := "Package+distribution+disabled"
if enabled {
eventType = "package_enabled"
notice = "Package+distribution+enabled"
}
_ = s.audit(r.Context(), eventType, tech.Login, nil, "", slug, s.clientIP(r),
fmt.Sprintf("enabled=%t", enabled))
http.Redirect(w, r, "/portal/packages?notice="+notice, http.StatusSeeOther)
}
func (s *Server) savePackage( func (s *Server) savePackage(
r *http.Request, r *http.Request,
slug string, slug string,
+95
View File
@@ -125,6 +125,101 @@ func authorizationUpdateRequest(
return response return response
} }
func packageStatusRequest(
t *testing.T,
server *Server,
sessionToken string,
slug string,
enabled bool,
) *httptest.ResponseRecorder {
t.Helper()
values := url.Values{
"csrf_token": {"csrf-token"},
"slug": {slug},
}
if enabled {
values.Set("enabled", "1")
}
request := httptest.NewRequest(
http.MethodPost,
"/portal/packages/status",
strings.NewReader(values.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessionToken})
response := httptest.NewRecorder()
server.requireTechnician(server.handleSetPackageStatus)(response, request)
return response
}
func TestSetPackageStatusChangesOnlyDistributionAvailability(t *testing.T) {
server, sessionToken := newAuthorizationTestServer(t)
if _, err := server.db.Exec(
`INSERT INTO packages
(slug, display_name, package_name, package_version, file_name, sha256, enabled)
VALUES ('sentinelone-linux', 'SentinelOne', 'sentinelone-linux',
'26.1.1.31', 'agent.deb', ?, TRUE)`,
strings.Repeat("a", 64),
); err != nil {
t.Fatal(err)
}
response := packageStatusRequest(t, server, sessionToken, "sentinelone-linux", false)
if response.Code != http.StatusSeeOther {
t.Fatalf("disable status = %d, want %d: %s", response.Code, http.StatusSeeOther, response.Body.String())
}
if location := response.Header().Get("Location"); !strings.Contains(location, "disabled") {
t.Fatalf("disable redirect = %q", location)
}
var version, fileName, sha256 string
var enabled bool
if err := server.db.QueryRow(
`SELECT package_version, file_name, sha256, enabled
FROM packages WHERE slug = 'sentinelone-linux'`,
).Scan(&version, &fileName, &sha256, &enabled); err != nil {
t.Fatal(err)
}
if enabled || version != "26.1.1.31" || fileName != "agent.deb" ||
sha256 != strings.Repeat("a", 64) {
t.Fatalf("disable changed package metadata: enabled=%t version=%q file=%q sha256=%q", enabled, version, fileName, sha256)
}
response = packageStatusRequest(t, server, sessionToken, "sentinelone-linux", true)
if response.Code != http.StatusSeeOther {
t.Fatalf("enable status = %d, want %d: %s", response.Code, http.StatusSeeOther, response.Body.String())
}
if err := server.db.QueryRow(
`SELECT enabled FROM packages WHERE slug = 'sentinelone-linux'`,
).Scan(&enabled); err != nil {
t.Fatal(err)
}
if !enabled {
t.Fatal("package was not re-enabled")
}
rows, err := server.db.Query(
`SELECT event_type, actor, package_slug, details
FROM audit_events ORDER BY id`,
)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for index, expected := range []string{"package_disabled", "package_enabled"} {
if !rows.Next() {
t.Fatalf("missing audit event %d", index)
}
var eventType, actor, slug, details string
if err := rows.Scan(&eventType, &actor, &slug, &details); err != nil {
t.Fatal(err)
}
if eventType != expected || actor != "taiadmin" || slug != "sentinelone-linux" {
t.Fatalf("audit event = %q/%q/%q", eventType, actor, slug)
}
}
}
func TestUpdateAuthorizationExtendsAndReplacesAccess(t *testing.T) { func TestUpdateAuthorizationExtendsAndReplacesAccess(t *testing.T) {
server, sessionToken := newAuthorizationTestServer(t) server, sessionToken := newAuthorizationTestServer(t)
oldExpiresAt := seedAuthorizationForUpdate(t, server) oldExpiresAt := seedAuthorizationForUpdate(t, server)
+1
View File
@@ -206,6 +206,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /portal/authorizations/{id}/update", s.requireTechnician(s.handleUpdateAuthorization)) mux.HandleFunc("POST /portal/authorizations/{id}/update", s.requireTechnician(s.handleUpdateAuthorization))
mux.HandleFunc("POST /portal/authorizations/{id}/revoke", s.requireTechnician(s.handleRevokeAuthorization)) mux.HandleFunc("POST /portal/authorizations/{id}/revoke", s.requireTechnician(s.handleRevokeAuthorization))
mux.HandleFunc("POST /portal/packages", s.requireTechnician(s.handleUpsertPackage)) mux.HandleFunc("POST /portal/packages", s.requireTechnician(s.handleUpsertPackage))
mux.HandleFunc("POST /portal/packages/status", s.requireTechnician(s.handleSetPackageStatus))
mux.HandleFunc("POST /portal/packages/upload", s.requireTechnician(s.handleUploadPackage)) mux.HandleFunc("POST /portal/packages/upload", s.requireTechnician(s.handleUploadPackage))
mux.HandleFunc("POST /api/v1/exchange", s.handleExchange) mux.HandleFunc("POST /api/v1/exchange", s.handleExchange)
mux.HandleFunc("POST /api/v1/hosts/register", s.handleFleetRegister) mux.HandleFunc("POST /api/v1/hosts/register", s.handleFleetRegister)
+6 -1
View File
@@ -65,9 +65,14 @@ func TestPackageAndAuditTemplatesExecute(t *testing.T) {
Enabled: true, Enabled: true,
}}, }},
} }
if err := templates.ExecuteTemplate(io.Discard, "packages.html", data); err != nil { var packagesOutput bytes.Buffer
if err := templates.ExecuteTemplate(&packagesOutput, "packages.html", data); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !strings.Contains(packagesOutput.String(), "Save status only") ||
!strings.Contains(packagesOutput.String(), `formaction="/portal/packages/status"`) {
t.Fatal("packages page does not include the status-only update control")
}
data.CurrentView = "audit" data.CurrentView = "audit"
data.AuditFilters.TimeRange = "30d" data.AuditFilters.TimeRange = "30d"
+1
View File
@@ -248,6 +248,7 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; }
.package-row div { display: grid; gap: 4px; } .package-row div { display: grid; gap: 4px; }
.package-row small { color: var(--muted); } .package-row small { color: var(--muted); }
.package-row .status { align-items: center; justify-content: center; align-self: center; line-height: 1; text-align: center; } .package-row .status { align-items: center; justify-content: center; align-self: center; line-height: 1; text-align: center; }
.package-update-actions { display: flex; flex-wrap: wrap; gap: 12px; }
.toggle { display: flex; align-items: center; gap: 9px; } .toggle { display: flex; align-items: center; gap: 9px; }
.empty, .fine-print { color: var(--muted); } .empty, .fine-print { color: var(--muted); }
.package-forms { display: grid; gap: 26px; } .package-forms { display: grid; gap: 26px; }
+10
View File
@@ -18,6 +18,7 @@ document.addEventListener("click", async (event) => {
document.addEventListener("submit", async (event) => { document.addEventListener("submit", async (event) => {
const form = event.target.closest("[data-upload-form]"); const form = event.target.closest("[data-upload-form]");
if (!form) return; if (!form) return;
if (event.submitter?.matches("[data-package-status]")) return;
event.preventDefault(); event.preventDefault();
const button = form.querySelector("button[type=submit]"); const button = form.querySelector("button[type=submit]");
@@ -51,3 +52,12 @@ document.addEventListener("submit", async (event) => {
button.disabled = false; button.disabled = false;
} }
}); });
document.querySelectorAll("[data-package-select]").forEach((select) => {
const enabled = select.form.querySelector("[data-package-enabled]");
const syncPackageStatus = () => {
enabled.checked = select.selectedOptions[0]?.dataset.enabled === "true";
};
select.addEventListener("change", syncPackageStatus);
syncPackageStatus();
});
+10 -5
View File
@@ -46,17 +46,22 @@
<input type="hidden" name="mode" value="update"> <input type="hidden" name="mode" value="update">
<p class="form-title">Update a package</p> <p class="form-title">Update a package</p>
<label>Package <label>Package
<select name="slug" required> <select name="slug" required data-package-select>
{{range .Packages}} {{range .Packages}}
<option value="{{.Slug}}">{{.DisplayName}} ({{.Slug}} · {{.PackageVersion}})</option> <option value="{{.Slug}}" data-enabled="{{.Enabled}}">{{.DisplayName}} ({{.Slug}} · {{.PackageVersion}})</option>
{{end}} {{end}}
</select> </select>
</label> </label>
<label>New version <input name="package_version" placeholder="26.2.0.10" required></label> <label>New version <input name="package_version" placeholder="26.2.0.10" required></label>
<label class="toggle"><input type="checkbox" name="enabled" value="1" checked> Enable after upload</label> <label class="toggle"><input type="checkbox" name="enabled" value="1" data-package-enabled> Available for distribution</label>
<label>Replacement installer <input name="package_file" type="file" required></label> <label>Replacement installer <input name="package_file" type="file" required></label>
<p class="form-help">The package ID stays the same. Existing authorizations automatically use the replacement, and its prior Gitea version is removed.</p> <p class="form-help">Replace the installer and version, or save only its distribution status. Disabling takes effect immediately but keeps the package and its authorization selections intact.</p>
<button class="button primary" type="submit">Replace current package</button> <div class="package-update-actions">
<button class="button primary" type="submit">Replace current package</button>
<button class="button secondary" type="submit"
formaction="/portal/packages/status" formnovalidate
data-package-status>Save status only</button>
</div>
<p class="upload-status" data-upload-status aria-live="polite"></p> <p class="upload-status" data-upload-status aria-live="polite"></p>
</form> </form>
{{end}} {{end}}