285 lines
7.8 KiB
Go
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()
|
|
}
|