Files
2026-02-10 20:58:24 -06:00

285 lines
7.8 KiB
Go

package db
import (
"database/sql"
"fmt"
"time"
)
// 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"`
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"`
}
// 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 DBTX, 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
// 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 DBTX, 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 DBTX, 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 DBTX, 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 DBTX, 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()
}