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

View File

@@ -204,6 +204,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
HealthChecker: healthChecker,
ReadmeFetcher: readmeFetcher,
Templates: uiTemplates,
DefaultHoldDID: defaultHoldDID,
})
}
}

View File

@@ -64,7 +64,7 @@ func main() {
}
// Bootstrap PDS with captain record, hold owner as first crew member, and profile
if err := holdPDS.Bootstrap(ctx, driver, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew, cfg.Registration.ProfileAvatarURL); err != nil {
if err := holdPDS.Bootstrap(ctx, driver, cfg.Registration.OwnerDID, cfg.Server.Public, cfg.Registration.AllowAllCrew, cfg.Registration.ProfileAvatarURL, cfg.Registration.Region); err != nil {
slog.Error("Failed to bootstrap PDS", "error", err)
os.Exit(1)
}

304
docs/DIRECT_HOLD_ACCESS.md Normal file
View File

@@ -0,0 +1,304 @@
# Accessing Hold Data Without AppView
This document explains how to retrieve your data directly from a hold service without going through the ATCR AppView. This is useful for:
- GDPR data export requests
- Backup and migration
- Debugging and development
- Building alternative clients
## Quick Start: App Passwords (Recommended)
The simplest way to authenticate is using an ATProto app password. This avoids the complexity of OAuth + DPoP.
### Step 1: Create an App Password
1. Go to your Bluesky settings: https://bsky.app/settings/app-passwords
2. Create a new app password
3. Save it securely (you'll only see it once)
### Step 2: Get a Session Token
```bash
# Replace with your handle and app password
HANDLE="yourhandle.bsky.social"
APP_PASSWORD="xxxx-xxxx-xxxx-xxxx"
# Create session with your PDS
SESSION=$(curl -s -X POST "https://bsky.social/xrpc/com.atproto.server.createSession" \
-H "Content-Type: application/json" \
-d "{\"identifier\": \"$HANDLE\", \"password\": \"$APP_PASSWORD\"}")
# Extract tokens
ACCESS_JWT=$(echo "$SESSION" | jq -r '.accessJwt')
DID=$(echo "$SESSION" | jq -r '.did')
PDS=$(echo "$SESSION" | jq -r '.didDoc.service[0].serviceEndpoint')
echo "DID: $DID"
echo "PDS: $PDS"
```
### Step 3: Get a Service Token for the Hold
```bash
# The hold DID you want to access (e.g., did:web:hold01.atcr.io)
HOLD_DID="did:web:hold01.atcr.io"
# Get a service token from your PDS
SERVICE_TOKEN=$(curl -s -X GET "$PDS/xrpc/com.atproto.server.getServiceAuth?aud=$HOLD_DID" \
-H "Authorization: Bearer $ACCESS_JWT" | jq -r '.token')
echo "Service Token: $SERVICE_TOKEN"
```
### Step 4: Call Hold Endpoints
Now you can call any authenticated hold endpoint with the service token:
```bash
# Export your data from the hold
curl -s "https://hold01.atcr.io/xrpc/io.atcr.hold.exportUserData" \
-H "Authorization: Bearer $SERVICE_TOKEN" | jq .
```
### Complete Script
Here's a complete script that does all the above:
```bash
#!/bin/bash
# export-hold-data.sh - Export your data from an ATCR hold
set -e
# Configuration
HANDLE="${1:-yourhandle.bsky.social}"
APP_PASSWORD="${2:-xxxx-xxxx-xxxx-xxxx}"
HOLD_DID="${3:-did:web:hold01.atcr.io}"
# Default PDS (Bluesky's main PDS)
DEFAULT_PDS="https://bsky.social"
echo "Authenticating as $HANDLE..."
# Step 1: Create session
SESSION=$(curl -s -X POST "$DEFAULT_PDS/xrpc/com.atproto.server.createSession" \
-H "Content-Type: application/json" \
-d "{\"identifier\": \"$HANDLE\", \"password\": \"$APP_PASSWORD\"}")
# Check for errors
if echo "$SESSION" | jq -e '.error' > /dev/null 2>&1; then
echo "Error: $(echo "$SESSION" | jq -r '.message')"
exit 1
fi
ACCESS_JWT=$(echo "$SESSION" | jq -r '.accessJwt')
DID=$(echo "$SESSION" | jq -r '.did')
# Try to get PDS from didDoc, fall back to default
PDS=$(echo "$SESSION" | jq -r '.didDoc.service[] | select(.id == "#atproto_pds") | .serviceEndpoint' 2>/dev/null || echo "$DEFAULT_PDS")
if [ "$PDS" = "null" ] || [ -z "$PDS" ]; then
PDS="$DEFAULT_PDS"
fi
echo "Authenticated as $DID"
echo "PDS: $PDS"
# Step 2: Get service token for the hold
echo "Getting service token for $HOLD_DID..."
SERVICE_RESPONSE=$(curl -s -X GET "$PDS/xrpc/com.atproto.server.getServiceAuth?aud=$HOLD_DID" \
-H "Authorization: Bearer $ACCESS_JWT")
if echo "$SERVICE_RESPONSE" | jq -e '.error' > /dev/null 2>&1; then
echo "Error getting service token: $(echo "$SERVICE_RESPONSE" | jq -r '.message')"
exit 1
fi
SERVICE_TOKEN=$(echo "$SERVICE_RESPONSE" | jq -r '.token')
# Step 3: Resolve hold DID to URL
if [[ "$HOLD_DID" == did:web:* ]]; then
# did:web:example.com -> https://example.com
HOLD_HOST="${HOLD_DID#did:web:}"
HOLD_URL="https://$HOLD_HOST"
else
echo "Error: Only did:web holds are currently supported for direct resolution"
exit 1
fi
echo "Hold URL: $HOLD_URL"
# Step 4: Export data
echo "Exporting data from $HOLD_URL..."
curl -s "$HOLD_URL/xrpc/io.atcr.hold.exportUserData" \
-H "Authorization: Bearer $SERVICE_TOKEN" | jq .
```
Usage:
```bash
chmod +x export-hold-data.sh
./export-hold-data.sh yourhandle.bsky.social xxxx-xxxx-xxxx-xxxx did:web:hold01.atcr.io
```
---
## Available Hold Endpoints
Once you have a service token, you can call these endpoints:
### Data Export (GDPR)
```bash
GET /xrpc/io.atcr.hold.exportUserData
Authorization: Bearer {service_token}
```
Returns all your data stored on that hold:
- Layer records (blobs you've pushed)
- Crew membership status
- Usage statistics
- Whether you're the hold captain
### Quota Information
```bash
GET /xrpc/io.atcr.hold.getQuota?userDid={your_did}
# No auth required - just needs your DID
```
### Blob Download (if you have read access)
```bash
GET /xrpc/com.atproto.sync.getBlob?did={owner_did}&cid={blob_digest}
Authorization: Bearer {service_token}
```
Returns a presigned URL to download the blob directly from storage.
---
## OAuth + DPoP (Advanced)
App passwords are the simplest option, but OAuth with DPoP is the "proper" way to authenticate in ATProto. However, it's significantly more complex because:
1. **DPoP (Demonstrating Proof of Possession)** - Every request requires a cryptographically signed JWT proving you control a specific key
2. **PAR (Pushed Authorization Requests)** - Authorization parameters are sent server-to-server
3. **PKCE (Proof Key for Code Exchange)** - Prevents authorization code interception
### Why DPoP Makes Curl Impractical
Each request requires a fresh DPoP proof JWT with:
- Unique `jti` (request ID)
- Current `iat` timestamp
- HTTP method and URL bound to the request
- Server-provided `nonce`
- Signature using your P-256 private key
Example DPoP proof structure:
```json
{
"alg": "ES256",
"typ": "dpop+jwt",
"jwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." }
}
{
"htm": "GET",
"htu": "https://bsky.social/xrpc/com.atproto.server.getServiceAuth",
"jti": "550e8400-e29b-41d4-a716-446655440000",
"iat": 1735689100,
"nonce": "server-provided-nonce"
}
```
### If You Need OAuth
If you need OAuth (e.g., for a production application), you'll want to use a library:
**Go:**
```go
import "github.com/bluesky-social/indigo/atproto/auth/oauth"
```
**TypeScript/JavaScript:**
```bash
npm install @atproto/oauth-client-node
```
**Python:**
```bash
pip install atproto
```
These libraries handle all the DPoP complexity for you.
### High-Level OAuth Flow
For documentation purposes, here's what the flow looks like:
1. **Resolve identity**: `handle``DID``PDS endpoint`
2. **Discover OAuth server**: `GET {pds}/.well-known/oauth-authorization-server`
3. **Generate DPoP key**: Create P-256 key pair
4. **PAR request**: Send authorization parameters (with DPoP proof)
5. **User authorization**: Browser-based login
6. **Token exchange**: Exchange code for tokens (with DPoP proof)
7. **Use tokens**: All subsequent requests include DPoP proofs
Each step after #3 requires generating a fresh DPoP proof JWT, which is why libraries are essential.
---
## Troubleshooting
### "Invalid token" or "Token expired"
Service tokens are only valid for ~60 seconds. Get a fresh one:
```bash
SERVICE_TOKEN=$(curl -s "$PDS/xrpc/com.atproto.server.getServiceAuth?aud=$HOLD_DID" \
-H "Authorization: Bearer $ACCESS_JWT" | jq -r '.token')
```
### "Session expired"
Your access JWT from `createSession` has expired. Create a new session:
```bash
SESSION=$(curl -s -X POST "$PDS/xrpc/com.atproto.server.createSession" ...)
ACCESS_JWT=$(echo "$SESSION" | jq -r '.accessJwt')
```
### "Audience mismatch"
The service token is scoped to a specific hold. Make sure `HOLD_DID` matches exactly what's in the `aud` claim of your token.
### "Access denied: user is not a crew member"
You don't have access to this hold. You need to either:
- Be the hold captain (owner)
- Be a crew member with appropriate permissions
### Finding Your Hold DID
Check your sailor profile to find your default hold:
```bash
curl -s "https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=$DID&collection=io.atcr.sailor.profile&rkey=self" \
-H "Authorization: Bearer $ACCESS_JWT" | jq -r '.value.defaultHold'
```
Or check your manifest records for the hold where your images are stored:
```bash
curl -s "https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=$DID&collection=io.atcr.manifest&limit=1" \
-H "Authorization: Bearer $ACCESS_JWT" | jq -r '.records[0].value.holdDid'
```
---
## Security Notes
- **App passwords** are scoped tokens that can be revoked without changing your main password
- **Service tokens** are short-lived (60 seconds) and scoped to a specific hold
- **Never share** your app password or access tokens
- Service tokens can only be used for the specific hold they were requested for (`aud` claim)
---
## References
- [ATProto OAuth Specification](https://atproto.com/specs/oauth)
- [DPoP RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449)
- [Bluesky OAuth Guide](https://docs.bsky.app/docs/advanced-guides/oauth-client)
- [ATCR BYOS Documentation](./BYOS.md)

1721
docs/HOLD_DISCOVERY.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -37,11 +37,12 @@ This document lists all XRPC endpoints implemented in the Hold service (`pkg/hol
| `/xrpc/com.atproto.repo.deleteRecord` | POST | Delete a record |
| `/xrpc/com.atproto.repo.uploadBlob` | POST | Upload ATProto blob |
### DPoP Auth Required
### Auth Required (Service Token or DPoP)
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/xrpc/io.atcr.hold.requestCrew` | POST | Request crew membership |
| `/xrpc/io.atcr.hold.exportUserData` | GET | GDPR data export (returns user's records) |
---
@@ -60,6 +61,22 @@ All require `blob:write` permission via service token:
---
## ATCR Hold-Specific Endpoints (`io.atcr.hold.*`)
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/xrpc/io.atcr.hold.initiateUpload` | POST | blob:write | Start multipart upload |
| `/xrpc/io.atcr.hold.getPartUploadUrl` | POST | blob:write | Get presigned URL for part |
| `/xrpc/io.atcr.hold.uploadPart` | PUT | blob:write | Direct buffered part upload |
| `/xrpc/io.atcr.hold.completeUpload` | POST | blob:write | Finalize multipart upload |
| `/xrpc/io.atcr.hold.abortUpload` | POST | blob:write | Cancel multipart upload |
| `/xrpc/io.atcr.hold.notifyManifest` | POST | blob:write | Notify manifest push |
| `/xrpc/io.atcr.hold.requestCrew` | POST | auth | Request crew membership |
| `/xrpc/io.atcr.hold.exportUserData` | GET | auth | GDPR data export |
| `/xrpc/io.atcr.hold.getQuota` | GET | none | Get user quota info |
---
## Standard ATProto Endpoints (excluding io.atcr.hold.*)
| Endpoint |
@@ -82,3 +99,11 @@ All require `blob:write` permission via service token:
| /xrpc/app.bsky.actor.getProfiles |
| /.well-known/did.json |
| /.well-known/atproto-did |
---
## See Also
- [DIRECT_HOLD_ACCESS.md](./DIRECT_HOLD_ACCESS.md) - How to call hold endpoints directly without AppView (app passwords, curl examples)
- [BYOS.md](./BYOS.md) - Bring Your Own Storage architecture
- [OAUTH.md](./OAUTH.md) - OAuth + DPoP authentication details

View File

@@ -36,11 +36,6 @@
"type": "string",
"description": "S3 region where blobs are stored",
"maxLength": 64
},
"provider": {
"type": "string",
"description": "Deployment provider (e.g., fly.io, aws, etc.)",
"maxLength": 64
}
}
}

393
pkg/appview/db/export.go Normal file
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
}

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
}

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)

View File

@@ -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);

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,

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
}

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()
}

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 ""
}

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.
//

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 {

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)

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>

View File

@@ -342,16 +342,12 @@ func (t *CaptainRecord) MarshalCBOR(w io.Writer) error {
}
cw := cbg.NewCborWriter(w)
fieldCount := 8
fieldCount := 7
if t.Region == "" {
fieldCount--
}
if t.Provider == "" {
fieldCount--
}
if _, err := cw.Write(cbg.CborEncodeMajorType(cbg.MajMap, uint64(fieldCount))); err != nil {
return err
}
@@ -444,32 +440,6 @@ func (t *CaptainRecord) MarshalCBOR(w io.Writer) error {
}
}
// t.Provider (string) (string)
if t.Provider != "" {
if len("provider") > 8192 {
return xerrors.Errorf("Value in field \"provider\" was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len("provider"))); err != nil {
return err
}
if _, err := cw.WriteString(string("provider")); err != nil {
return err
}
if len(t.Provider) > 8192 {
return xerrors.Errorf("Value in field t.Provider was too long")
}
if err := cw.WriteMajorTypeHeader(cbg.MajTextString, uint64(len(t.Provider))); err != nil {
return err
}
if _, err := cw.WriteString(string(t.Provider)); err != nil {
return err
}
}
// t.DeployedAt (string) (string)
if len("deployedAt") > 8192 {
return xerrors.Errorf("Value in field \"deployedAt\" was too long")
@@ -619,17 +589,6 @@ func (t *CaptainRecord) UnmarshalCBOR(r io.Reader) (err error) {
t.Region = string(sval)
}
// t.Provider (string) (string)
case "provider":
{
sval, err := cbg.ReadStringWithMax(cr, 8192)
if err != nil {
return err
}
t.Provider = string(sval)
}
// t.DeployedAt (string) (string)
case "deployedAt":

View File

@@ -57,6 +57,12 @@ const (
// Query: userDid={did}
// Response: {"userDid": "...", "uniqueBlobs": 10, "totalSize": 1073741824}
HoldGetQuota = "/xrpc/io.atcr.hold.getQuota"
// HoldExportUserData exports all user data from a hold service (GDPR compliance).
// Method: GET
// Query: userDid={did}
// Response: JSON containing all user data stored by the hold
HoldExportUserData = "/xrpc/io.atcr.hold.exportUserData"
)
// Hold service crew management endpoints (io.atcr.hold.*)

View File

@@ -580,8 +580,7 @@ type CaptainRecord struct {
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // Deployment region (optional)
}
// CrewRecord represents a crew member in the hold

View File

@@ -43,7 +43,7 @@ func TestMain(m *testing.M) {
if err != nil {
panic(err)
}
err = sharedPublicPDS.Bootstrap(ctx, nil, "did:plc:owner123", true, false, "")
err = sharedPublicPDS.Bootstrap(ctx, nil, "did:plc:owner123", true, false, "", "")
if err != nil {
panic(err)
}
@@ -54,7 +54,7 @@ func TestMain(m *testing.M) {
if err != nil {
panic(err)
}
err = sharedPrivatePDS.Bootstrap(ctx, nil, "did:plc:owner123", false, false, "")
err = sharedPrivatePDS.Bootstrap(ctx, nil, "did:plc:owner123", false, false, "", "")
if err != nil {
panic(err)
}
@@ -65,7 +65,7 @@ func TestMain(m *testing.M) {
if err != nil {
panic(err)
}
err = sharedAllowCrewPDS.Bootstrap(ctx, nil, "did:plc:owner123", false, true, "")
err = sharedAllowCrewPDS.Bootstrap(ctx, nil, "did:plc:owner123", false, true, "", "")
if err != nil {
panic(err)
}
@@ -93,7 +93,7 @@ func createTestHoldPDS(t *testing.T, ownerDID string, public bool, allowAllCrew
// Bootstrap with owner if provided
if ownerDID != "" {
err = holdPDS.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "")
err = holdPDS.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "", "")
if err != nil {
t.Fatalf("Failed to bootstrap HoldPDS: %v", err)
}

View File

@@ -144,13 +144,13 @@ type captainRecordWithMeta struct {
// getCachedCaptainRecord retrieves a captain record from database cache
func (a *RemoteHoldAuthorizer) getCachedCaptainRecord(holdDID string) (*captainRecordWithMeta, error) {
query := `
SELECT owner_did, public, allow_all_crew, deployed_at, region, provider, updated_at
SELECT owner_did, public, allow_all_crew, deployed_at, region, updated_at
FROM hold_captain_records
WHERE hold_did = ?
`
var record atproto.CaptainRecord
var deployedAt, region, provider sql.NullString
var deployedAt, region sql.NullString
var updatedAt time.Time
err := a.db.QueryRow(query, holdDID).Scan(
@@ -159,7 +159,6 @@ func (a *RemoteHoldAuthorizer) getCachedCaptainRecord(holdDID string) (*captainR
&record.AllowAllCrew,
&deployedAt,
&region,
&provider,
&updatedAt,
)
@@ -178,9 +177,6 @@ func (a *RemoteHoldAuthorizer) getCachedCaptainRecord(holdDID string) (*captainR
if region.Valid {
record.Region = region.String
}
if provider.Valid {
record.Provider = provider.String
}
return &captainRecordWithMeta{
CaptainRecord: &record,
@@ -193,15 +189,14 @@ func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *at
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
`
@@ -212,7 +207,6 @@ func (a *RemoteHoldAuthorizer) setCachedCaptainRecord(holdDID string, record *at
record.AllowAllCrew,
nullString(record.DeployedAt),
nullString(record.Region),
nullString(record.Provider),
time.Now(),
)
@@ -256,7 +250,6 @@ func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, h
AllowAllCrew bool `json:"allowAllCrew"`
DeployedAt string `json:"deployedAt"`
Region string `json:"region,omitempty"`
Provider string `json:"provider,omitempty"`
} `json:"value"`
}
@@ -272,7 +265,6 @@ func (a *RemoteHoldAuthorizer) fetchCaptainRecordFromXRPC(ctx context.Context, h
AllowAllCrew: xrpcResp.Value.AllowAllCrew,
DeployedAt: xrpcResp.Value.DeployedAt,
Region: xrpcResp.Value.Region,
Provider: xrpcResp.Value.Provider,
}
return record, nil

View File

@@ -129,7 +129,6 @@ func TestGetCaptainRecord_CacheHit(t *testing.T) {
AllowAllCrew: false,
DeployedAt: "2025-10-28T00:00:00Z",
Region: "us-east-1",
Provider: "fly.io",
}
err := remote.setCachedCaptainRecord(holdDID, captainRecord)

View File

@@ -7,8 +7,10 @@ package hold
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"os"
@@ -54,6 +56,9 @@ type RegistrationConfig struct {
// If true, creates posts when users push images
// Synced to captain record's enableBlueskyPosts field on startup
EnableBlueskyPosts bool `yaml:"enable_bluesky_posts"`
// Region is the deployment region, auto-detected from cloud metadata or S3 config
Region string `yaml:"region"`
}
// StorageConfig wraps distribution's storage configuration
@@ -148,6 +153,18 @@ func LoadConfigFromEnv() (*Config, error) {
// Admin panel configuration
cfg.Admin.Enabled = os.Getenv("HOLD_ADMIN_ENABLED") == "true"
// Detect region from cloud metadata or S3 config
if meta, err := DetectCloudMetadata(context.Background()); err == nil && meta != nil {
cfg.Registration.Region = meta.Region
slog.Info("Detected cloud metadata", "region", meta.Region)
} else {
// Fall back to S3 region
if storageType == "s3" {
cfg.Registration.Region = getEnvOrDefault("AWS_REGION", "us-east-1")
slog.Info("Using S3 region", "region", cfg.Registration.Region)
}
}
return cfg, nil
}
@@ -200,6 +217,7 @@ func getEnvOrDefault(key, defaultValue string) string {
return defaultValue
}
// RequestCrawl sends a crawl request to the ATProto relay for the given hostname.
// This makes the hold's PDS discoverable by the relay network.
func RequestCrawl(relayEndpoint, publicURL string) error {

65
pkg/hold/metadata.go Normal file
View File

@@ -0,0 +1,65 @@
package hold
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// CloudMetadata contains region info from cloud metadata service
type CloudMetadata struct {
Region string
}
// DetectCloudMetadata queries the instance metadata service (169.254.169.254)
// Currently supports UpCloud. Others can be added via PR.
func DetectCloudMetadata(ctx context.Context) (*CloudMetadata, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
// Try UpCloud metadata format
if meta, err := detectUpCloud(ctx); err == nil {
return meta, nil
}
// Add other providers here (AWS, GCP, Azure, DigitalOcean, etc.)
// Contributors welcome!
return nil, nil // No metadata available
}
// detectUpCloud queries UpCloud's metadata service
func detectUpCloud(ctx context.Context) (*CloudMetadata, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "http://169.254.169.254/metadata/v1.json", nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("metadata returned %d", resp.StatusCode)
}
var data struct {
CloudName string `json:"cloud_name"`
Region string `json:"region"`
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, err
}
if data.CloudName != "upcloud" {
return nil, fmt.Errorf("not upcloud: %s", data.CloudName)
}
return &CloudMetadata{
Region: data.Region,
}, nil
}

View File

@@ -111,7 +111,7 @@ func setupTestOCIHandler(t *testing.T) (*XRPCHandler, context.Context) {
r, w, _ := os.Pipe()
os.Stdout = w
err = holdPDS.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = holdPDS.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
// Restore stdout
w.Close()

View File

@@ -17,7 +17,7 @@ const (
// CreateCaptainRecord creates the captain record for the hold (first-time only).
// This will FAIL if the captain record already exists. Use UpdateCaptainRecord to modify.
func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool, enableBlueskyPosts bool) (cid.Cid, error) {
func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, public bool, allowAllCrew bool, enableBlueskyPosts bool, region string) (cid.Cid, error) {
captainRecord := &atproto.CaptainRecord{
Type: atproto.CaptainCollection,
Owner: ownerDID,
@@ -25,6 +25,7 @@ func (p *HoldPDS) CreateCaptainRecord(ctx context.Context, ownerDID string, publ
AllowAllCrew: allowAllCrew,
EnableBlueskyPosts: enableBlueskyPosts,
DeployedAt: time.Now().Format(time.RFC3339),
Region: region,
}
// Use repomgr.PutRecord - creates with explicit rkey, fails if already exists

View File

@@ -55,7 +55,7 @@ func setupTestPDSWithBootstrap(t *testing.T, ownerDID string, public, allowAllCr
r, w, _ := os.Pipe()
os.Stdout = w
err := pds.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "")
err := pds.Bootstrap(ctx, nil, ownerDID, public, allowAllCrew, "", "")
w.Close()
os.Stdout = oldStdout
@@ -114,7 +114,7 @@ func TestCreateCaptainRecord(t *testing.T) {
defer pds.Close()
// Create captain record
recordCID, err := pds.CreateCaptainRecord(ctx, tt.ownerDID, tt.public, tt.allowAllCrew, tt.enableBlueskyPosts)
recordCID, err := pds.CreateCaptainRecord(ctx, tt.ownerDID, tt.public, tt.allowAllCrew, tt.enableBlueskyPosts, "")
if err != nil {
t.Fatalf("CreateCaptainRecord failed: %v", err)
}
@@ -164,7 +164,7 @@ func TestGetCaptainRecord(t *testing.T) {
ownerDID := "did:plc:alice123"
// Create captain record
createdCID, err := pds.CreateCaptainRecord(ctx, ownerDID, true, false, false)
createdCID, err := pds.CreateCaptainRecord(ctx, ownerDID, true, false, false, "")
if err != nil {
t.Fatalf("CreateCaptainRecord failed: %v", err)
}
@@ -221,7 +221,7 @@ func TestUpdateCaptainRecord(t *testing.T) {
ownerDID := "did:plc:alice123"
// Create initial captain record (public=false, allowAllCrew=false, enableBlueskyPosts=false)
_, err := pds.CreateCaptainRecord(ctx, ownerDID, false, false, false)
_, err := pds.CreateCaptainRecord(ctx, ownerDID, false, false, false, "")
if err != nil {
t.Fatalf("CreateCaptainRecord failed: %v", err)
}
@@ -343,7 +343,6 @@ func TestCaptainRecord_CBORRoundtrip(t *testing.T) {
AllowAllCrew: true,
DeployedAt: "2025-10-16T12:00:00Z",
Region: "us-west-2",
Provider: "fly.io",
},
},
{
@@ -355,7 +354,6 @@ func TestCaptainRecord_CBORRoundtrip(t *testing.T) {
AllowAllCrew: true,
DeployedAt: "2025-10-16T12:00:00Z",
Region: "",
Provider: "",
},
},
}
@@ -400,9 +398,6 @@ func TestCaptainRecord_CBORRoundtrip(t *testing.T) {
if decoded.Region != tt.record.Region {
t.Errorf("Region mismatch: expected %s, got %s", tt.record.Region, decoded.Region)
}
if decoded.Provider != tt.record.Provider {
t.Errorf("Provider mismatch: expected %s, got %s", tt.record.Provider, decoded.Provider)
}
})
}
}

View File

@@ -212,3 +212,85 @@ func (p *HoldPDS) getCrewTier(ctx context.Context, userDID string) string {
return ""
}
// ListLayerRecordsForUser returns all layer records uploaded by a specific user
// Used for GDPR data export to return all layers a user has pushed to this hold
func (p *HoldPDS) ListLayerRecordsForUser(ctx context.Context, userDID string) ([]*atproto.LayerRecord, error) {
if p.recordsIndex == nil {
return nil, fmt.Errorf("records index not available")
}
// Get session for reading record data
session, err := p.carstore.ReadOnlySession(p.uid)
if err != nil {
return nil, fmt.Errorf("failed to create session: %w", err)
}
head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
if err != nil {
return nil, fmt.Errorf("failed to get repo head: %w", err)
}
if !head.Defined() {
// Empty repo - return empty list
return []*atproto.LayerRecord{}, nil
}
repoHandle, err := repo.OpenRepo(ctx, session, head)
if err != nil {
return nil, fmt.Errorf("failed to open repo: %w", err)
}
var records []*atproto.LayerRecord
// Iterate all layer records via the index
cursor := ""
batchSize := 1000 // Process in batches
for {
indexRecords, nextCursor, err := p.recordsIndex.ListRecords(atproto.LayerCollection, batchSize, cursor, true)
if err != nil {
return nil, fmt.Errorf("failed to list layer records: %w", err)
}
for _, rec := range indexRecords {
// Construct record path and get the record data
recordPath := rec.Collection + "/" + rec.Rkey
_, recBytes, err := repoHandle.GetRecordBytes(ctx, recordPath)
if err != nil {
// Skip records we can't read
continue
}
// Decode the layer record
recordValue, err := lexutil.CborDecodeValue(*recBytes)
if err != nil {
continue
}
layerRecord, ok := recordValue.(*atproto.LayerRecord)
if !ok {
continue
}
// Filter by userDID
if layerRecord.UserDID != userDID {
continue
}
records = append(records, layerRecord)
}
if nextCursor == "" {
break
}
cursor = nextCursor
}
if records == nil {
records = []*atproto.LayerRecord{}
}
return records, nil
}

View File

@@ -308,7 +308,7 @@ func setupTestPDSWithIndex(t *testing.T, ownerDID string) (*HoldPDS, func()) {
}
// Bootstrap with owner
if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil {
if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil {
t.Fatalf("Failed to bootstrap PDS: %v", err)
}

View File

@@ -153,7 +153,7 @@ func (p *HoldPDS) UID() models.Uid {
}
// Bootstrap initializes the hold with the captain record, owner as first crew member, and profile
func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDriver, ownerDID string, public bool, allowAllCrew bool, avatarURL string) error {
func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDriver, ownerDID string, public bool, allowAllCrew bool, avatarURL, region string) error {
if ownerDID == "" {
return nil
}
@@ -185,7 +185,7 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDri
}
// Create captain record (hold ownership and settings)
_, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew, p.enableBlueskyPosts)
_, err = p.CreateCaptainRecord(ctx, ownerDID, public, allowAllCrew, p.enableBlueskyPosts, region)
if err != nil {
return fmt.Errorf("failed to create captain record: %w", err)
}
@@ -193,7 +193,8 @@ func (p *HoldPDS) Bootstrap(ctx context.Context, storageDriver driver.StorageDri
slog.Info("Created captain record",
"public", public,
"allowAllCrew", allowAllCrew,
"enableBlueskyPosts", p.enableBlueskyPosts)
"enableBlueskyPosts", p.enableBlueskyPosts,
"region", region)
// Add hold owner as first crew member with admin role
_, err = p.AddCrewMember(ctx, ownerDID, "admin", []string{"blob:read", "blob:write", "crew:admin"})

View File

@@ -69,7 +69,7 @@ func TestNewHoldPDS_ExistingRepo(t *testing.T) {
// Bootstrap with a captain record
ownerDID := "did:plc:owner123"
if err := pds1.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil {
if err := pds1.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
@@ -129,7 +129,7 @@ func TestBootstrap_NewRepo(t *testing.T) {
publicAccess := true
allowAllCrew := false
err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "")
err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "", "")
if err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
@@ -204,7 +204,7 @@ func TestBootstrap_Idempotent(t *testing.T) {
ownerDID := "did:plc:alice123"
// First bootstrap
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
if err != nil {
t.Fatalf("First bootstrap failed: %v", err)
}
@@ -223,7 +223,7 @@ func TestBootstrap_Idempotent(t *testing.T) {
crewCount1 := len(crew1)
// Second bootstrap (should be idempotent - skip creation)
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
if err != nil {
t.Fatalf("Second bootstrap failed: %v", err)
}
@@ -268,7 +268,7 @@ func TestBootstrap_EmptyOwner(t *testing.T) {
defer pds.Close()
// Bootstrap with empty owner DID (should be no-op)
err = pds.Bootstrap(ctx, nil, "", true, false, "")
err = pds.Bootstrap(ctx, nil, "", true, false, "", "")
if err != nil {
t.Fatalf("Bootstrap with empty owner should not error: %v", err)
}
@@ -302,7 +302,7 @@ func TestLexiconTypeRegistration(t *testing.T) {
// Bootstrap to create captain record
ownerDID := "did:plc:alice123"
if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, ""); err != nil {
if err := pds.Bootstrap(ctx, nil, ownerDID, true, false, "", ""); err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
@@ -355,7 +355,7 @@ func TestBootstrap_DidWebOwner(t *testing.T) {
publicAccess := true
allowAllCrew := false
err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "")
err = pds.Bootstrap(ctx, nil, ownerDID, publicAccess, allowAllCrew, "", "")
if err != nil {
t.Fatalf("Bootstrap failed with did:web owner: %v", err)
}
@@ -414,7 +414,7 @@ func TestBootstrap_MixedDIDs(t *testing.T) {
// Bootstrap with did:plc owner
plcOwner := "did:plc:alice123"
err = pds.Bootstrap(ctx, nil, plcOwner, true, false, "")
err = pds.Bootstrap(ctx, nil, plcOwner, true, false, "", "")
if err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
@@ -509,7 +509,7 @@ func TestBootstrap_CrewWithoutCaptain(t *testing.T) {
}
// Bootstrap should create captain record
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
if err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
@@ -559,7 +559,7 @@ func TestBootstrap_CaptainWithoutCrew(t *testing.T) {
// Create captain record WITHOUT crew (unusual state)
ownerDID := "did:plc:alice123"
_, err = pds.CreateCaptainRecord(ctx, ownerDID, true, false, false)
_, err = pds.CreateCaptainRecord(ctx, ownerDID, true, false, false, "")
if err != nil {
t.Fatalf("CreateCaptainRecord failed: %v", err)
}
@@ -584,7 +584,7 @@ func TestBootstrap_CaptainWithoutCrew(t *testing.T) {
// Bootstrap should be idempotent but notice missing crew
// Currently Bootstrap skips if captain exists, so crew won't be added
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
if err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
@@ -856,7 +856,7 @@ func TestHoldPDS_BackfillRecordsIndex(t *testing.T) {
// Bootstrap to create some records in MST (captain + crew)
ownerDID := "did:plc:testowner"
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
if err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}
@@ -921,7 +921,7 @@ func TestHoldPDS_BackfillRecordsIndex_SkipsWhenSynced(t *testing.T) {
defer pds.Close()
// Bootstrap to create records
err = pds.Bootstrap(ctx, nil, "did:plc:testowner", true, false, "")
err = pds.Bootstrap(ctx, nil, "did:plc:testowner", true, false, "", "")
if err != nil {
t.Fatalf("Bootstrap failed: %v", err)
}

View File

@@ -216,3 +216,26 @@ func (p *HoldPDS) ListStats(ctx context.Context) ([]*atproto.StatsRecord, error)
return stats, nil
}
// ListStatsRecordsForUser returns all stats records where the user is the repository owner
// Used for GDPR data export to return all stats for repositories owned by the user
func (p *HoldPDS) ListStatsRecordsForUser(ctx context.Context, userDID string) ([]*atproto.StatsRecord, error) {
// Get all stats records and filter by ownerDID
allStats, err := p.ListStats(ctx)
if err != nil {
return nil, err
}
var userStats []*atproto.StatsRecord
for _, stat := range allStats {
if stat.OwnerDID == userDID {
userStats = append(userStats, stat)
}
}
if userStats == nil {
userStats = []*atproto.StatsRecord{}
}
return userStats, nil
}

View File

@@ -277,7 +277,7 @@ func TestMain(m *testing.M) {
// Bootstrap once
ownerDID := "did:plc:testowner123"
err = sharedPDS.Bootstrap(sharedCtx, nil, ownerDID, true, false, "")
err = sharedPDS.Bootstrap(sharedCtx, nil, ownerDID, true, false, "", "")
if err != nil {
panic(fmt.Sprintf("Failed to bootstrap shared PDS: %v", err))
}

View File

@@ -195,6 +195,8 @@ func (h *XRPCHandler) RegisterHandlers(r chi.Router) {
r.Group(func(r chi.Router) {
r.Use(h.requireAuth)
r.Post(atproto.HoldRequestCrew, h.HandleRequestCrew)
// GDPR data export endpoint (TODO: implement)
r.Get("/xrpc/io.atcr.hold.exportUserData", h.HandleExportUserData)
})
// Public quota endpoint (no auth - quota is per-user, just needs userDid param)
@@ -1492,3 +1494,139 @@ func (h *XRPCHandler) HandleGetQuota(w http.ResponseWriter, r *http.Request) {
render.JSON(w, r, stats)
}
// HoldUserDataExport represents the GDPR data export from a hold service
type HoldUserDataExport struct {
ExportedAt time.Time `json:"exported_at"`
HoldDID string `json:"hold_did"`
UserDID string `json:"user_did"`
IsCaptain bool `json:"is_captain"`
CrewRecord *CrewExport `json:"crew_record,omitempty"`
LayerRecords []LayerExport `json:"layer_records"`
StatsRecords []StatsExport `json:"stats_records"`
}
// CrewExport represents a sanitized crew record for export
type CrewExport struct {
Role string `json:"role"`
Permissions []string `json:"permissions"`
Tier string `json:"tier,omitempty"`
AddedAt string `json:"added_at"`
}
// LayerExport represents a layer record for export
type LayerExport struct {
Digest string `json:"digest"`
Size int64 `json:"size"`
MediaType string `json:"media_type"`
Manifest string `json:"manifest"`
CreatedAt string `json:"created_at"`
}
// StatsExport represents a stats record for export
type StatsExport struct {
Repository string `json:"repository"`
PullCount int64 `json:"pull_count"`
PushCount int64 `json:"push_count"`
LastPull string `json:"last_pull,omitempty"`
LastPush string `json:"last_push,omitempty"`
UpdatedAt string `json:"updated_at"`
}
// HandleExportUserData handles GDPR data export requests for a specific user.
// This endpoint returns all records stored on this hold's PDS that reference
// the authenticated user's DID.
//
// Returns:
// - io.atcr.hold.layer records where userDid matches
// - io.atcr.hold.crew record for the DID (if exists)
// - io.atcr.hold.stats records where ownerDid matches
// - Whether the user is the hold captain
//
// Authentication: Requires valid service token from user's PDS
func (h *XRPCHandler) HandleExportUserData(w http.ResponseWriter, r *http.Request) {
// Get authenticated user from context
user := getUserFromContext(r)
if user == nil {
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
slog.Info("GDPR data export requested",
"requester_did", user.DID,
"hold_did", h.pds.DID())
export := HoldUserDataExport{
ExportedAt: time.Now().UTC(),
HoldDID: h.pds.DID(),
UserDID: user.DID,
LayerRecords: []LayerExport{},
StatsRecords: []StatsExport{},
}
// Check if user is captain
_, captain, err := h.pds.GetCaptainRecord(r.Context())
if err == nil && captain != nil && captain.Owner == user.DID {
export.IsCaptain = true
}
// Get crew record for user
_, crewRecord, err := h.pds.GetCrewMemberByDID(r.Context(), user.DID)
if err == nil && crewRecord != nil {
export.CrewRecord = &CrewExport{
Role: crewRecord.Role,
Permissions: crewRecord.Permissions,
Tier: crewRecord.Tier,
AddedAt: crewRecord.AddedAt,
}
}
// Get layer records for user
layerRecords, err := h.pds.ListLayerRecordsForUser(r.Context(), user.DID)
if err != nil {
slog.Warn("Failed to get layer records for export",
"user_did", user.DID,
"error", err)
// Continue with empty list - don't fail entire export
} else {
for _, layer := range layerRecords {
export.LayerRecords = append(export.LayerRecords, LayerExport{
Digest: layer.Digest,
Size: layer.Size,
MediaType: layer.MediaType,
Manifest: layer.Manifest,
CreatedAt: layer.CreatedAt,
})
}
}
// Get stats records for user
statsRecords, err := h.pds.ListStatsRecordsForUser(r.Context(), user.DID)
if err != nil {
slog.Warn("Failed to get stats records for export",
"user_did", user.DID,
"error", err)
// Continue with empty list - don't fail entire export
} else {
for _, stat := range statsRecords {
export.StatsRecords = append(export.StatsRecords, StatsExport{
Repository: stat.Repository,
PullCount: stat.PullCount,
PushCount: stat.PushCount,
LastPull: stat.LastPull,
LastPush: stat.LastPush,
UpdatedAt: stat.UpdatedAt,
})
}
}
slog.Info("GDPR data export completed",
"user_did", user.DID,
"hold_did", h.pds.DID(),
"is_captain", export.IsCaptain,
"has_crew_record", export.CrewRecord != nil,
"layer_count", len(export.LayerRecords),
"stats_count", len(export.StatsRecords))
render.JSON(w, r, export)
}

View File

@@ -58,7 +58,7 @@ func setupTestXRPCHandler(t *testing.T) (*XRPCHandler, context.Context) {
r, w, _ := os.Pipe()
os.Stdout = w
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
// Restore stdout
w.Close()
@@ -116,7 +116,7 @@ func setupTestXRPCHandlerWithIndex(t *testing.T) (*XRPCHandler, context.Context)
r, w, _ := os.Pipe()
os.Stdout = w
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
// Restore stdout
w.Close()
@@ -1986,7 +1986,7 @@ func setupTestXRPCHandlerWithBlobs(t *testing.T) (*XRPCHandler, *mockS3Service,
r, w, _ := os.Pipe()
os.Stdout = w
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "", "")
// Restore stdout
w.Close()
@@ -2429,7 +2429,7 @@ func TestRequireOwnerOrCrewAdmin_Authorized(t *testing.T) {
// Clean up - recreate captain record if it was deleted
if w.Code == http.StatusOK {
handler.pds.Bootstrap(ctx, nil, "did:plc:testowner123", true, false, "")
handler.pds.Bootstrap(ctx, nil, "did:plc:testowner123", true, false, "", "")
}
}

View File

@@ -7,6 +7,7 @@
package logging
import (
"fmt"
"io"
"log/slog"
"os"
@@ -56,7 +57,16 @@ func InitLogger(level string) {
levelVar.Set(logLevel)
opts := &slog.HandlerOptions{
Level: levelVar,
Level: levelVar,
AddSource: true,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.SourceKey {
if src, ok := a.Value.Any().(*slog.Source); ok {
a.Value = slog.StringValue(shortenSource(src.File, src.Line))
}
}
return a
},
}
handler := slog.NewTextHandler(os.Stdout, opts)
@@ -127,6 +137,48 @@ func autoRevert() {
"trigger", "auto-revert")
}
// shortenSource shortens file paths for cleaner log output.
// - Our code (atcr.io/): shows pkg/appview/jetstream/processor.go:73
// - Library code (/pkg/mod/): shows indigo/atproto/identity/handle.go:225
// - Other: shows last 3 path components
func shortenSource(file string, line int) string {
// Our code: strip everything up to and including atcr.io/
if idx := strings.Index(file, "atcr.io/"); idx != -1 {
return fmt.Sprintf("%s:%d", file[idx+8:], line) // 8 = len("atcr.io/")
}
// Library code in go mod cache: extract module name + relative path
// Example: /go/pkg/mod/github.com/bluesky-social/indigo@v0.0.0-.../atproto/identity/handle.go
// becomes: indigo/atproto/identity/handle.go:225
if idx := strings.Index(file, "/pkg/mod/"); idx != -1 {
modPath := file[idx+9:] // 9 = len("/pkg/mod/")
if atIdx := strings.Index(modPath, "@"); atIdx != -1 {
// Get module path before @
modFullPath := modPath[:atIdx]
parts := strings.Split(modFullPath, "/")
// Get module name - skip version suffix like "v3" if present
modName := parts[len(parts)-1]
if len(parts) >= 2 && len(modName) >= 2 && modName[0] == 'v' && modName[1] >= '0' && modName[1] <= '9' {
modName = parts[len(parts)-2]
}
// Get path after version
afterAt := modPath[atIdx+1:]
if slashIdx := strings.Index(afterAt, "/"); slashIdx != -1 {
return fmt.Sprintf("%s%s:%d", modName, afterAt[slashIdx:], line)
}
}
}
// Fallback: show last 3 path components
parts := strings.Split(file, "/")
if len(parts) > 3 {
parts = parts[len(parts)-3:]
}
return fmt.Sprintf("%s:%d", strings.Join(parts, "/"), line)
}
func levelToString(l slog.Level) string {
switch l {
case slog.LevelDebug:

View File

@@ -395,3 +395,58 @@ func ExampleSetupTestLogger() {
// cleanup() will restore the original logger when defer runs
}
func TestShortenSource(t *testing.T) {
tests := []struct {
name string
file string
line int
expected string
}{
{
name: "our code",
file: "/app/atcr.io/pkg/appview/jetstream/processor.go",
line: 73,
expected: "pkg/appview/jetstream/processor.go:73",
},
{
name: "indigo library",
file: "/go/pkg/mod/github.com/bluesky-social/indigo@v0.0.0-20251218205144-034a2c019e64/atproto/identity/handle.go",
line: 225,
expected: "indigo/atproto/identity/handle.go:225",
},
{
name: "distribution with v3 suffix",
file: "/go/pkg/mod/github.com/distribution/distribution/v3@v3.0.0-rc.3/registry/storage/driver.go",
line: 123,
expected: "distribution/registry/storage/driver.go:123",
},
{
name: "chi router",
file: "/go/pkg/mod/github.com/go-chi/chi/v5@v5.0.10/mux.go",
line: 42,
expected: "chi/mux.go:42",
},
{
name: "simple module without version suffix",
file: "/go/pkg/mod/github.com/ipfs/go-cid@v0.4.1/cid.go",
line: 99,
expected: "go-cid/cid.go:99",
},
{
name: "fallback - unknown path",
file: "/some/random/path/to/file.go",
line: 10,
expected: "path/to/file.go:10",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := shortenSource(tt.file, tt.line)
if result != tt.expected {
t.Errorf("shortenSource(%q, %d) = %q, want %q", tt.file, tt.line, result, tt.expected)
}
})
}
}