implement hold discovery dropdown in settings. implement a data privacy export feature

This commit is contained in:
Evan Jarrett
2026-01-07 22:41:14 -06:00
parent d4b88b5105
commit 3409af6c67
39 changed files with 4124 additions and 159 deletions
+393
View File
@@ -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
View File
@@ -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,
&region,
&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,
&region,
&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,
&region,
&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
}
-14
View File
@@ -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);
+18 -1
View File
@@ -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,
+230
View File
@@ -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
}
+153 -7
View File
@@ -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()
}
+75 -2
View File
@@ -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 ""
}
+78
View File
@@ -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.
//
+62
View File
@@ -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 {
+13 -3
View File
@@ -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)
+278 -19
View File
@@ -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>