release / govulncheck (push) Successful in 45s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 4m25s
release / android (push) Successful in 6m17s
release / Build signed APK (releases and dev) (push) Successful in 5m57s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m54s
release / Verify release artifacts (tag releases only) (push) Skipped
The duplicate sweep proposed 4,197 groups and every one waited for the operator. Most are safe to settle, and Lidarr defines what safe means: it maps one file to each track of the release it monitors and downloads any mapped file that disappears. Deleting a mapped copy opens exactly the hole the operator saw Lidarr fill. Classify (#5435) - Migration 0075: duplicate_groups.class (same_release, cross_release, mismatch, review), resolve_note, resolved_automatically; duplicate_group_members.lidarr_state (tracked, unmapped); fingerprint_settings.auto_resolve; notification kind duplicates_resolved with both kind CHECKs swapped (rule 36). - library.ClassifyDuplicateGroup, with MatchTitleKey dropping featuring credits, remaster notes and video-rip markers, and keeping live, demo, remix and instrumental. The rip markers move from api to library. Choose the copy to keep (#5436) - ProposeSurvivor ranks the copy Lidarr maps first, then tag fit (a clash-free track number, no rip marker in the name, an MBID), then the quality rules. File size picked the wrong Humanz copy in 6 of 21 groups. Act (#5437) - An hourly resolver pass reads Lidarr's unmapped files, matched by the last three path components, and records each copy's state. - Same album, with at most one copy mapped: merged into the mapped copy. The merge is guarded, so a mapped copy can never be removed (MergeDuplicateGroupGuarded, ErrCopyTrackedByLidarr). - Same album, every copy mapped: the monitored release lists the song twice (Humanz's 14x12" box set). The pass moves Lidarr to the release that lists each song once and best covers what is on disk. It never picks one covering less, and is capped at 10 albums per pass. - Fixed point (lesson #4183): the chosen release no longer repeats. - The album is left alone for 24h while Lidarr rescans, so "every copy unmapped" mid-rescan is never read as licence to merge. - Both actions are audited with no actor and summarised to admins. The operator can switch them off in the Fingerprinting card (rule 25). - Manual merges use the same guard: 409 copy_tracked_by_lidarr, or 503 lidarr_unavailable when Lidarr cannot say. Web - Duplicates gets tabs: Needs review, Across releases, Resolved automatically. Each loads as you scroll (rule 172), replacing the pager. - Each copy says whether Lidarr uses it. - The resolver's note shows on each group. - The merge confirm blocks, before sending, a merge that would remove the copy Lidarr uses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
112 lines
4.4 KiB
Go
112 lines
4.4 KiB
Go
// Package audit writes admin-driven user-management events to audit_log.
|
|
// Thin wrapper over the sqlc-generated WriteAuditLog query — the value
|
|
// is centralizing the action-name vocabulary and the metadata
|
|
// marshaling so callers don't repeat boilerplate.
|
|
//
|
|
// Audit writes are best-effort from the caller's perspective: a failed
|
|
// audit write must NOT fail the user-facing operation. Callers
|
|
// log-and-continue. The audit log is observability, not a transaction
|
|
// participant.
|
|
package audit
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
)
|
|
|
|
// Action is the discriminator stored in audit_log.action. New values
|
|
// added in U2/U3 + future tasks; declare them here so callers don't
|
|
// stringly-type and so a future audit-search UI has a single source
|
|
// of truth for the vocabulary.
|
|
type Action string
|
|
|
|
const (
|
|
// U1
|
|
ActionRegister Action = "register"
|
|
ActionPromoteAdmin Action = "promote_admin"
|
|
ActionDemoteAdmin Action = "demote_admin"
|
|
ActionInviteCreate Action = "invite_create"
|
|
ActionInviteRedeem Action = "invite_redeem"
|
|
ActionInviteRevoke Action = "invite_revoke"
|
|
|
|
// U2 (declared early; callers in U1 don't use them yet, but
|
|
// having them here means U2's diff is purely additive on the
|
|
// caller side, not also touching this file).
|
|
ActionCreateUserAdmin Action = "create_user_admin"
|
|
ActionDeleteUser Action = "delete_user"
|
|
ActionPasswordResetAdmin Action = "password_reset_admin"
|
|
ActionAutoApproveToggle Action = "auto_approve_toggle"
|
|
|
|
// U3
|
|
ActionPasswordChangeSelf Action = "password_change_self"
|
|
ActionTokenRegenerate Action = "token_regenerate"
|
|
ActionForgotPasswordInit Action = "forgot_password_initiated"
|
|
ActionPasswordResetByEmail Action = "password_reset_via_email"
|
|
|
|
// Active-sessions surface (#370). Worth auditing rather than silent:
|
|
// revoking sessions is what a user does when they think an account is
|
|
// compromised, so the audit trail is most useful precisely when it's
|
|
// exercised.
|
|
ActionSessionRevoke Action = "session_revoke"
|
|
ActionSessionRevokeOthers Action = "session_revoke_others"
|
|
|
|
// Duplicate merge (#3911). Irreversible: a copy's file and row are removed
|
|
// and its history moved onto the copy kept. The metadata names both, so the
|
|
// log can answer "where did that file go" long after the report is gone.
|
|
ActionDuplicateMerge Action = "duplicate_merge"
|
|
|
|
// Lidarr release change (M498 #5437): the duplicate resolver moved an
|
|
// album's monitored release in Lidarr to one that lists each song once.
|
|
// Written with no actor; the metadata names the album and both releases.
|
|
ActionLidarrReleaseChange Action = "lidarr_release_change"
|
|
|
|
// Subsonic password (#5026): generated in Settings for t/s-only clients.
|
|
ActionSubsonicPasswordSet Action = "subsonic_password_set"
|
|
ActionSubsonicPasswordClear Action = "subsonic_password_clear"
|
|
)
|
|
|
|
// Write inserts one audit_log row. metadata is marshaled as JSON;
|
|
// nil metadata writes SQL NULL. Errors are returned so callers can
|
|
// log them — but per package doc, callers should NOT fail user-facing
|
|
// operations on audit-write failures.
|
|
//
|
|
// actorID may be a zero/invalid pgtype.UUID for system actions
|
|
// (e.g. self-registration where the new user is both actor and
|
|
// target — pass them as the same id, or pass invalid for actor and
|
|
// the new user as target).
|
|
func Write(ctx context.Context, pool *pgxpool.Pool, actorID, targetID pgtype.UUID, action Action, metadata map[string]any) error {
|
|
q := dbq.New(pool)
|
|
var jsonMeta []byte
|
|
if metadata != nil {
|
|
b, err := json.Marshal(metadata)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
jsonMeta = b
|
|
}
|
|
return q.WriteAuditLog(ctx, dbq.WriteAuditLogParams{
|
|
ActorID: actorID,
|
|
TargetID: targetID,
|
|
Action: string(action),
|
|
Metadata: jsonMeta,
|
|
})
|
|
}
|
|
|
|
// WriteOrLog writes the audit row; on error, logs at Warn and swallows
|
|
// (audit failures must not break user-facing operations — see package doc).
|
|
// Use this when the audit is observability, not gating; use Write directly
|
|
// when the caller needs strict semantics (e.g. tests).
|
|
func WriteOrLog(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, actorID, targetID pgtype.UUID, action Action, metadata map[string]any) {
|
|
if err := Write(ctx, pool, actorID, targetID, action, metadata); err != nil {
|
|
if logger != nil {
|
|
logger.Warn("audit failed", "action", string(action), "err", err)
|
|
}
|
|
}
|
|
}
|