mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 01:04:15 +00:00
implement hold discovery dropdown in settings. implement a data privacy export feature
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// UserDataExport represents the GDPR-compliant data export for a user
|
||||
// Contains only data we originate, not cached PDS data
|
||||
type UserDataExport struct {
|
||||
ExportedAt time.Time `json:"exported_at"`
|
||||
ExportVersion string `json:"export_version"`
|
||||
DID string `json:"did"`
|
||||
Devices []DeviceExport `json:"devices"`
|
||||
OAuthSessions []OAuthSessionExport `json:"oauth_sessions"`
|
||||
UISessions []UISessionExport `json:"ui_sessions"`
|
||||
HoldMemberships HoldMembershipsExport `json:"hold_memberships"`
|
||||
KnownHolds KnownHoldsExport `json:"known_holds"`
|
||||
CachedDataNote CachedDataNote `json:"cached_data_note"`
|
||||
}
|
||||
|
||||
// DeviceExport is a sanitized device record (no secret hash)
|
||||
type DeviceExport struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IPAddress string `json:"ip_address"`
|
||||
Location string `json:"location,omitempty"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsed *time.Time `json:"last_used,omitempty"`
|
||||
}
|
||||
|
||||
// OAuthSessionExport is a sanitized OAuth session record (no tokens)
|
||||
type OAuthSessionExport struct {
|
||||
SessionID string `json:"session_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// UISessionExport is a sanitized UI session record
|
||||
type UISessionExport struct {
|
||||
ID string `json:"id"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// HoldMembershipsExport contains hold approval and denial records
|
||||
type HoldMembershipsExport struct {
|
||||
Approvals []HoldApprovalExport `json:"approvals"`
|
||||
Denials []HoldDenialExport `json:"denials"`
|
||||
}
|
||||
|
||||
// HoldApprovalExport represents a hold crew approval
|
||||
type HoldApprovalExport struct {
|
||||
HoldDID string `json:"hold_did"`
|
||||
ApprovedAt time.Time `json:"approved_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// HoldDenialExport represents a hold crew denial (rate limiting)
|
||||
type HoldDenialExport struct {
|
||||
HoldDID string `json:"hold_did"`
|
||||
DenialCount int `json:"denial_count"`
|
||||
NextRetryAt time.Time `json:"next_retry_at"`
|
||||
LastDeniedAt time.Time `json:"last_denied_at"`
|
||||
}
|
||||
|
||||
// KnownHoldsExport lists holds where the user has interacted
|
||||
type KnownHoldsExport struct {
|
||||
Note string `json:"note"`
|
||||
Holds []KnownHoldExport `json:"holds"`
|
||||
}
|
||||
|
||||
// KnownHoldExport represents a hold the user has interacted with
|
||||
type KnownHoldExport struct {
|
||||
HoldDID string `json:"hold_did"`
|
||||
Relationship string `json:"relationship"` // "captain", "crew_member"
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
ExportEndpoint string `json:"export_endpoint"`
|
||||
}
|
||||
|
||||
// CachedDataNote explains what cached data exists and how to access it
|
||||
type CachedDataNote struct {
|
||||
Message string `json:"message"`
|
||||
DeletionNotice string `json:"deletion_notice"`
|
||||
YourPDSCollections []string `json:"your_pds_collections"`
|
||||
HowToAccess string `json:"how_to_access"`
|
||||
}
|
||||
|
||||
// ExportUserData gathers all user data for GDPR export
|
||||
// Only includes data we originate, not cached PDS data
|
||||
func ExportUserData(db *sql.DB, did string) (*UserDataExport, error) {
|
||||
export := &UserDataExport{
|
||||
ExportedAt: time.Now().UTC(),
|
||||
ExportVersion: "1.0",
|
||||
DID: did,
|
||||
}
|
||||
|
||||
// Get devices (sanitized - no secret hash)
|
||||
devices, err := getDevicesForExport(db, did)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get devices: %w", err)
|
||||
}
|
||||
export.Devices = devices
|
||||
|
||||
// Get OAuth sessions (sanitized - no tokens)
|
||||
oauthSessions, err := getOAuthSessionsForExport(db, did)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get OAuth sessions: %w", err)
|
||||
}
|
||||
export.OAuthSessions = oauthSessions
|
||||
|
||||
// Get UI sessions
|
||||
uiSessions, err := getUISessionsForExport(db, did)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get UI sessions: %w", err)
|
||||
}
|
||||
export.UISessions = uiSessions
|
||||
|
||||
// Get hold memberships (approvals and denials)
|
||||
memberships, err := getHoldMembershipsForExport(db, did)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get hold memberships: %w", err)
|
||||
}
|
||||
export.HoldMemberships = memberships
|
||||
|
||||
// Get known holds (where user is captain or crew)
|
||||
knownHolds, err := getKnownHoldsForExport(db, did)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get known holds: %w", err)
|
||||
}
|
||||
export.KnownHolds = knownHolds
|
||||
|
||||
// Add cached data note
|
||||
export.CachedDataNote = CachedDataNote{
|
||||
Message: "We cache data from your PDS for performance. This cached data is NOT included in this export as it is under your direct control on your PDS.",
|
||||
DeletionNotice: "If you delete your account, ALL data including cached data will be permanently removed from our servers.",
|
||||
YourPDSCollections: []string{
|
||||
"io.atcr.manifest - Your container image manifests",
|
||||
"io.atcr.tag - Your image tags",
|
||||
"io.atcr.sailor.profile - Your profile preferences",
|
||||
"io.atcr.sailor.star - Your starred repositories",
|
||||
"io.atcr.repo.page - Your repository pages (description, avatar)",
|
||||
},
|
||||
HowToAccess: "Use your PDS provider's tools or ATProto client libraries to export this data directly.",
|
||||
}
|
||||
|
||||
return export, nil
|
||||
}
|
||||
|
||||
// getDevicesForExport retrieves sanitized device records
|
||||
func getDevicesForExport(db *sql.DB, did string) ([]DeviceExport, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, name, ip_address, location, user_agent, created_at, last_used
|
||||
FROM devices
|
||||
WHERE did = ?
|
||||
ORDER BY created_at DESC
|
||||
`, did)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var devices []DeviceExport
|
||||
for rows.Next() {
|
||||
var d DeviceExport
|
||||
var lastUsed sql.NullTime
|
||||
var location sql.NullString
|
||||
|
||||
err := rows.Scan(&d.ID, &d.Name, &d.IPAddress, &location, &d.UserAgent, &d.CreatedAt, &lastUsed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if lastUsed.Valid {
|
||||
d.LastUsed = &lastUsed.Time
|
||||
}
|
||||
if location.Valid {
|
||||
d.Location = location.String
|
||||
}
|
||||
|
||||
devices = append(devices, d)
|
||||
}
|
||||
|
||||
if devices == nil {
|
||||
devices = []DeviceExport{}
|
||||
}
|
||||
|
||||
return devices, rows.Err()
|
||||
}
|
||||
|
||||
// getOAuthSessionsForExport retrieves sanitized OAuth session records
|
||||
func getOAuthSessionsForExport(db *sql.DB, did string) ([]OAuthSessionExport, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT session_id, created_at, updated_at
|
||||
FROM oauth_sessions
|
||||
WHERE account_did = ?
|
||||
ORDER BY created_at DESC
|
||||
`, did)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []OAuthSessionExport
|
||||
for rows.Next() {
|
||||
var s OAuthSessionExport
|
||||
err := rows.Scan(&s.SessionID, &s.CreatedAt, &s.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
|
||||
if sessions == nil {
|
||||
sessions = []OAuthSessionExport{}
|
||||
}
|
||||
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
// getUISessionsForExport retrieves sanitized UI session records
|
||||
func getUISessionsForExport(db *sql.DB, did string) ([]UISessionExport, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, expires_at, created_at
|
||||
FROM ui_sessions
|
||||
WHERE did = ?
|
||||
ORDER BY created_at DESC
|
||||
`, did)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var sessions []UISessionExport
|
||||
for rows.Next() {
|
||||
var s UISessionExport
|
||||
err := rows.Scan(&s.ID, &s.ExpiresAt, &s.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessions = append(sessions, s)
|
||||
}
|
||||
|
||||
if sessions == nil {
|
||||
sessions = []UISessionExport{}
|
||||
}
|
||||
|
||||
return sessions, rows.Err()
|
||||
}
|
||||
|
||||
// getHoldMembershipsForExport retrieves hold approval and denial records
|
||||
func getHoldMembershipsForExport(db *sql.DB, did string) (HoldMembershipsExport, error) {
|
||||
memberships := HoldMembershipsExport{
|
||||
Approvals: []HoldApprovalExport{},
|
||||
Denials: []HoldDenialExport{},
|
||||
}
|
||||
|
||||
// Get approvals
|
||||
approvalRows, err := db.Query(`
|
||||
SELECT hold_did, approved_at, expires_at
|
||||
FROM hold_crew_approvals
|
||||
WHERE user_did = ?
|
||||
ORDER BY approved_at DESC
|
||||
`, did)
|
||||
if err != nil {
|
||||
return memberships, err
|
||||
}
|
||||
defer approvalRows.Close()
|
||||
|
||||
for approvalRows.Next() {
|
||||
var a HoldApprovalExport
|
||||
err := approvalRows.Scan(&a.HoldDID, &a.ApprovedAt, &a.ExpiresAt)
|
||||
if err != nil {
|
||||
return memberships, err
|
||||
}
|
||||
memberships.Approvals = append(memberships.Approvals, a)
|
||||
}
|
||||
if err := approvalRows.Err(); err != nil {
|
||||
return memberships, err
|
||||
}
|
||||
|
||||
// Get denials
|
||||
denialRows, err := db.Query(`
|
||||
SELECT hold_did, denial_count, next_retry_at, last_denied_at
|
||||
FROM hold_crew_denials
|
||||
WHERE user_did = ?
|
||||
ORDER BY last_denied_at DESC
|
||||
`, did)
|
||||
if err != nil {
|
||||
return memberships, err
|
||||
}
|
||||
defer denialRows.Close()
|
||||
|
||||
for denialRows.Next() {
|
||||
var d HoldDenialExport
|
||||
err := denialRows.Scan(&d.HoldDID, &d.DenialCount, &d.NextRetryAt, &d.LastDeniedAt)
|
||||
if err != nil {
|
||||
return memberships, err
|
||||
}
|
||||
memberships.Denials = append(memberships.Denials, d)
|
||||
}
|
||||
|
||||
return memberships, denialRows.Err()
|
||||
}
|
||||
|
||||
// getKnownHoldsForExport retrieves holds where user is captain or crew member
|
||||
func getKnownHoldsForExport(db *sql.DB, did string) (KnownHoldsExport, error) {
|
||||
known := KnownHoldsExport{
|
||||
Note: "Hold services where you have interacted. Each hold stores its own records about you. Contact each hold directly to export that data.",
|
||||
Holds: []KnownHoldExport{},
|
||||
}
|
||||
|
||||
// Get holds where user is captain
|
||||
captainRows, err := db.Query(`
|
||||
SELECT hold_did, updated_at
|
||||
FROM hold_captain_records
|
||||
WHERE owner_did = ?
|
||||
ORDER BY updated_at DESC
|
||||
`, did)
|
||||
if err != nil {
|
||||
return known, err
|
||||
}
|
||||
defer captainRows.Close()
|
||||
|
||||
for captainRows.Next() {
|
||||
var holdDID string
|
||||
var updatedAt time.Time
|
||||
err := captainRows.Scan(&holdDID, &updatedAt)
|
||||
if err != nil {
|
||||
return known, err
|
||||
}
|
||||
known.Holds = append(known.Holds, KnownHoldExport{
|
||||
HoldDID: holdDID,
|
||||
Relationship: "captain",
|
||||
FirstSeen: updatedAt,
|
||||
ExportEndpoint: resolveHoldExportEndpoint(holdDID),
|
||||
})
|
||||
}
|
||||
if err := captainRows.Err(); err != nil {
|
||||
return known, err
|
||||
}
|
||||
|
||||
// Get holds where user is crew member
|
||||
crewRows, err := db.Query(`
|
||||
SELECT hold_did, created_at
|
||||
FROM hold_crew_members
|
||||
WHERE member_did = ?
|
||||
ORDER BY created_at DESC
|
||||
`, did)
|
||||
if err != nil {
|
||||
return known, err
|
||||
}
|
||||
defer crewRows.Close()
|
||||
|
||||
for crewRows.Next() {
|
||||
var holdDID string
|
||||
var createdAt time.Time
|
||||
err := crewRows.Scan(&holdDID, &createdAt)
|
||||
if err != nil {
|
||||
return known, err
|
||||
}
|
||||
|
||||
// Check if already added as captain
|
||||
alreadyAdded := false
|
||||
for _, h := range known.Holds {
|
||||
if h.HoldDID == holdDID {
|
||||
alreadyAdded = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !alreadyAdded {
|
||||
known.Holds = append(known.Holds, KnownHoldExport{
|
||||
HoldDID: holdDID,
|
||||
Relationship: "crew_member",
|
||||
FirstSeen: createdAt,
|
||||
ExportEndpoint: resolveHoldExportEndpoint(holdDID),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return known, crewRows.Err()
|
||||
}
|
||||
|
||||
// resolveHoldExportEndpoint converts a hold DID to its export endpoint URL
|
||||
// Uses the shared ResolveHoldURL for did:web resolution
|
||||
func resolveHoldExportEndpoint(holdDID string) string {
|
||||
return atproto.ResolveHoldURL(holdDID) + atproto.HoldExportUserData
|
||||
}
|
||||
+272
-11
@@ -14,7 +14,6 @@ type HoldCaptainRecord struct {
|
||||
AllowAllCrew bool `json:"allowAllCrew"`
|
||||
DeployedAt string `json:"deployedAt"`
|
||||
Region string `json:"region"`
|
||||
Provider string `json:"provider"`
|
||||
UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
|
||||
}
|
||||
|
||||
@@ -23,13 +22,13 @@ type HoldCaptainRecord struct {
|
||||
func GetCaptainRecord(db *sql.DB, holdDID string) (*HoldCaptainRecord, error) {
|
||||
query := `
|
||||
SELECT hold_did, owner_did, public, allow_all_crew,
|
||||
deployed_at, region, provider, updated_at
|
||||
deployed_at, region, updated_at
|
||||
FROM hold_captain_records
|
||||
WHERE hold_did = ?
|
||||
`
|
||||
|
||||
var record HoldCaptainRecord
|
||||
var deployedAt, region, provider sql.NullString
|
||||
var deployedAt, region sql.NullString
|
||||
|
||||
err := db.QueryRow(query, holdDID).Scan(
|
||||
&record.HoldDID,
|
||||
@@ -38,7 +37,6 @@ func GetCaptainRecord(db *sql.DB, holdDID string) (*HoldCaptainRecord, error) {
|
||||
&record.AllowAllCrew,
|
||||
&deployedAt,
|
||||
®ion,
|
||||
&provider,
|
||||
&record.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -57,9 +55,6 @@ func GetCaptainRecord(db *sql.DB, holdDID string) (*HoldCaptainRecord, error) {
|
||||
if region.Valid {
|
||||
record.Region = region.String
|
||||
}
|
||||
if provider.Valid {
|
||||
record.Provider = provider.String
|
||||
}
|
||||
|
||||
return &record, nil
|
||||
}
|
||||
@@ -69,15 +64,14 @@ func UpsertCaptainRecord(db *sql.DB, record *HoldCaptainRecord) error {
|
||||
query := `
|
||||
INSERT INTO hold_captain_records (
|
||||
hold_did, owner_did, public, allow_all_crew,
|
||||
deployed_at, region, provider, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
deployed_at, region, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(hold_did) DO UPDATE SET
|
||||
owner_did = excluded.owner_did,
|
||||
public = excluded.public,
|
||||
allow_all_crew = excluded.allow_all_crew,
|
||||
deployed_at = excluded.deployed_at,
|
||||
region = excluded.region,
|
||||
provider = excluded.provider,
|
||||
updated_at = excluded.updated_at
|
||||
`
|
||||
|
||||
@@ -88,7 +82,6 @@ func UpsertCaptainRecord(db *sql.DB, record *HoldCaptainRecord) error {
|
||||
record.AllowAllCrew,
|
||||
nullString(record.DeployedAt),
|
||||
nullString(record.Region),
|
||||
nullString(record.Provider),
|
||||
record.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -136,3 +129,271 @@ func nullString(s string) sql.NullString {
|
||||
}
|
||||
return sql.NullString{String: s, Valid: true}
|
||||
}
|
||||
|
||||
// GetCaptainRecordsForOwner retrieves all captain records where the user is the owner
|
||||
// Used for GDPR export to find all holds owned by a user
|
||||
func GetCaptainRecordsForOwner(db *sql.DB, ownerDID string) ([]*HoldCaptainRecord, error) {
|
||||
query := `
|
||||
SELECT hold_did, owner_did, public, allow_all_crew,
|
||||
deployed_at, region, updated_at
|
||||
FROM hold_captain_records
|
||||
WHERE owner_did = ?
|
||||
ORDER BY updated_at DESC
|
||||
`
|
||||
|
||||
rows, err := db.Query(query, ownerDID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query captain records for owner: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []*HoldCaptainRecord
|
||||
for rows.Next() {
|
||||
var record HoldCaptainRecord
|
||||
var deployedAt, region sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&record.HoldDID,
|
||||
&record.OwnerDID,
|
||||
&record.Public,
|
||||
&record.AllowAllCrew,
|
||||
&deployedAt,
|
||||
®ion,
|
||||
&record.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan captain record: %w", err)
|
||||
}
|
||||
|
||||
if deployedAt.Valid {
|
||||
record.DeployedAt = deployedAt.String
|
||||
}
|
||||
if region.Valid {
|
||||
record.Region = region.String
|
||||
}
|
||||
|
||||
records = append(records, &record)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating captain records: %w", err)
|
||||
}
|
||||
|
||||
if records == nil {
|
||||
records = []*HoldCaptainRecord{}
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// DeleteCaptainRecord removes a captain record from the cache
|
||||
func DeleteCaptainRecord(db *sql.DB, holdDID string) error {
|
||||
// Note: hold_crew_members doesn't have CASCADE, so delete crew first
|
||||
_, err := db.Exec(`DELETE FROM hold_crew_members WHERE hold_did = ?`, holdDID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete crew members for hold: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`DELETE FROM hold_captain_records WHERE hold_did = ?`, holdDID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete captain record: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CrewMember represents a cached crew membership from Jetstream
|
||||
type CrewMember struct {
|
||||
HoldDID string
|
||||
MemberDID string
|
||||
Rkey string
|
||||
Role string
|
||||
Permissions string // JSON array
|
||||
Tier string
|
||||
AddedAt string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// UpsertCrewMember inserts or updates a crew member record
|
||||
func UpsertCrewMember(db *sql.DB, member *CrewMember) error {
|
||||
query := `
|
||||
INSERT INTO hold_crew_members (
|
||||
hold_did, member_did, rkey, role, permissions, tier, added_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(hold_did, member_did) DO UPDATE SET
|
||||
rkey = excluded.rkey,
|
||||
role = excluded.role,
|
||||
permissions = excluded.permissions,
|
||||
tier = excluded.tier,
|
||||
added_at = excluded.added_at,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`
|
||||
|
||||
_, err := db.Exec(query,
|
||||
member.HoldDID,
|
||||
member.MemberDID,
|
||||
member.Rkey,
|
||||
nullString(member.Role),
|
||||
nullString(member.Permissions),
|
||||
nullString(member.Tier),
|
||||
nullString(member.AddedAt),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upsert crew member: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCrewMemberByRkey removes a crew member by rkey (for delete events from Jetstream)
|
||||
func DeleteCrewMemberByRkey(db *sql.DB, holdDID, rkey string) error {
|
||||
_, err := db.Exec(`DELETE FROM hold_crew_members WHERE hold_did = ? AND rkey = ?`, holdDID, rkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete crew member by rkey: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AvailableHold represents a hold available to a user, with membership info
|
||||
type AvailableHold struct {
|
||||
HoldDID string
|
||||
OwnerDID string
|
||||
Public bool
|
||||
AllowAllCrew bool
|
||||
Region string
|
||||
Membership string // "owner", "crew", "eligible", "public"
|
||||
Permissions string // JSON array (only for crew)
|
||||
}
|
||||
|
||||
// GetAvailableHolds returns all holds available to a user, grouped by membership type
|
||||
// Results are ordered: owner first, then crew, then eligible, then public
|
||||
func GetAvailableHolds(db *sql.DB, userDID string) ([]AvailableHold, error) {
|
||||
query := `
|
||||
SELECT
|
||||
h.hold_did,
|
||||
h.owner_did,
|
||||
h.public,
|
||||
h.allow_all_crew,
|
||||
h.region,
|
||||
CASE
|
||||
WHEN h.owner_did = ?1 THEN 'owner'
|
||||
WHEN c.member_did IS NOT NULL THEN 'crew'
|
||||
WHEN h.allow_all_crew = 1 THEN 'eligible'
|
||||
WHEN h.public = 1 THEN 'public'
|
||||
ELSE 'none'
|
||||
END as membership,
|
||||
c.permissions
|
||||
FROM hold_captain_records h
|
||||
LEFT JOIN hold_crew_members c ON h.hold_did = c.hold_did AND c.member_did = ?1
|
||||
WHERE h.public = 1
|
||||
OR h.allow_all_crew = 1
|
||||
OR h.owner_did = ?1
|
||||
OR c.member_did IS NOT NULL
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN h.owner_did = ?1 THEN 0
|
||||
WHEN c.member_did IS NOT NULL THEN 1
|
||||
WHEN h.allow_all_crew = 1 THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
h.hold_did
|
||||
`
|
||||
|
||||
rows, err := db.Query(query, userDID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query available holds: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var holds []AvailableHold
|
||||
for rows.Next() {
|
||||
var hold AvailableHold
|
||||
var region, permissions sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&hold.HoldDID,
|
||||
&hold.OwnerDID,
|
||||
&hold.Public,
|
||||
&hold.AllowAllCrew,
|
||||
®ion,
|
||||
&hold.Membership,
|
||||
&permissions,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan available hold: %w", err)
|
||||
}
|
||||
|
||||
if region.Valid {
|
||||
hold.Region = region.String
|
||||
}
|
||||
if permissions.Valid {
|
||||
hold.Permissions = permissions.String
|
||||
}
|
||||
|
||||
holds = append(holds, hold)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating available holds: %w", err)
|
||||
}
|
||||
|
||||
return holds, nil
|
||||
}
|
||||
|
||||
// GetCrewMemberships returns all holds where a user is a crew member
|
||||
func GetCrewMemberships(db *sql.DB, memberDID string) ([]CrewMember, error) {
|
||||
query := `
|
||||
SELECT hold_did, member_did, rkey, role, permissions, tier, added_at, created_at, updated_at
|
||||
FROM hold_crew_members
|
||||
WHERE member_did = ?
|
||||
ORDER BY added_at DESC
|
||||
`
|
||||
|
||||
rows, err := db.Query(query, memberDID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query crew memberships: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var memberships []CrewMember
|
||||
for rows.Next() {
|
||||
var m CrewMember
|
||||
var role, permissions, tier, addedAt sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&m.HoldDID,
|
||||
&m.MemberDID,
|
||||
&m.Rkey,
|
||||
&role,
|
||||
&permissions,
|
||||
&tier,
|
||||
&addedAt,
|
||||
&m.CreatedAt,
|
||||
&m.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan crew membership: %w", err)
|
||||
}
|
||||
|
||||
if role.Valid {
|
||||
m.Role = role.String
|
||||
}
|
||||
if permissions.Valid {
|
||||
m.Permissions = permissions.String
|
||||
}
|
||||
if tier.Valid {
|
||||
m.Tier = tier.String
|
||||
}
|
||||
if addedAt.Valid {
|
||||
m.AddedAt = addedAt.String
|
||||
}
|
||||
|
||||
memberships = append(memberships, m)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating crew memberships: %w", err)
|
||||
}
|
||||
|
||||
return memberships, nil
|
||||
}
|
||||
|
||||
@@ -103,7 +103,6 @@ func TestGetCaptainRecord(t *testing.T) {
|
||||
AllowAllCrew: false,
|
||||
DeployedAt: "2025-01-15",
|
||||
Region: "us-west-2",
|
||||
Provider: "aws",
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -159,9 +158,6 @@ func TestGetCaptainRecord(t *testing.T) {
|
||||
if record.Region != testRecord.Region {
|
||||
t.Errorf("Region = %v, want %v", record.Region, testRecord.Region)
|
||||
}
|
||||
if record.Provider != testRecord.Provider {
|
||||
t.Errorf("Provider = %v, want %v", record.Provider, testRecord.Provider)
|
||||
}
|
||||
} else {
|
||||
if record != nil {
|
||||
t.Errorf("Expected nil, got record: %+v", record)
|
||||
@@ -183,7 +179,6 @@ func TestGetCaptainRecord_NullableFields(t *testing.T) {
|
||||
AllowAllCrew: true,
|
||||
DeployedAt: "", // Empty - should be NULL
|
||||
Region: "", // Empty - should be NULL
|
||||
Provider: "", // Empty - should be NULL
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -207,9 +202,6 @@ func TestGetCaptainRecord_NullableFields(t *testing.T) {
|
||||
if record.Region != "" {
|
||||
t.Errorf("Region = %v, want empty string", record.Region)
|
||||
}
|
||||
if record.Provider != "" {
|
||||
t.Errorf("Provider = %v, want empty string", record.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpsertCaptainRecord_Insert tests inserting new records
|
||||
@@ -223,7 +215,6 @@ func TestUpsertCaptainRecord_Insert(t *testing.T) {
|
||||
AllowAllCrew: true,
|
||||
DeployedAt: "2025-02-01",
|
||||
Region: "eu-west-1",
|
||||
Provider: "gcp",
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -262,7 +253,6 @@ func TestUpsertCaptainRecord_Update(t *testing.T) {
|
||||
AllowAllCrew: false,
|
||||
DeployedAt: "2025-01-01",
|
||||
Region: "us-east-1",
|
||||
Provider: "aws",
|
||||
UpdatedAt: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
|
||||
@@ -279,7 +269,6 @@ func TestUpsertCaptainRecord_Update(t *testing.T) {
|
||||
AllowAllCrew: true, // Changed allow all crew
|
||||
DeployedAt: "2025-03-01", // Changed date
|
||||
Region: "ap-south-1", // Changed region
|
||||
Provider: "azure", // Changed provider
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -313,9 +302,6 @@ func TestUpsertCaptainRecord_Update(t *testing.T) {
|
||||
if retrieved.Region != updatedRecord.Region {
|
||||
t.Errorf("Region = %v, want %v", retrieved.Region, updatedRecord.Region)
|
||||
}
|
||||
if retrieved.Provider != updatedRecord.Provider {
|
||||
t.Errorf("Provider = %v, want %v", retrieved.Provider, updatedRecord.Provider)
|
||||
}
|
||||
|
||||
// Verify there's still only one record in the database
|
||||
holds, err := ListHoldDIDs(db)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
description: Add hold_crew_members table for cached crew memberships from Jetstream
|
||||
query: |
|
||||
-- Cached hold crew memberships from Jetstream
|
||||
-- Enables reverse lookup: "which holds is user X a member of?"
|
||||
CREATE TABLE IF NOT EXISTS hold_crew_members (
|
||||
hold_did TEXT NOT NULL,
|
||||
member_did TEXT NOT NULL,
|
||||
rkey TEXT NOT NULL,
|
||||
role TEXT,
|
||||
permissions TEXT, -- JSON array
|
||||
tier TEXT,
|
||||
added_at TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (hold_did, member_did)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_hold_crew_member ON hold_crew_members(member_did);
|
||||
CREATE INDEX IF NOT EXISTS idx_hold_crew_hold ON hold_crew_members(hold_did);
|
||||
CREATE INDEX IF NOT EXISTS idx_hold_crew_rkey ON hold_crew_members(hold_did, rkey);
|
||||
@@ -183,7 +183,6 @@ CREATE TABLE IF NOT EXISTS hold_captain_records (
|
||||
allow_all_crew BOOLEAN NOT NULL,
|
||||
deployed_at TEXT,
|
||||
region TEXT,
|
||||
provider TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
|
||||
@@ -207,6 +206,24 @@ CREATE TABLE IF NOT EXISTS hold_crew_denials (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at);
|
||||
|
||||
-- Cached hold crew memberships from Jetstream
|
||||
-- Enables reverse lookup: "which holds is user X a member of?"
|
||||
CREATE TABLE IF NOT EXISTS hold_crew_members (
|
||||
hold_did TEXT NOT NULL,
|
||||
member_did TEXT NOT NULL,
|
||||
rkey TEXT NOT NULL,
|
||||
role TEXT,
|
||||
permissions TEXT, -- JSON array
|
||||
tier TEXT,
|
||||
added_at TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (hold_did, member_did)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_hold_crew_member ON hold_crew_members(member_did);
|
||||
CREATE INDEX IF NOT EXISTS idx_hold_crew_hold ON hold_crew_members(hold_did);
|
||||
CREATE INDEX IF NOT EXISTS idx_hold_crew_rkey ON hold_crew_members(hold_did, rkey);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS repo_pages (
|
||||
did TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
)
|
||||
|
||||
// HoldExportResult represents the result of fetching export from a hold
|
||||
type HoldExportResult struct {
|
||||
HoldDID string `json:"hold_did"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Status string `json:"status"` // "success", "failed", "offline"
|
||||
Error string `json:"error,omitempty"`
|
||||
Data json.RawMessage `json:"data,omitempty"` // Raw JSON from hold
|
||||
}
|
||||
|
||||
// FullUserDataExport represents the complete GDPR export including hold data
|
||||
type FullUserDataExport struct {
|
||||
AppViewData *db.UserDataExport `json:"appview_data"`
|
||||
HoldExports []HoldExportResult `json:"hold_exports"`
|
||||
}
|
||||
|
||||
// ExportUserDataHandler handles GDPR data export requests
|
||||
type ExportUserDataHandler struct {
|
||||
DB *sql.DB
|
||||
Refresher *oauth.Refresher
|
||||
}
|
||||
|
||||
func (h *ExportUserDataHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Get authenticated user from middleware
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Processing data export request", "component", "export", "did", user.DID)
|
||||
|
||||
// Export all user data from database
|
||||
appViewData, err := db.ExportUserData(h.DB, user.DID)
|
||||
if err != nil {
|
||||
slog.Error("Failed to export user data", "component", "export", "did", user.DID, "error", err)
|
||||
http.Error(w, "Failed to export data", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Get all holds where user is a member (from cached crew memberships)
|
||||
holdExports := h.fetchHoldExports(r.Context(), user)
|
||||
|
||||
// Build full export
|
||||
fullExport := FullUserDataExport{
|
||||
AppViewData: appViewData,
|
||||
HoldExports: holdExports,
|
||||
}
|
||||
|
||||
// Set headers for file download
|
||||
filename := fmt.Sprintf("atcr-data-export-%s.json", time.Now().Format("2006-01-02"))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
|
||||
// Write JSON with indentation for readability
|
||||
encoder := json.NewEncoder(w)
|
||||
encoder.SetIndent("", " ")
|
||||
if err := encoder.Encode(fullExport); err != nil {
|
||||
slog.Error("Failed to encode export data", "component", "export", "did", user.DID, "error", err)
|
||||
// Can't send error response at this point, headers already sent
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("Data export completed successfully",
|
||||
"component", "export",
|
||||
"did", user.DID,
|
||||
"hold_count", len(holdExports))
|
||||
}
|
||||
|
||||
// fetchHoldExports fetches export data from all holds where user is a member
|
||||
func (h *ExportUserDataHandler) fetchHoldExports(ctx context.Context, user *db.User) []HoldExportResult {
|
||||
var results []HoldExportResult
|
||||
|
||||
// Get crew memberships from database
|
||||
memberships, err := db.GetCrewMemberships(h.DB, user.DID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get crew memberships for export",
|
||||
"component", "export",
|
||||
"did", user.DID,
|
||||
"error", err)
|
||||
return results
|
||||
}
|
||||
|
||||
if len(memberships) == 0 {
|
||||
return results
|
||||
}
|
||||
|
||||
// Collect unique hold DIDs
|
||||
holdDIDs := make(map[string]bool)
|
||||
for _, m := range memberships {
|
||||
holdDIDs[m.HoldDID] = true
|
||||
}
|
||||
|
||||
// Also check captain records (holds owned by user)
|
||||
if h.DB != nil {
|
||||
captainHolds, err := db.GetCaptainRecordsForOwner(h.DB, user.DID)
|
||||
if err == nil {
|
||||
for _, hold := range captainHolds {
|
||||
holdDIDs[hold.HoldDID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from each hold concurrently with timeout
|
||||
var wg sync.WaitGroup
|
||||
resultChan := make(chan HoldExportResult, len(holdDIDs))
|
||||
|
||||
for holdDID := range holdDIDs {
|
||||
wg.Add(1)
|
||||
go func(holdDID string) {
|
||||
defer wg.Done()
|
||||
result := h.fetchSingleHoldExport(ctx, user, holdDID)
|
||||
resultChan <- result
|
||||
}(holdDID)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
wg.Wait()
|
||||
close(resultChan)
|
||||
|
||||
// Collect results
|
||||
for result := range resultChan {
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// fetchSingleHoldExport fetches export data from a single hold
|
||||
func (h *ExportUserDataHandler) fetchSingleHoldExport(ctx context.Context, user *db.User, holdDID string) HoldExportResult {
|
||||
// Resolve hold DID to URL
|
||||
holdURL := atproto.ResolveHoldURL(holdDID)
|
||||
endpoint := holdURL + "/xrpc/io.atcr.hold.exportUserData"
|
||||
|
||||
result := HoldExportResult{
|
||||
HoldDID: holdDID,
|
||||
Endpoint: endpoint,
|
||||
Status: "failed",
|
||||
}
|
||||
|
||||
// Check if we have OAuth refresher (needed for service tokens)
|
||||
if h.Refresher == nil {
|
||||
result.Error = "OAuth not configured - cannot authenticate to hold"
|
||||
return result
|
||||
}
|
||||
|
||||
// Create context with timeout (5 seconds per hold)
|
||||
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Get service token from user's PDS
|
||||
serviceToken, err := auth.GetOrFetchServiceToken(timeoutCtx, h.Refresher, user.DID, holdDID, user.PDSEndpoint)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get service token for hold export",
|
||||
"component", "export",
|
||||
"hold_did", holdDID,
|
||||
"user_did", user.DID,
|
||||
"error", err)
|
||||
result.Error = fmt.Sprintf("Failed to authenticate: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequestWithContext(timeoutCtx, "GET", endpoint, nil)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("Failed to create request: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Set auth header
|
||||
req.Header.Set("Authorization", "Bearer "+serviceToken)
|
||||
|
||||
// Make request
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Warn("Hold export request failed",
|
||||
"component", "export",
|
||||
"hold_did", holdDID,
|
||||
"endpoint", endpoint,
|
||||
"error", err)
|
||||
result.Status = "offline"
|
||||
result.Error = fmt.Sprintf("Could not contact hold. Please request export directly at: %s", endpoint)
|
||||
return result
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check response status
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
result.Error = fmt.Sprintf("Hold returned status %d: %s", resp.StatusCode, string(body))
|
||||
return result
|
||||
}
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
result.Error = fmt.Sprintf("Failed to read response: %v", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Store raw JSON data
|
||||
result.Status = "success"
|
||||
result.Data = json.RawMessage(body)
|
||||
|
||||
slog.Debug("Successfully fetched hold export",
|
||||
"component", "export",
|
||||
"hold_did", holdDID,
|
||||
"user_did", user.DID)
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,22 +1,38 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
"atcr.io/pkg/appview/storage"
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
)
|
||||
|
||||
// HoldDisplay represents a hold for display in the UI
|
||||
type HoldDisplay struct {
|
||||
DID string `json:"did"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Region string `json:"region"`
|
||||
Membership string `json:"membership"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
// SettingsHandler handles the settings page
|
||||
type SettingsHandler struct {
|
||||
Templates *template.Template
|
||||
Refresher *oauth.Refresher
|
||||
RegistryURL string
|
||||
Templates *template.Template
|
||||
Refresher *oauth.Refresher
|
||||
RegistryURL string
|
||||
DB *sql.DB
|
||||
DefaultHoldDID string
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -47,6 +63,63 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
slog.Debug("Fetched profile", "component", "settings", "did", user.DID, "default_hold", profile.DefaultHold)
|
||||
|
||||
// Get available holds for dropdown
|
||||
var ownedHolds, crewHolds, eligibleHolds, publicHolds []HoldDisplay
|
||||
holdDataMap := make(map[string]HoldDisplay)
|
||||
|
||||
if h.DB != nil {
|
||||
availableHolds, err := db.GetAvailableHolds(h.DB, user.DID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get available holds", "component", "settings", "did", user.DID, "error", err)
|
||||
} else {
|
||||
// Group holds by membership type
|
||||
for _, hold := range availableHolds {
|
||||
display := HoldDisplay{
|
||||
DID: hold.HoldDID,
|
||||
DisplayName: deriveDisplayName(hold.HoldDID),
|
||||
Region: hold.Region,
|
||||
Membership: hold.Membership,
|
||||
}
|
||||
|
||||
// Parse permissions JSON if present
|
||||
if hold.Permissions != "" {
|
||||
json.Unmarshal([]byte(hold.Permissions), &display.Permissions)
|
||||
}
|
||||
|
||||
// Add to data map for JavaScript
|
||||
holdDataMap[hold.HoldDID] = display
|
||||
|
||||
// Group by membership type
|
||||
switch hold.Membership {
|
||||
case "owner":
|
||||
ownedHolds = append(ownedHolds, display)
|
||||
case "crew":
|
||||
crewHolds = append(crewHolds, display)
|
||||
case "eligible":
|
||||
eligibleHolds = append(eligibleHolds, display)
|
||||
case "public":
|
||||
publicHolds = append(publicHolds, display)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize hold data for JavaScript
|
||||
holdDataJSON, _ := json.Marshal(holdDataMap)
|
||||
|
||||
// Check if current hold needs to be shown separately (not in discovered holds)
|
||||
_, currentHoldDiscovered := holdDataMap[profile.DefaultHold]
|
||||
showCurrentHold := profile.DefaultHold != "" && !currentHoldDiscovered
|
||||
|
||||
// Look up AppView default hold details from database
|
||||
appViewDefaultDisplay := deriveDisplayName(h.DefaultHoldDID)
|
||||
var appViewDefaultRegion string
|
||||
if h.DefaultHoldDID != "" && h.DB != nil {
|
||||
if captain, err := db.GetCaptainRecord(h.DB, h.DefaultHoldDID); err == nil && captain != nil {
|
||||
appViewDefaultRegion = captain.Region
|
||||
}
|
||||
}
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Profile struct {
|
||||
@@ -55,8 +128,28 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
PDSEndpoint string
|
||||
DefaultHold string
|
||||
}
|
||||
CurrentHoldDID string
|
||||
CurrentHoldDisplay string
|
||||
ShowCurrentHold bool
|
||||
AppViewDefaultHoldDisplay string
|
||||
AppViewDefaultRegion string
|
||||
OwnedHolds []HoldDisplay
|
||||
CrewHolds []HoldDisplay
|
||||
EligibleHolds []HoldDisplay
|
||||
PublicHolds []HoldDisplay
|
||||
HoldDataJSON template.JS
|
||||
}{
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
CurrentHoldDID: profile.DefaultHold,
|
||||
CurrentHoldDisplay: deriveDisplayName(profile.DefaultHold),
|
||||
ShowCurrentHold: showCurrentHold,
|
||||
AppViewDefaultHoldDisplay: appViewDefaultDisplay,
|
||||
AppViewDefaultRegion: appViewDefaultRegion,
|
||||
OwnedHolds: ownedHolds,
|
||||
CrewHolds: crewHolds,
|
||||
EligibleHolds: eligibleHolds,
|
||||
PublicHolds: publicHolds,
|
||||
HoldDataJSON: template.JS(holdDataJSON),
|
||||
}
|
||||
|
||||
data.Profile.Handle = user.Handle
|
||||
@@ -70,10 +163,31 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// deriveDisplayName derives a human-readable name from a hold DID
|
||||
func deriveDisplayName(did string) string {
|
||||
// For did:web, extract the domain
|
||||
if strings.HasPrefix(did, "did:web:") {
|
||||
domain := strings.TrimPrefix(did, "did:web:")
|
||||
// URL-decode the domain (did:web encodes : as %3A)
|
||||
decoded, err := url.QueryUnescape(domain)
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
return domain
|
||||
}
|
||||
|
||||
// For did:plc, truncate for display
|
||||
if len(did) > 24 {
|
||||
return did[:24] + "..."
|
||||
}
|
||||
return did
|
||||
}
|
||||
|
||||
// UpdateDefaultHoldHandler handles updating the default hold
|
||||
type UpdateDefaultHoldHandler struct {
|
||||
Refresher *oauth.Refresher
|
||||
Templates *template.Template
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -83,7 +197,39 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
holdEndpoint := r.FormValue("hold_endpoint")
|
||||
// Accept hold_did (new dropdown) or hold_endpoint (legacy text input)
|
||||
holdDID := r.FormValue("hold_did")
|
||||
if holdDID == "" {
|
||||
holdDID = r.FormValue("hold_endpoint")
|
||||
}
|
||||
|
||||
// Validate hold DID if provided and database is available
|
||||
if holdDID != "" && h.DB != nil {
|
||||
// Check if user has access to this hold
|
||||
availableHolds, err := db.GetAvailableHolds(h.DB, user.DID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to validate hold access", "component", "settings", "did", user.DID, "error", err)
|
||||
// Don't block - fall through to allow the update
|
||||
} else {
|
||||
hasAccess := false
|
||||
for _, hold := range availableHolds {
|
||||
if hold.HoldDID == holdDID {
|
||||
hasAccess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasAccess {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
h.Templates.ExecuteTemplate(w, "alert", map[string]string{
|
||||
"Class": "error",
|
||||
"Icon": "alert-circle",
|
||||
"Message": "You don't have access to this hold",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
|
||||
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
||||
@@ -92,10 +238,10 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
|
||||
profile, err := storage.GetProfile(r.Context(), client)
|
||||
if err != nil || profile == nil {
|
||||
// Profile doesn't exist, create new one
|
||||
profile = atproto.NewSailorProfileRecord(holdEndpoint)
|
||||
profile = atproto.NewSailorProfileRecord(holdDID)
|
||||
} else {
|
||||
// Update existing profile
|
||||
profile.DefaultHold = holdEndpoint
|
||||
profile.DefaultHold = holdDID
|
||||
profile.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
|
||||
@@ -61,13 +61,18 @@ func NewBackfillWorker(database *sql.DB, relayEndpoint, defaultHoldDID string, t
|
||||
func (b *BackfillWorker) Start(ctx context.Context) error {
|
||||
slog.Info("Backfill: Starting sync-based backfill...")
|
||||
|
||||
// First, query and cache the default hold's captain record
|
||||
// First, query and cache the default hold's captain and crew records
|
||||
// This is necessary for localhost/private holds not discoverable via relay
|
||||
if b.defaultHoldDID != "" {
|
||||
slog.Info("Backfill querying default hold captain record", "hold_did", b.defaultHoldDID)
|
||||
slog.Info("Backfill querying default hold records", "hold_did", b.defaultHoldDID)
|
||||
if err := b.queryCaptainRecord(ctx, b.defaultHoldDID); err != nil {
|
||||
slog.Warn("Backfill failed to query default hold captain record", "error", err)
|
||||
// Don't fail the whole backfill - just warn
|
||||
}
|
||||
if err := b.queryCrewRecords(ctx, b.defaultHoldDID); err != nil {
|
||||
slog.Warn("Backfill failed to query default hold crew records", "error", err)
|
||||
// Don't fail the whole backfill - just warn
|
||||
}
|
||||
}
|
||||
|
||||
collections := []string{
|
||||
@@ -77,6 +82,8 @@ func (b *BackfillWorker) Start(ctx context.Context) error {
|
||||
atproto.SailorProfileCollection, // io.atcr.sailor.profile
|
||||
atproto.RepoPageCollection, // io.atcr.repo.page
|
||||
atproto.StatsCollection, // io.atcr.hold.stats (from holds)
|
||||
atproto.CaptainCollection, // io.atcr.hold.captain (from holds)
|
||||
atproto.CrewCollection, // io.atcr.hold.crew (from holds)
|
||||
}
|
||||
|
||||
for _, collection := range collections {
|
||||
@@ -316,6 +323,16 @@ func (b *BackfillWorker) processRecord(ctx context.Context, did, collection stri
|
||||
// Stats are stored in hold PDSes, not user PDSes
|
||||
// 'did' here is the hold's DID (e.g., did:web:hold01.atcr.io)
|
||||
return b.processor.ProcessStats(ctx, did, record.Value, false)
|
||||
case atproto.CaptainCollection:
|
||||
// Captain records are stored in hold PDSes
|
||||
// 'did' here is the hold's DID (e.g., did:web:hold01.atcr.io)
|
||||
return b.processor.ProcessCaptain(ctx, did, record.Value)
|
||||
case atproto.CrewCollection:
|
||||
// Crew records are stored in hold PDSes
|
||||
// 'did' here is the hold's DID, rkey is derived from member DID
|
||||
// Extract rkey from record URI (at://did/collection/rkey)
|
||||
rkey := extractRkeyFromURI(record.URI)
|
||||
return b.processor.ProcessCrew(ctx, did, rkey, record.Value)
|
||||
default:
|
||||
return fmt.Errorf("unsupported collection: %s", collection)
|
||||
}
|
||||
@@ -391,6 +408,51 @@ func (b *BackfillWorker) queryCaptainRecord(ctx context.Context, holdDID string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryCrewRecords queries a hold's crew records and caches them in the database
|
||||
// This is necessary for localhost/private holds that aren't discoverable via the relay
|
||||
func (b *BackfillWorker) queryCrewRecords(ctx context.Context, holdDID string) error {
|
||||
// Resolve hold DID to URL
|
||||
holdURL := atproto.ResolveHoldURL(holdDID)
|
||||
|
||||
// Create client for hold's PDS
|
||||
holdClient := atproto.NewClient(holdURL, holdDID, "")
|
||||
|
||||
var cursor string
|
||||
recordCount := 0
|
||||
|
||||
// Paginate through all crew records
|
||||
for {
|
||||
records, nextCursor, err := holdClient.ListRecordsForRepo(ctx, holdDID, atproto.CrewCollection, 100, cursor)
|
||||
if err != nil {
|
||||
// If no crew records exist, that's okay
|
||||
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "RecordNotFound") {
|
||||
slog.Debug("No crew records found for hold", "hold_did", holdDID)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to list crew records: %w", err)
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
rkey := extractRkeyFromURI(record.URI)
|
||||
if err := b.processor.ProcessCrew(ctx, holdDID, rkey, record.Value); err != nil {
|
||||
slog.Warn("Backfill failed to process crew record", "hold_did", holdDID, "uri", record.URI, "error", err)
|
||||
continue
|
||||
}
|
||||
recordCount++
|
||||
}
|
||||
|
||||
if nextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
if recordCount > 0 {
|
||||
slog.Info("Backfill cached crew records for hold", "hold_did", holdDID, "count", recordCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileAnnotations ensures annotations come from the newest manifest in each repository
|
||||
// This fixes the out-of-order backfill issue where older manifests can overwrite newer annotations
|
||||
func (b *BackfillWorker) reconcileAnnotations(ctx context.Context, did string, pdsClient *atproto.Client) error {
|
||||
@@ -635,3 +697,14 @@ func (b *BackfillWorker) updateRepoPageInPDS(ctx context.Context, did, pdsEndpoi
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractRkeyFromURI extracts the rkey from an AT-URI
|
||||
// Format: at://did/collection/rkey
|
||||
func extractRkeyFromURI(uri string) string {
|
||||
// URI format: at://did/collection/rkey
|
||||
parts := strings.Split(uri, "/")
|
||||
if len(parts) >= 5 {
|
||||
return parts[4]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -433,6 +433,84 @@ func (p *Processor) ProcessStats(ctx context.Context, holdDID string, recordData
|
||||
})
|
||||
}
|
||||
|
||||
// ProcessCaptain handles captain record events from hold PDSes
|
||||
// This is called when Jetstream receives a captain create/update/delete event from a hold
|
||||
// The holdDID is the DID of the hold PDS (event.DID), and the record contains ownership info
|
||||
func (p *Processor) ProcessCaptain(ctx context.Context, holdDID string, recordData []byte) error {
|
||||
// Unmarshal captain record
|
||||
var captainRecord atproto.CaptainRecord
|
||||
if err := json.Unmarshal(recordData, &captainRecord); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal captain record: %w", err)
|
||||
}
|
||||
|
||||
// Convert to db struct and upsert
|
||||
record := &db.HoldCaptainRecord{
|
||||
HoldDID: holdDID,
|
||||
OwnerDID: captainRecord.Owner,
|
||||
Public: captainRecord.Public,
|
||||
AllowAllCrew: captainRecord.AllowAllCrew,
|
||||
DeployedAt: captainRecord.DeployedAt,
|
||||
Region: captainRecord.Region,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := db.UpsertCaptainRecord(p.db, record); err != nil {
|
||||
return fmt.Errorf("failed to upsert captain record: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Processed captain record",
|
||||
"component", "processor",
|
||||
"hold_did", holdDID,
|
||||
"owner_did", captainRecord.Owner,
|
||||
"public", captainRecord.Public,
|
||||
"allow_all_crew", captainRecord.AllowAllCrew)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessCrew handles crew record events from hold PDSes
|
||||
// This is called when Jetstream receives a crew create/update/delete event from a hold
|
||||
// The holdDID is the DID of the hold PDS (event.DID), and the record contains member info
|
||||
func (p *Processor) ProcessCrew(ctx context.Context, holdDID string, rkey string, recordData []byte) error {
|
||||
// Unmarshal crew record
|
||||
var crewRecord atproto.CrewRecord
|
||||
if err := json.Unmarshal(recordData, &crewRecord); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal crew record: %w", err)
|
||||
}
|
||||
|
||||
// Marshal permissions to JSON string
|
||||
permissionsJSON := ""
|
||||
if len(crewRecord.Permissions) > 0 {
|
||||
if jsonBytes, err := json.Marshal(crewRecord.Permissions); err == nil {
|
||||
permissionsJSON = string(jsonBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to db struct and upsert
|
||||
member := &db.CrewMember{
|
||||
HoldDID: holdDID,
|
||||
MemberDID: crewRecord.Member,
|
||||
Rkey: rkey,
|
||||
Role: crewRecord.Role,
|
||||
Permissions: permissionsJSON,
|
||||
Tier: crewRecord.Tier,
|
||||
AddedAt: crewRecord.AddedAt,
|
||||
}
|
||||
|
||||
if err := db.UpsertCrewMember(p.db, member); err != nil {
|
||||
return fmt.Errorf("failed to upsert crew member: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("Processed crew record",
|
||||
"component", "processor",
|
||||
"hold_did", holdDID,
|
||||
"member_did", crewRecord.Member,
|
||||
"role", crewRecord.Role,
|
||||
"permissions", crewRecord.Permissions)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessAccount handles account status events (deactivation/deletion/etc)
|
||||
// This is called when Jetstream receives an account event indicating status changes.
|
||||
//
|
||||
|
||||
@@ -326,6 +326,12 @@ func (w *Worker) processMessage(message []byte) error {
|
||||
case atproto.StatsCollection:
|
||||
slog.Info("Jetstream processing stats event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey)
|
||||
return w.processStats(commit)
|
||||
case atproto.CaptainCollection:
|
||||
slog.Info("Jetstream processing captain event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey)
|
||||
return w.processCaptain(commit)
|
||||
case atproto.CrewCollection:
|
||||
slog.Info("Jetstream processing crew event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey)
|
||||
return w.processCrew(commit)
|
||||
default:
|
||||
// Ignore other collections
|
||||
return nil
|
||||
@@ -514,6 +520,62 @@ func (w *Worker) processStats(commit *CommitEvent) error {
|
||||
return w.processor.ProcessStats(context.Background(), commit.DID, recordBytes, false)
|
||||
}
|
||||
|
||||
// processCaptain processes a captain record event from a hold's PDS
|
||||
func (w *Worker) processCaptain(commit *CommitEvent) error {
|
||||
holdDID := commit.DID // The repo DID IS the hold DID
|
||||
|
||||
if commit.Operation == "delete" {
|
||||
// Delete captain record - this cascades to crew members
|
||||
if err := db.DeleteCaptainRecord(w.db, holdDID); err != nil {
|
||||
return fmt.Errorf("failed to delete captain record: %w", err)
|
||||
}
|
||||
slog.Info("Deleted captain record for hold", "hold_did", holdDID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse captain record
|
||||
if commit.Record == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Marshal map to bytes for processing
|
||||
recordBytes, err := json.Marshal(commit.Record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal captain record: %w", err)
|
||||
}
|
||||
|
||||
// Use shared processor
|
||||
return w.processor.ProcessCaptain(context.Background(), holdDID, recordBytes)
|
||||
}
|
||||
|
||||
// processCrew processes a crew record event from a hold's PDS
|
||||
func (w *Worker) processCrew(commit *CommitEvent) error {
|
||||
holdDID := commit.DID // The repo DID IS the hold DID
|
||||
|
||||
if commit.Operation == "delete" {
|
||||
// Delete crew member by rkey
|
||||
if err := db.DeleteCrewMemberByRkey(w.db, holdDID, commit.RKey); err != nil {
|
||||
return fmt.Errorf("failed to delete crew member: %w", err)
|
||||
}
|
||||
slog.Info("Deleted crew member from hold", "hold_did", holdDID, "rkey", commit.RKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse crew record
|
||||
if commit.Record == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Marshal map to bytes for processing
|
||||
recordBytes, err := json.Marshal(commit.Record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal crew record: %w", err)
|
||||
}
|
||||
|
||||
// Use shared processor - pass rkey for storage
|
||||
return w.processor.ProcessCrew(context.Background(), holdDID, commit.RKey, recordBytes)
|
||||
}
|
||||
|
||||
// processIdentity processes an identity event (handle change)
|
||||
func (w *Worker) processIdentity(event *JetstreamEvent) error {
|
||||
if event.Identity == nil {
|
||||
|
||||
@@ -29,6 +29,7 @@ type UIDependencies struct {
|
||||
HealthChecker *holdhealth.Checker
|
||||
ReadmeFetcher *readme.Fetcher
|
||||
Templates *template.Template
|
||||
DefaultHoldDID string
|
||||
}
|
||||
|
||||
// RegisterUIRoutes registers all web UI and API routes on the provided router
|
||||
@@ -185,9 +186,11 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
r.Use(middleware.RequireAuth(deps.SessionStore, deps.Database))
|
||||
|
||||
r.Get("/settings", (&uihandlers.SettingsHandler{
|
||||
Templates: deps.Templates,
|
||||
Refresher: deps.Refresher,
|
||||
RegistryURL: registryURL,
|
||||
Templates: deps.Templates,
|
||||
Refresher: deps.Refresher,
|
||||
RegistryURL: registryURL,
|
||||
DB: deps.Database,
|
||||
DefaultHoldDID: deps.DefaultHoldDID,
|
||||
}).ServeHTTP)
|
||||
|
||||
r.Get("/api/storage", (&uihandlers.StorageHandler{
|
||||
@@ -198,6 +201,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{
|
||||
Refresher: deps.Refresher,
|
||||
Templates: deps.Templates,
|
||||
DB: deps.Database,
|
||||
}).ServeHTTP)
|
||||
|
||||
r.Delete("/api/images/{repository}/tags/{tag}", (&uihandlers.DeleteTagHandler{
|
||||
@@ -236,6 +240,12 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
Store: deps.DeviceStore,
|
||||
SessionStore: deps.SessionStore,
|
||||
}).ServeHTTP)
|
||||
|
||||
// GDPR data export
|
||||
r.Get("/api/export-data", (&uihandlers.ExportUserDataHandler{
|
||||
DB: deps.Database,
|
||||
Refresher: deps.Refresher,
|
||||
}).ServeHTTP)
|
||||
})
|
||||
|
||||
// Logout endpoint (supports both GET and POST)
|
||||
|
||||
@@ -39,9 +39,9 @@
|
||||
</section>
|
||||
|
||||
<!-- Default Hold Section -->
|
||||
<section class="settings-section">
|
||||
<section class="settings-section hold-section">
|
||||
<h2>Default Hold</h2>
|
||||
<p>Current: <strong id="current-hold">{{ if .Profile.DefaultHold }}{{ .Profile.DefaultHold }}{{ else }}Not set{{ end }}</strong></p>
|
||||
<p class="help-text">Select where your container images will be stored.</p>
|
||||
|
||||
<form hx-post="/api/profile/default-hold"
|
||||
hx-target="#hold-status"
|
||||
@@ -49,19 +49,78 @@
|
||||
id="hold-form">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="hold-endpoint">Hold Endpoint:</label>
|
||||
<input type="text"
|
||||
id="hold-endpoint"
|
||||
name="hold_endpoint"
|
||||
value="{{ .Profile.DefaultHold }}"
|
||||
placeholder="https://hold.example.com" />
|
||||
<small>Leave empty to use AppView default storage</small>
|
||||
<label for="default-hold">Storage Hold:</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="default-hold" name="hold_did" class="form-select">
|
||||
<option value=""{{ if eq .CurrentHoldDID "" }} selected{{ end }}>AppView Default ({{ .AppViewDefaultHoldDisplay }}{{ if .AppViewDefaultRegion }}, {{ .AppViewDefaultRegion }}{{ end }})</option>
|
||||
|
||||
{{ if .ShowCurrentHold }}
|
||||
<option value="{{ .CurrentHoldDID }}" selected>Current ({{ .CurrentHoldDisplay }})</option>
|
||||
{{ end }}
|
||||
|
||||
{{ if .OwnedHolds }}
|
||||
<optgroup label="Your Holds">
|
||||
{{ range .OwnedHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
|
||||
{{ if .CrewHolds }}
|
||||
<optgroup label="Crew Member">
|
||||
{{ range .CrewHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
|
||||
{{ if .EligibleHolds }}
|
||||
<optgroup label="Open Registration">
|
||||
{{ range .EligibleHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
|
||||
{{ if .PublicHolds }}
|
||||
<optgroup label="Public Holds">
|
||||
{{ range .PublicHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
</select>
|
||||
<i data-lucide="chevron-down" class="select-icon"></i>
|
||||
</div>
|
||||
<small>Your images will be stored on the selected hold</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
</form>
|
||||
|
||||
<div id="hold-status"></div>
|
||||
|
||||
<!-- Hold details panel (shows when hold selected) -->
|
||||
<div id="hold-details" class="hold-details" style="display: none;">
|
||||
<h3>Hold Details</h3>
|
||||
<dl>
|
||||
<dt>DID:</dt>
|
||||
<dd id="hold-did"></dd>
|
||||
<dt>Region:</dt>
|
||||
<dd id="hold-region"></dd>
|
||||
<dt>Your Access:</dt>
|
||||
<dd id="hold-access"></dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- Authorized Devices Section -->
|
||||
@@ -114,25 +173,91 @@
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Data Privacy Section -->
|
||||
<section class="settings-section privacy-section">
|
||||
<h2>Data Privacy</h2>
|
||||
<p>Download a copy of all data we store about you.</p>
|
||||
|
||||
<div class="privacy-actions">
|
||||
<a href="/api/export-data" class="btn-secondary" download>
|
||||
<i data-lucide="download"></i>
|
||||
Export All My Data
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="privacy-note">
|
||||
<small>
|
||||
This includes your authorized devices, sessions, and hold memberships.
|
||||
Data stored on your PDS is already under your control.
|
||||
See our <a href="/privacy">Privacy Policy</a> for details.
|
||||
</small>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Default Hold Update - Dynamic display update
|
||||
// Hold data from server (for details panel)
|
||||
const holdData = {{ .HoldDataJSON }};
|
||||
|
||||
// Hold Selection and Details Display
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const holdSelect = document.getElementById('default-hold');
|
||||
const holdDetails = document.getElementById('hold-details');
|
||||
const holdForm = document.getElementById('hold-form');
|
||||
|
||||
holdForm.addEventListener('htmx:afterSwap', function(event) {
|
||||
// Check if the response contains success indicator
|
||||
if (event.detail.xhr.status === 200) {
|
||||
const holdInput = document.getElementById('hold-endpoint');
|
||||
const currentHoldDisplay = document.getElementById('current-hold');
|
||||
const newValue = holdInput.value.trim();
|
||||
if (holdSelect) {
|
||||
holdSelect.addEventListener('change', function() {
|
||||
const selectedDID = this.value;
|
||||
|
||||
// Update the current hold display
|
||||
currentHoldDisplay.textContent = newValue || 'Not set';
|
||||
if (!selectedDID || !holdData[selectedDID]) {
|
||||
holdDetails.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const hold = holdData[selectedDID];
|
||||
|
||||
document.getElementById('hold-did').textContent = hold.did;
|
||||
document.getElementById('hold-region').textContent = hold.region || 'Unknown';
|
||||
|
||||
// Set access level with badge
|
||||
const accessEl = document.getElementById('hold-access');
|
||||
const accessLabel = {
|
||||
'owner': 'Owner (Full Control)',
|
||||
'crew': 'Crew Member',
|
||||
'eligible': 'Open Registration',
|
||||
'public': 'Public Access'
|
||||
}[hold.membership] || hold.membership;
|
||||
|
||||
const accessClass = 'access-' + hold.membership;
|
||||
accessEl.innerHTML = '<span class="access-badge ' + accessClass + '">' + accessLabel + '</span>';
|
||||
|
||||
// Show permissions for crew members
|
||||
if (hold.membership === 'crew' && hold.permissions && hold.permissions.length > 0) {
|
||||
accessEl.innerHTML += '<br><small>Permissions: ' + hold.permissions.join(', ') + '</small>';
|
||||
}
|
||||
|
||||
holdDetails.style.display = 'block';
|
||||
});
|
||||
|
||||
// Trigger on page load if a hold is already selected
|
||||
if (holdSelect.value) {
|
||||
holdSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// HTMX success handler
|
||||
if (holdForm) {
|
||||
holdForm.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.xhr.status === 200) {
|
||||
// Reinitialize Lucide icons if any were added
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Device Management JavaScript
|
||||
@@ -399,6 +524,140 @@
|
||||
.devices-list {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* Hold Selection Styles */
|
||||
.hold-section .select-wrapper {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
.hold-section .form-select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 2.5rem 0.75rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
}
|
||||
.hold-section .select-icon {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: var(--fg-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hold-section .form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px var(--primary-bg, rgba(59, 130, 246, 0.1));
|
||||
}
|
||||
.hold-section .form-select:focus + .select-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
.hold-section .form-select optgroup {
|
||||
font-weight: bold;
|
||||
color: var(--fg-muted);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.hold-section .form-select option {
|
||||
padding: 0.5rem;
|
||||
font-weight: normal;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* Hold Details Panel */
|
||||
.hold-details {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--code-bg);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.hold-details h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--fg-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.hold-details dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
.hold-details dt {
|
||||
color: var(--fg-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.hold-details dd {
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Access Level Badges */
|
||||
.access-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.access-owner {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.access-crew {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.access-eligible {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
.access-public {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* Privacy Section Styles */
|
||||
.privacy-section .privacy-actions {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.privacy-section .btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--code-bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.privacy-section .btn-secondary:hover {
|
||||
background: var(--border);
|
||||
border-color: var(--fg-muted);
|
||||
}
|
||||
.privacy-section .privacy-note {
|
||||
color: var(--fg-muted);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.privacy-section .privacy-note a {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user