update gui
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
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(
|
||||
r *http.Request,
|
||||
slug string,
|
||||
|
||||
@@ -125,6 +125,101 @@ func authorizationUpdateRequest(
|
||||
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) {
|
||||
server, sessionToken := newAuthorizationTestServer(t)
|
||||
oldExpiresAt := seedAuthorizationForUpdate(t, server)
|
||||
|
||||
@@ -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}/revoke", s.requireTechnician(s.handleRevokeAuthorization))
|
||||
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 /api/v1/exchange", s.handleExchange)
|
||||
mux.HandleFunc("POST /api/v1/hosts/register", s.handleFleetRegister)
|
||||
|
||||
@@ -65,9 +65,14 @@ func TestPackageAndAuditTemplatesExecute(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
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.AuditFilters.TimeRange = "30d"
|
||||
|
||||
@@ -248,6 +248,7 @@ dd { margin: 4px 0 0; overflow-wrap: anywhere; }
|
||||
.package-row div { display: grid; gap: 4px; }
|
||||
.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-update-actions { display: flex; flex-wrap: wrap; gap: 12px; }
|
||||
.toggle { display: flex; align-items: center; gap: 9px; }
|
||||
.empty, .fine-print { color: var(--muted); }
|
||||
.package-forms { display: grid; gap: 26px; }
|
||||
|
||||
@@ -18,6 +18,7 @@ document.addEventListener("click", async (event) => {
|
||||
document.addEventListener("submit", async (event) => {
|
||||
const form = event.target.closest("[data-upload-form]");
|
||||
if (!form) return;
|
||||
if (event.submitter?.matches("[data-package-status]")) return;
|
||||
event.preventDefault();
|
||||
|
||||
const button = form.querySelector("button[type=submit]");
|
||||
@@ -51,3 +52,12 @@ document.addEventListener("submit", async (event) => {
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -46,17 +46,22 @@
|
||||
<input type="hidden" name="mode" value="update">
|
||||
<p class="form-title">Update a package</p>
|
||||
<label>Package
|
||||
<select name="slug" required>
|
||||
<select name="slug" required data-package-select>
|
||||
{{range .Packages}}
|
||||
<option value="{{.Slug}}">{{.DisplayName}} ({{.Slug}} · {{.PackageVersion}})</option>
|
||||
<option value="{{.Slug}}" data-enabled="{{.Enabled}}">{{.DisplayName}} ({{.Slug}} · {{.PackageVersion}})</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</form>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user