mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-05-02 14:15:51 +00:00
implement hold discovery dropdown in settings. implement a data privacy export feature
This commit is contained in:
@@ -204,6 +204,7 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
HealthChecker: healthChecker,
|
||||
ReadmeFetcher: readmeFetcher,
|
||||
Templates: uiTemplates,
|
||||
DefaultHoldDID: defaultHoldDID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
304
docs/DIRECT_HOLD_ACCESS.md
Normal 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
1721
docs/HOLD_DISCOVERY.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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
393
pkg/appview/db/export.go
Normal 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
|
||||
}
|
||||
@@ -14,7 +14,6 @@ type HoldCaptainRecord struct {
|
||||
AllowAllCrew bool `json:"allowAllCrew"`
|
||||
DeployedAt string `json:"deployedAt"`
|
||||
Region string `json:"region"`
|
||||
Provider string `json:"provider"`
|
||||
UpdatedAt time.Time `json:"-"` // Set manually, not from JSON
|
||||
}
|
||||
|
||||
@@ -23,13 +22,13 @@ type HoldCaptainRecord struct {
|
||||
func GetCaptainRecord(db *sql.DB, holdDID string) (*HoldCaptainRecord, error) {
|
||||
query := `
|
||||
SELECT hold_did, owner_did, public, allow_all_crew,
|
||||
deployed_at, region, provider, updated_at
|
||||
deployed_at, region, updated_at
|
||||
FROM hold_captain_records
|
||||
WHERE hold_did = ?
|
||||
`
|
||||
|
||||
var record HoldCaptainRecord
|
||||
var deployedAt, region, provider sql.NullString
|
||||
var deployedAt, region sql.NullString
|
||||
|
||||
err := db.QueryRow(query, holdDID).Scan(
|
||||
&record.HoldDID,
|
||||
@@ -38,7 +37,6 @@ func GetCaptainRecord(db *sql.DB, holdDID string) (*HoldCaptainRecord, error) {
|
||||
&record.AllowAllCrew,
|
||||
&deployedAt,
|
||||
®ion,
|
||||
&provider,
|
||||
&record.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -57,9 +55,6 @@ func GetCaptainRecord(db *sql.DB, holdDID string) (*HoldCaptainRecord, error) {
|
||||
if region.Valid {
|
||||
record.Region = region.String
|
||||
}
|
||||
if provider.Valid {
|
||||
record.Provider = provider.String
|
||||
}
|
||||
|
||||
return &record, nil
|
||||
}
|
||||
@@ -69,15 +64,14 @@ func UpsertCaptainRecord(db *sql.DB, record *HoldCaptainRecord) error {
|
||||
query := `
|
||||
INSERT INTO hold_captain_records (
|
||||
hold_did, owner_did, public, allow_all_crew,
|
||||
deployed_at, region, provider, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
deployed_at, region, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(hold_did) DO UPDATE SET
|
||||
owner_did = excluded.owner_did,
|
||||
public = excluded.public,
|
||||
allow_all_crew = excluded.allow_all_crew,
|
||||
deployed_at = excluded.deployed_at,
|
||||
region = excluded.region,
|
||||
provider = excluded.provider,
|
||||
updated_at = excluded.updated_at
|
||||
`
|
||||
|
||||
@@ -88,7 +82,6 @@ func UpsertCaptainRecord(db *sql.DB, record *HoldCaptainRecord) error {
|
||||
record.AllowAllCrew,
|
||||
nullString(record.DeployedAt),
|
||||
nullString(record.Region),
|
||||
nullString(record.Provider),
|
||||
record.UpdatedAt,
|
||||
)
|
||||
|
||||
@@ -136,3 +129,271 @@ func nullString(s string) sql.NullString {
|
||||
}
|
||||
return sql.NullString{String: s, Valid: true}
|
||||
}
|
||||
|
||||
// GetCaptainRecordsForOwner retrieves all captain records where the user is the owner
|
||||
// Used for GDPR export to find all holds owned by a user
|
||||
func GetCaptainRecordsForOwner(db *sql.DB, ownerDID string) ([]*HoldCaptainRecord, error) {
|
||||
query := `
|
||||
SELECT hold_did, owner_did, public, allow_all_crew,
|
||||
deployed_at, region, updated_at
|
||||
FROM hold_captain_records
|
||||
WHERE owner_did = ?
|
||||
ORDER BY updated_at DESC
|
||||
`
|
||||
|
||||
rows, err := db.Query(query, ownerDID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query captain records for owner: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []*HoldCaptainRecord
|
||||
for rows.Next() {
|
||||
var record HoldCaptainRecord
|
||||
var deployedAt, region sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&record.HoldDID,
|
||||
&record.OwnerDID,
|
||||
&record.Public,
|
||||
&record.AllowAllCrew,
|
||||
&deployedAt,
|
||||
®ion,
|
||||
&record.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan captain record: %w", err)
|
||||
}
|
||||
|
||||
if deployedAt.Valid {
|
||||
record.DeployedAt = deployedAt.String
|
||||
}
|
||||
if region.Valid {
|
||||
record.Region = region.String
|
||||
}
|
||||
|
||||
records = append(records, &record)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating captain records: %w", err)
|
||||
}
|
||||
|
||||
if records == nil {
|
||||
records = []*HoldCaptainRecord{}
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// DeleteCaptainRecord removes a captain record from the cache
|
||||
func DeleteCaptainRecord(db *sql.DB, holdDID string) error {
|
||||
// Note: hold_crew_members doesn't have CASCADE, so delete crew first
|
||||
_, err := db.Exec(`DELETE FROM hold_crew_members WHERE hold_did = ?`, holdDID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete crew members for hold: %w", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec(`DELETE FROM hold_captain_records WHERE hold_did = ?`, holdDID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete captain record: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CrewMember represents a cached crew membership from Jetstream
|
||||
type CrewMember struct {
|
||||
HoldDID string
|
||||
MemberDID string
|
||||
Rkey string
|
||||
Role string
|
||||
Permissions string // JSON array
|
||||
Tier string
|
||||
AddedAt string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// UpsertCrewMember inserts or updates a crew member record
|
||||
func UpsertCrewMember(db *sql.DB, member *CrewMember) error {
|
||||
query := `
|
||||
INSERT INTO hold_crew_members (
|
||||
hold_did, member_did, rkey, role, permissions, tier, added_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(hold_did, member_did) DO UPDATE SET
|
||||
rkey = excluded.rkey,
|
||||
role = excluded.role,
|
||||
permissions = excluded.permissions,
|
||||
tier = excluded.tier,
|
||||
added_at = excluded.added_at,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
`
|
||||
|
||||
_, err := db.Exec(query,
|
||||
member.HoldDID,
|
||||
member.MemberDID,
|
||||
member.Rkey,
|
||||
nullString(member.Role),
|
||||
nullString(member.Permissions),
|
||||
nullString(member.Tier),
|
||||
nullString(member.AddedAt),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to upsert crew member: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCrewMemberByRkey removes a crew member by rkey (for delete events from Jetstream)
|
||||
func DeleteCrewMemberByRkey(db *sql.DB, holdDID, rkey string) error {
|
||||
_, err := db.Exec(`DELETE FROM hold_crew_members WHERE hold_did = ? AND rkey = ?`, holdDID, rkey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete crew member by rkey: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AvailableHold represents a hold available to a user, with membership info
|
||||
type AvailableHold struct {
|
||||
HoldDID string
|
||||
OwnerDID string
|
||||
Public bool
|
||||
AllowAllCrew bool
|
||||
Region string
|
||||
Membership string // "owner", "crew", "eligible", "public"
|
||||
Permissions string // JSON array (only for crew)
|
||||
}
|
||||
|
||||
// GetAvailableHolds returns all holds available to a user, grouped by membership type
|
||||
// Results are ordered: owner first, then crew, then eligible, then public
|
||||
func GetAvailableHolds(db *sql.DB, userDID string) ([]AvailableHold, error) {
|
||||
query := `
|
||||
SELECT
|
||||
h.hold_did,
|
||||
h.owner_did,
|
||||
h.public,
|
||||
h.allow_all_crew,
|
||||
h.region,
|
||||
CASE
|
||||
WHEN h.owner_did = ?1 THEN 'owner'
|
||||
WHEN c.member_did IS NOT NULL THEN 'crew'
|
||||
WHEN h.allow_all_crew = 1 THEN 'eligible'
|
||||
WHEN h.public = 1 THEN 'public'
|
||||
ELSE 'none'
|
||||
END as membership,
|
||||
c.permissions
|
||||
FROM hold_captain_records h
|
||||
LEFT JOIN hold_crew_members c ON h.hold_did = c.hold_did AND c.member_did = ?1
|
||||
WHERE h.public = 1
|
||||
OR h.allow_all_crew = 1
|
||||
OR h.owner_did = ?1
|
||||
OR c.member_did IS NOT NULL
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN h.owner_did = ?1 THEN 0
|
||||
WHEN c.member_did IS NOT NULL THEN 1
|
||||
WHEN h.allow_all_crew = 1 THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
h.hold_did
|
||||
`
|
||||
|
||||
rows, err := db.Query(query, userDID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query available holds: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var holds []AvailableHold
|
||||
for rows.Next() {
|
||||
var hold AvailableHold
|
||||
var region, permissions sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&hold.HoldDID,
|
||||
&hold.OwnerDID,
|
||||
&hold.Public,
|
||||
&hold.AllowAllCrew,
|
||||
®ion,
|
||||
&hold.Membership,
|
||||
&permissions,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan available hold: %w", err)
|
||||
}
|
||||
|
||||
if region.Valid {
|
||||
hold.Region = region.String
|
||||
}
|
||||
if permissions.Valid {
|
||||
hold.Permissions = permissions.String
|
||||
}
|
||||
|
||||
holds = append(holds, hold)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating available holds: %w", err)
|
||||
}
|
||||
|
||||
return holds, nil
|
||||
}
|
||||
|
||||
// GetCrewMemberships returns all holds where a user is a crew member
|
||||
func GetCrewMemberships(db *sql.DB, memberDID string) ([]CrewMember, error) {
|
||||
query := `
|
||||
SELECT hold_did, member_did, rkey, role, permissions, tier, added_at, created_at, updated_at
|
||||
FROM hold_crew_members
|
||||
WHERE member_did = ?
|
||||
ORDER BY added_at DESC
|
||||
`
|
||||
|
||||
rows, err := db.Query(query, memberDID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query crew memberships: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var memberships []CrewMember
|
||||
for rows.Next() {
|
||||
var m CrewMember
|
||||
var role, permissions, tier, addedAt sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&m.HoldDID,
|
||||
&m.MemberDID,
|
||||
&m.Rkey,
|
||||
&role,
|
||||
&permissions,
|
||||
&tier,
|
||||
&addedAt,
|
||||
&m.CreatedAt,
|
||||
&m.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan crew membership: %w", err)
|
||||
}
|
||||
|
||||
if role.Valid {
|
||||
m.Role = role.String
|
||||
}
|
||||
if permissions.Valid {
|
||||
m.Permissions = permissions.String
|
||||
}
|
||||
if tier.Valid {
|
||||
m.Tier = tier.String
|
||||
}
|
||||
if addedAt.Valid {
|
||||
m.AddedAt = addedAt.String
|
||||
}
|
||||
|
||||
memberships = append(memberships, m)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating crew memberships: %w", err)
|
||||
}
|
||||
|
||||
return memberships, nil
|
||||
}
|
||||
|
||||
@@ -103,7 +103,6 @@ func TestGetCaptainRecord(t *testing.T) {
|
||||
AllowAllCrew: false,
|
||||
DeployedAt: "2025-01-15",
|
||||
Region: "us-west-2",
|
||||
Provider: "aws",
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -159,9 +158,6 @@ func TestGetCaptainRecord(t *testing.T) {
|
||||
if record.Region != testRecord.Region {
|
||||
t.Errorf("Region = %v, want %v", record.Region, testRecord.Region)
|
||||
}
|
||||
if record.Provider != testRecord.Provider {
|
||||
t.Errorf("Provider = %v, want %v", record.Provider, testRecord.Provider)
|
||||
}
|
||||
} else {
|
||||
if record != nil {
|
||||
t.Errorf("Expected nil, got record: %+v", record)
|
||||
@@ -183,7 +179,6 @@ func TestGetCaptainRecord_NullableFields(t *testing.T) {
|
||||
AllowAllCrew: true,
|
||||
DeployedAt: "", // Empty - should be NULL
|
||||
Region: "", // Empty - should be NULL
|
||||
Provider: "", // Empty - should be NULL
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -207,9 +202,6 @@ func TestGetCaptainRecord_NullableFields(t *testing.T) {
|
||||
if record.Region != "" {
|
||||
t.Errorf("Region = %v, want empty string", record.Region)
|
||||
}
|
||||
if record.Provider != "" {
|
||||
t.Errorf("Provider = %v, want empty string", record.Provider)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpsertCaptainRecord_Insert tests inserting new records
|
||||
@@ -223,7 +215,6 @@ func TestUpsertCaptainRecord_Insert(t *testing.T) {
|
||||
AllowAllCrew: true,
|
||||
DeployedAt: "2025-02-01",
|
||||
Region: "eu-west-1",
|
||||
Provider: "gcp",
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -262,7 +253,6 @@ func TestUpsertCaptainRecord_Update(t *testing.T) {
|
||||
AllowAllCrew: false,
|
||||
DeployedAt: "2025-01-01",
|
||||
Region: "us-east-1",
|
||||
Provider: "aws",
|
||||
UpdatedAt: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
|
||||
@@ -279,7 +269,6 @@ func TestUpsertCaptainRecord_Update(t *testing.T) {
|
||||
AllowAllCrew: true, // Changed allow all crew
|
||||
DeployedAt: "2025-03-01", // Changed date
|
||||
Region: "ap-south-1", // Changed region
|
||||
Provider: "azure", // Changed provider
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -313,9 +302,6 @@ func TestUpsertCaptainRecord_Update(t *testing.T) {
|
||||
if retrieved.Region != updatedRecord.Region {
|
||||
t.Errorf("Region = %v, want %v", retrieved.Region, updatedRecord.Region)
|
||||
}
|
||||
if retrieved.Provider != updatedRecord.Provider {
|
||||
t.Errorf("Provider = %v, want %v", retrieved.Provider, updatedRecord.Provider)
|
||||
}
|
||||
|
||||
// Verify there's still only one record in the database
|
||||
holds, err := ListHoldDIDs(db)
|
||||
|
||||
19
pkg/appview/db/migrations/0008_add_hold_crew_members.yaml
Normal file
19
pkg/appview/db/migrations/0008_add_hold_crew_members.yaml
Normal 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);
|
||||
@@ -183,7 +183,6 @@ CREATE TABLE IF NOT EXISTS hold_captain_records (
|
||||
allow_all_crew BOOLEAN NOT NULL,
|
||||
deployed_at TEXT,
|
||||
region TEXT,
|
||||
provider TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_hold_captain_updated ON hold_captain_records(updated_at);
|
||||
@@ -207,6 +206,24 @@ CREATE TABLE IF NOT EXISTS hold_crew_denials (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_crew_denials_retry ON hold_crew_denials(next_retry_at);
|
||||
|
||||
-- Cached hold crew memberships from Jetstream
|
||||
-- Enables reverse lookup: "which holds is user X a member of?"
|
||||
CREATE TABLE IF NOT EXISTS hold_crew_members (
|
||||
hold_did TEXT NOT NULL,
|
||||
member_did TEXT NOT NULL,
|
||||
rkey TEXT NOT NULL,
|
||||
role TEXT,
|
||||
permissions TEXT, -- JSON array
|
||||
tier TEXT,
|
||||
added_at TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (hold_did, member_did)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_hold_crew_member ON hold_crew_members(member_did);
|
||||
CREATE INDEX IF NOT EXISTS idx_hold_crew_hold ON hold_crew_members(hold_did);
|
||||
CREATE INDEX IF NOT EXISTS idx_hold_crew_rkey ON hold_crew_members(hold_did, rkey);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS repo_pages (
|
||||
did TEXT NOT NULL,
|
||||
repository TEXT NOT NULL,
|
||||
|
||||
230
pkg/appview/handlers/export.go
Normal file
230
pkg/appview/handlers/export.go
Normal 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
|
||||
}
|
||||
@@ -1,22 +1,38 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
"atcr.io/pkg/appview/storage"
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
)
|
||||
|
||||
// HoldDisplay represents a hold for display in the UI
|
||||
type HoldDisplay struct {
|
||||
DID string `json:"did"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Region string `json:"region"`
|
||||
Membership string `json:"membership"`
|
||||
Permissions []string `json:"permissions,omitempty"`
|
||||
}
|
||||
|
||||
// SettingsHandler handles the settings page
|
||||
type SettingsHandler struct {
|
||||
Templates *template.Template
|
||||
Refresher *oauth.Refresher
|
||||
RegistryURL string
|
||||
Templates *template.Template
|
||||
Refresher *oauth.Refresher
|
||||
RegistryURL string
|
||||
DB *sql.DB
|
||||
DefaultHoldDID string
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -47,6 +63,63 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
slog.Debug("Fetched profile", "component", "settings", "did", user.DID, "default_hold", profile.DefaultHold)
|
||||
|
||||
// Get available holds for dropdown
|
||||
var ownedHolds, crewHolds, eligibleHolds, publicHolds []HoldDisplay
|
||||
holdDataMap := make(map[string]HoldDisplay)
|
||||
|
||||
if h.DB != nil {
|
||||
availableHolds, err := db.GetAvailableHolds(h.DB, user.DID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get available holds", "component", "settings", "did", user.DID, "error", err)
|
||||
} else {
|
||||
// Group holds by membership type
|
||||
for _, hold := range availableHolds {
|
||||
display := HoldDisplay{
|
||||
DID: hold.HoldDID,
|
||||
DisplayName: deriveDisplayName(hold.HoldDID),
|
||||
Region: hold.Region,
|
||||
Membership: hold.Membership,
|
||||
}
|
||||
|
||||
// Parse permissions JSON if present
|
||||
if hold.Permissions != "" {
|
||||
json.Unmarshal([]byte(hold.Permissions), &display.Permissions)
|
||||
}
|
||||
|
||||
// Add to data map for JavaScript
|
||||
holdDataMap[hold.HoldDID] = display
|
||||
|
||||
// Group by membership type
|
||||
switch hold.Membership {
|
||||
case "owner":
|
||||
ownedHolds = append(ownedHolds, display)
|
||||
case "crew":
|
||||
crewHolds = append(crewHolds, display)
|
||||
case "eligible":
|
||||
eligibleHolds = append(eligibleHolds, display)
|
||||
case "public":
|
||||
publicHolds = append(publicHolds, display)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize hold data for JavaScript
|
||||
holdDataJSON, _ := json.Marshal(holdDataMap)
|
||||
|
||||
// Check if current hold needs to be shown separately (not in discovered holds)
|
||||
_, currentHoldDiscovered := holdDataMap[profile.DefaultHold]
|
||||
showCurrentHold := profile.DefaultHold != "" && !currentHoldDiscovered
|
||||
|
||||
// Look up AppView default hold details from database
|
||||
appViewDefaultDisplay := deriveDisplayName(h.DefaultHoldDID)
|
||||
var appViewDefaultRegion string
|
||||
if h.DefaultHoldDID != "" && h.DB != nil {
|
||||
if captain, err := db.GetCaptainRecord(h.DB, h.DefaultHoldDID); err == nil && captain != nil {
|
||||
appViewDefaultRegion = captain.Region
|
||||
}
|
||||
}
|
||||
|
||||
data := struct {
|
||||
PageData
|
||||
Profile struct {
|
||||
@@ -55,8 +128,28 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
PDSEndpoint string
|
||||
DefaultHold string
|
||||
}
|
||||
CurrentHoldDID string
|
||||
CurrentHoldDisplay string
|
||||
ShowCurrentHold bool
|
||||
AppViewDefaultHoldDisplay string
|
||||
AppViewDefaultRegion string
|
||||
OwnedHolds []HoldDisplay
|
||||
CrewHolds []HoldDisplay
|
||||
EligibleHolds []HoldDisplay
|
||||
PublicHolds []HoldDisplay
|
||||
HoldDataJSON template.JS
|
||||
}{
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
PageData: NewPageData(r, h.RegistryURL),
|
||||
CurrentHoldDID: profile.DefaultHold,
|
||||
CurrentHoldDisplay: deriveDisplayName(profile.DefaultHold),
|
||||
ShowCurrentHold: showCurrentHold,
|
||||
AppViewDefaultHoldDisplay: appViewDefaultDisplay,
|
||||
AppViewDefaultRegion: appViewDefaultRegion,
|
||||
OwnedHolds: ownedHolds,
|
||||
CrewHolds: crewHolds,
|
||||
EligibleHolds: eligibleHolds,
|
||||
PublicHolds: publicHolds,
|
||||
HoldDataJSON: template.JS(holdDataJSON),
|
||||
}
|
||||
|
||||
data.Profile.Handle = user.Handle
|
||||
@@ -70,10 +163,31 @@ func (h *SettingsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// deriveDisplayName derives a human-readable name from a hold DID
|
||||
func deriveDisplayName(did string) string {
|
||||
// For did:web, extract the domain
|
||||
if strings.HasPrefix(did, "did:web:") {
|
||||
domain := strings.TrimPrefix(did, "did:web:")
|
||||
// URL-decode the domain (did:web encodes : as %3A)
|
||||
decoded, err := url.QueryUnescape(domain)
|
||||
if err == nil {
|
||||
return decoded
|
||||
}
|
||||
return domain
|
||||
}
|
||||
|
||||
// For did:plc, truncate for display
|
||||
if len(did) > 24 {
|
||||
return did[:24] + "..."
|
||||
}
|
||||
return did
|
||||
}
|
||||
|
||||
// UpdateDefaultHoldHandler handles updating the default hold
|
||||
type UpdateDefaultHoldHandler struct {
|
||||
Refresher *oauth.Refresher
|
||||
Templates *template.Template
|
||||
DB *sql.DB
|
||||
}
|
||||
|
||||
func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -83,7 +197,39 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
|
||||
return
|
||||
}
|
||||
|
||||
holdEndpoint := r.FormValue("hold_endpoint")
|
||||
// Accept hold_did (new dropdown) or hold_endpoint (legacy text input)
|
||||
holdDID := r.FormValue("hold_did")
|
||||
if holdDID == "" {
|
||||
holdDID = r.FormValue("hold_endpoint")
|
||||
}
|
||||
|
||||
// Validate hold DID if provided and database is available
|
||||
if holdDID != "" && h.DB != nil {
|
||||
// Check if user has access to this hold
|
||||
availableHolds, err := db.GetAvailableHolds(h.DB, user.DID)
|
||||
if err != nil {
|
||||
slog.Warn("Failed to validate hold access", "component", "settings", "did", user.DID, "error", err)
|
||||
// Don't block - fall through to allow the update
|
||||
} else {
|
||||
hasAccess := false
|
||||
for _, hold := range availableHolds {
|
||||
if hold.HoldDID == holdDID {
|
||||
hasAccess = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasAccess {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
h.Templates.ExecuteTemplate(w, "alert", map[string]string{
|
||||
"Class": "error",
|
||||
"Icon": "alert-circle",
|
||||
"Message": "You don't have access to this hold",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create ATProto client with session provider (uses DoWithSession for DPoP nonce safety)
|
||||
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
|
||||
@@ -92,10 +238,10 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
|
||||
profile, err := storage.GetProfile(r.Context(), client)
|
||||
if err != nil || profile == nil {
|
||||
// Profile doesn't exist, create new one
|
||||
profile = atproto.NewSailorProfileRecord(holdEndpoint)
|
||||
profile = atproto.NewSailorProfileRecord(holdDID)
|
||||
} else {
|
||||
// Update existing profile
|
||||
profile.DefaultHold = holdEndpoint
|
||||
profile.DefaultHold = holdDID
|
||||
profile.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
|
||||
@@ -61,13 +61,18 @@ func NewBackfillWorker(database *sql.DB, relayEndpoint, defaultHoldDID string, t
|
||||
func (b *BackfillWorker) Start(ctx context.Context) error {
|
||||
slog.Info("Backfill: Starting sync-based backfill...")
|
||||
|
||||
// First, query and cache the default hold's captain record
|
||||
// First, query and cache the default hold's captain and crew records
|
||||
// This is necessary for localhost/private holds not discoverable via relay
|
||||
if b.defaultHoldDID != "" {
|
||||
slog.Info("Backfill querying default hold captain record", "hold_did", b.defaultHoldDID)
|
||||
slog.Info("Backfill querying default hold records", "hold_did", b.defaultHoldDID)
|
||||
if err := b.queryCaptainRecord(ctx, b.defaultHoldDID); err != nil {
|
||||
slog.Warn("Backfill failed to query default hold captain record", "error", err)
|
||||
// Don't fail the whole backfill - just warn
|
||||
}
|
||||
if err := b.queryCrewRecords(ctx, b.defaultHoldDID); err != nil {
|
||||
slog.Warn("Backfill failed to query default hold crew records", "error", err)
|
||||
// Don't fail the whole backfill - just warn
|
||||
}
|
||||
}
|
||||
|
||||
collections := []string{
|
||||
@@ -77,6 +82,8 @@ func (b *BackfillWorker) Start(ctx context.Context) error {
|
||||
atproto.SailorProfileCollection, // io.atcr.sailor.profile
|
||||
atproto.RepoPageCollection, // io.atcr.repo.page
|
||||
atproto.StatsCollection, // io.atcr.hold.stats (from holds)
|
||||
atproto.CaptainCollection, // io.atcr.hold.captain (from holds)
|
||||
atproto.CrewCollection, // io.atcr.hold.crew (from holds)
|
||||
}
|
||||
|
||||
for _, collection := range collections {
|
||||
@@ -316,6 +323,16 @@ func (b *BackfillWorker) processRecord(ctx context.Context, did, collection stri
|
||||
// Stats are stored in hold PDSes, not user PDSes
|
||||
// 'did' here is the hold's DID (e.g., did:web:hold01.atcr.io)
|
||||
return b.processor.ProcessStats(ctx, did, record.Value, false)
|
||||
case atproto.CaptainCollection:
|
||||
// Captain records are stored in hold PDSes
|
||||
// 'did' here is the hold's DID (e.g., did:web:hold01.atcr.io)
|
||||
return b.processor.ProcessCaptain(ctx, did, record.Value)
|
||||
case atproto.CrewCollection:
|
||||
// Crew records are stored in hold PDSes
|
||||
// 'did' here is the hold's DID, rkey is derived from member DID
|
||||
// Extract rkey from record URI (at://did/collection/rkey)
|
||||
rkey := extractRkeyFromURI(record.URI)
|
||||
return b.processor.ProcessCrew(ctx, did, rkey, record.Value)
|
||||
default:
|
||||
return fmt.Errorf("unsupported collection: %s", collection)
|
||||
}
|
||||
@@ -391,6 +408,51 @@ func (b *BackfillWorker) queryCaptainRecord(ctx context.Context, holdDID string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryCrewRecords queries a hold's crew records and caches them in the database
|
||||
// This is necessary for localhost/private holds that aren't discoverable via the relay
|
||||
func (b *BackfillWorker) queryCrewRecords(ctx context.Context, holdDID string) error {
|
||||
// Resolve hold DID to URL
|
||||
holdURL := atproto.ResolveHoldURL(holdDID)
|
||||
|
||||
// Create client for hold's PDS
|
||||
holdClient := atproto.NewClient(holdURL, holdDID, "")
|
||||
|
||||
var cursor string
|
||||
recordCount := 0
|
||||
|
||||
// Paginate through all crew records
|
||||
for {
|
||||
records, nextCursor, err := holdClient.ListRecordsForRepo(ctx, holdDID, atproto.CrewCollection, 100, cursor)
|
||||
if err != nil {
|
||||
// If no crew records exist, that's okay
|
||||
if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "RecordNotFound") {
|
||||
slog.Debug("No crew records found for hold", "hold_did", holdDID)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to list crew records: %w", err)
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
rkey := extractRkeyFromURI(record.URI)
|
||||
if err := b.processor.ProcessCrew(ctx, holdDID, rkey, record.Value); err != nil {
|
||||
slog.Warn("Backfill failed to process crew record", "hold_did", holdDID, "uri", record.URI, "error", err)
|
||||
continue
|
||||
}
|
||||
recordCount++
|
||||
}
|
||||
|
||||
if nextCursor == "" {
|
||||
break
|
||||
}
|
||||
cursor = nextCursor
|
||||
}
|
||||
|
||||
if recordCount > 0 {
|
||||
slog.Info("Backfill cached crew records for hold", "hold_did", holdDID, "count", recordCount)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconcileAnnotations ensures annotations come from the newest manifest in each repository
|
||||
// This fixes the out-of-order backfill issue where older manifests can overwrite newer annotations
|
||||
func (b *BackfillWorker) reconcileAnnotations(ctx context.Context, did string, pdsClient *atproto.Client) error {
|
||||
@@ -635,3 +697,14 @@ func (b *BackfillWorker) updateRepoPageInPDS(ctx context.Context, did, pdsEndpoi
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractRkeyFromURI extracts the rkey from an AT-URI
|
||||
// Format: at://did/collection/rkey
|
||||
func extractRkeyFromURI(uri string) string {
|
||||
// URI format: at://did/collection/rkey
|
||||
parts := strings.Split(uri, "/")
|
||||
if len(parts) >= 5 {
|
||||
return parts[4]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -433,6 +433,84 @@ func (p *Processor) ProcessStats(ctx context.Context, holdDID string, recordData
|
||||
})
|
||||
}
|
||||
|
||||
// ProcessCaptain handles captain record events from hold PDSes
|
||||
// This is called when Jetstream receives a captain create/update/delete event from a hold
|
||||
// The holdDID is the DID of the hold PDS (event.DID), and the record contains ownership info
|
||||
func (p *Processor) ProcessCaptain(ctx context.Context, holdDID string, recordData []byte) error {
|
||||
// Unmarshal captain record
|
||||
var captainRecord atproto.CaptainRecord
|
||||
if err := json.Unmarshal(recordData, &captainRecord); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal captain record: %w", err)
|
||||
}
|
||||
|
||||
// Convert to db struct and upsert
|
||||
record := &db.HoldCaptainRecord{
|
||||
HoldDID: holdDID,
|
||||
OwnerDID: captainRecord.Owner,
|
||||
Public: captainRecord.Public,
|
||||
AllowAllCrew: captainRecord.AllowAllCrew,
|
||||
DeployedAt: captainRecord.DeployedAt,
|
||||
Region: captainRecord.Region,
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := db.UpsertCaptainRecord(p.db, record); err != nil {
|
||||
return fmt.Errorf("failed to upsert captain record: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("Processed captain record",
|
||||
"component", "processor",
|
||||
"hold_did", holdDID,
|
||||
"owner_did", captainRecord.Owner,
|
||||
"public", captainRecord.Public,
|
||||
"allow_all_crew", captainRecord.AllowAllCrew)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessCrew handles crew record events from hold PDSes
|
||||
// This is called when Jetstream receives a crew create/update/delete event from a hold
|
||||
// The holdDID is the DID of the hold PDS (event.DID), and the record contains member info
|
||||
func (p *Processor) ProcessCrew(ctx context.Context, holdDID string, rkey string, recordData []byte) error {
|
||||
// Unmarshal crew record
|
||||
var crewRecord atproto.CrewRecord
|
||||
if err := json.Unmarshal(recordData, &crewRecord); err != nil {
|
||||
return fmt.Errorf("failed to unmarshal crew record: %w", err)
|
||||
}
|
||||
|
||||
// Marshal permissions to JSON string
|
||||
permissionsJSON := ""
|
||||
if len(crewRecord.Permissions) > 0 {
|
||||
if jsonBytes, err := json.Marshal(crewRecord.Permissions); err == nil {
|
||||
permissionsJSON = string(jsonBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to db struct and upsert
|
||||
member := &db.CrewMember{
|
||||
HoldDID: holdDID,
|
||||
MemberDID: crewRecord.Member,
|
||||
Rkey: rkey,
|
||||
Role: crewRecord.Role,
|
||||
Permissions: permissionsJSON,
|
||||
Tier: crewRecord.Tier,
|
||||
AddedAt: crewRecord.AddedAt,
|
||||
}
|
||||
|
||||
if err := db.UpsertCrewMember(p.db, member); err != nil {
|
||||
return fmt.Errorf("failed to upsert crew member: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("Processed crew record",
|
||||
"component", "processor",
|
||||
"hold_did", holdDID,
|
||||
"member_did", crewRecord.Member,
|
||||
"role", crewRecord.Role,
|
||||
"permissions", crewRecord.Permissions)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessAccount handles account status events (deactivation/deletion/etc)
|
||||
// This is called when Jetstream receives an account event indicating status changes.
|
||||
//
|
||||
|
||||
@@ -326,6 +326,12 @@ func (w *Worker) processMessage(message []byte) error {
|
||||
case atproto.StatsCollection:
|
||||
slog.Info("Jetstream processing stats event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey)
|
||||
return w.processStats(commit)
|
||||
case atproto.CaptainCollection:
|
||||
slog.Info("Jetstream processing captain event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey)
|
||||
return w.processCaptain(commit)
|
||||
case atproto.CrewCollection:
|
||||
slog.Info("Jetstream processing crew event", "did", commit.DID, "operation", commit.Operation, "rkey", commit.RKey)
|
||||
return w.processCrew(commit)
|
||||
default:
|
||||
// Ignore other collections
|
||||
return nil
|
||||
@@ -514,6 +520,62 @@ func (w *Worker) processStats(commit *CommitEvent) error {
|
||||
return w.processor.ProcessStats(context.Background(), commit.DID, recordBytes, false)
|
||||
}
|
||||
|
||||
// processCaptain processes a captain record event from a hold's PDS
|
||||
func (w *Worker) processCaptain(commit *CommitEvent) error {
|
||||
holdDID := commit.DID // The repo DID IS the hold DID
|
||||
|
||||
if commit.Operation == "delete" {
|
||||
// Delete captain record - this cascades to crew members
|
||||
if err := db.DeleteCaptainRecord(w.db, holdDID); err != nil {
|
||||
return fmt.Errorf("failed to delete captain record: %w", err)
|
||||
}
|
||||
slog.Info("Deleted captain record for hold", "hold_did", holdDID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse captain record
|
||||
if commit.Record == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Marshal map to bytes for processing
|
||||
recordBytes, err := json.Marshal(commit.Record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal captain record: %w", err)
|
||||
}
|
||||
|
||||
// Use shared processor
|
||||
return w.processor.ProcessCaptain(context.Background(), holdDID, recordBytes)
|
||||
}
|
||||
|
||||
// processCrew processes a crew record event from a hold's PDS
|
||||
func (w *Worker) processCrew(commit *CommitEvent) error {
|
||||
holdDID := commit.DID // The repo DID IS the hold DID
|
||||
|
||||
if commit.Operation == "delete" {
|
||||
// Delete crew member by rkey
|
||||
if err := db.DeleteCrewMemberByRkey(w.db, holdDID, commit.RKey); err != nil {
|
||||
return fmt.Errorf("failed to delete crew member: %w", err)
|
||||
}
|
||||
slog.Info("Deleted crew member from hold", "hold_did", holdDID, "rkey", commit.RKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Parse crew record
|
||||
if commit.Record == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Marshal map to bytes for processing
|
||||
recordBytes, err := json.Marshal(commit.Record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal crew record: %w", err)
|
||||
}
|
||||
|
||||
// Use shared processor - pass rkey for storage
|
||||
return w.processor.ProcessCrew(context.Background(), holdDID, commit.RKey, recordBytes)
|
||||
}
|
||||
|
||||
// processIdentity processes an identity event (handle change)
|
||||
func (w *Worker) processIdentity(event *JetstreamEvent) error {
|
||||
if event.Identity == nil {
|
||||
|
||||
@@ -29,6 +29,7 @@ type UIDependencies struct {
|
||||
HealthChecker *holdhealth.Checker
|
||||
ReadmeFetcher *readme.Fetcher
|
||||
Templates *template.Template
|
||||
DefaultHoldDID string
|
||||
}
|
||||
|
||||
// RegisterUIRoutes registers all web UI and API routes on the provided router
|
||||
@@ -185,9 +186,11 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
r.Use(middleware.RequireAuth(deps.SessionStore, deps.Database))
|
||||
|
||||
r.Get("/settings", (&uihandlers.SettingsHandler{
|
||||
Templates: deps.Templates,
|
||||
Refresher: deps.Refresher,
|
||||
RegistryURL: registryURL,
|
||||
Templates: deps.Templates,
|
||||
Refresher: deps.Refresher,
|
||||
RegistryURL: registryURL,
|
||||
DB: deps.Database,
|
||||
DefaultHoldDID: deps.DefaultHoldDID,
|
||||
}).ServeHTTP)
|
||||
|
||||
r.Get("/api/storage", (&uihandlers.StorageHandler{
|
||||
@@ -198,6 +201,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
r.Post("/api/profile/default-hold", (&uihandlers.UpdateDefaultHoldHandler{
|
||||
Refresher: deps.Refresher,
|
||||
Templates: deps.Templates,
|
||||
DB: deps.Database,
|
||||
}).ServeHTTP)
|
||||
|
||||
r.Delete("/api/images/{repository}/tags/{tag}", (&uihandlers.DeleteTagHandler{
|
||||
@@ -236,6 +240,12 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) {
|
||||
Store: deps.DeviceStore,
|
||||
SessionStore: deps.SessionStore,
|
||||
}).ServeHTTP)
|
||||
|
||||
// GDPR data export
|
||||
r.Get("/api/export-data", (&uihandlers.ExportUserDataHandler{
|
||||
DB: deps.Database,
|
||||
Refresher: deps.Refresher,
|
||||
}).ServeHTTP)
|
||||
})
|
||||
|
||||
// Logout endpoint (supports both GET and POST)
|
||||
|
||||
@@ -39,9 +39,9 @@
|
||||
</section>
|
||||
|
||||
<!-- Default Hold Section -->
|
||||
<section class="settings-section">
|
||||
<section class="settings-section hold-section">
|
||||
<h2>Default Hold</h2>
|
||||
<p>Current: <strong id="current-hold">{{ if .Profile.DefaultHold }}{{ .Profile.DefaultHold }}{{ else }}Not set{{ end }}</strong></p>
|
||||
<p class="help-text">Select where your container images will be stored.</p>
|
||||
|
||||
<form hx-post="/api/profile/default-hold"
|
||||
hx-target="#hold-status"
|
||||
@@ -49,19 +49,78 @@
|
||||
id="hold-form">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="hold-endpoint">Hold Endpoint:</label>
|
||||
<input type="text"
|
||||
id="hold-endpoint"
|
||||
name="hold_endpoint"
|
||||
value="{{ .Profile.DefaultHold }}"
|
||||
placeholder="https://hold.example.com" />
|
||||
<small>Leave empty to use AppView default storage</small>
|
||||
<label for="default-hold">Storage Hold:</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="default-hold" name="hold_did" class="form-select">
|
||||
<option value=""{{ if eq .CurrentHoldDID "" }} selected{{ end }}>AppView Default ({{ .AppViewDefaultHoldDisplay }}{{ if .AppViewDefaultRegion }}, {{ .AppViewDefaultRegion }}{{ end }})</option>
|
||||
|
||||
{{ if .ShowCurrentHold }}
|
||||
<option value="{{ .CurrentHoldDID }}" selected>Current ({{ .CurrentHoldDisplay }})</option>
|
||||
{{ end }}
|
||||
|
||||
{{ if .OwnedHolds }}
|
||||
<optgroup label="Your Holds">
|
||||
{{ range .OwnedHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
|
||||
{{ if .CrewHolds }}
|
||||
<optgroup label="Crew Member">
|
||||
{{ range .CrewHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
|
||||
{{ if .EligibleHolds }}
|
||||
<optgroup label="Open Registration">
|
||||
{{ range .EligibleHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
|
||||
{{ if .PublicHolds }}
|
||||
<optgroup label="Public Holds">
|
||||
{{ range .PublicHolds }}
|
||||
<option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
|
||||
{{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
|
||||
</option>
|
||||
{{ end }}
|
||||
</optgroup>
|
||||
{{ end }}
|
||||
</select>
|
||||
<i data-lucide="chevron-down" class="select-icon"></i>
|
||||
</div>
|
||||
<small>Your images will be stored on the selected hold</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
</form>
|
||||
|
||||
<div id="hold-status"></div>
|
||||
|
||||
<!-- Hold details panel (shows when hold selected) -->
|
||||
<div id="hold-details" class="hold-details" style="display: none;">
|
||||
<h3>Hold Details</h3>
|
||||
<dl>
|
||||
<dt>DID:</dt>
|
||||
<dd id="hold-did"></dd>
|
||||
<dt>Region:</dt>
|
||||
<dd id="hold-region"></dd>
|
||||
<dt>Your Access:</dt>
|
||||
<dd id="hold-access"></dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- Authorized Devices Section -->
|
||||
@@ -114,25 +173,91 @@
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Data Privacy Section -->
|
||||
<section class="settings-section privacy-section">
|
||||
<h2>Data Privacy</h2>
|
||||
<p>Download a copy of all data we store about you.</p>
|
||||
|
||||
<div class="privacy-actions">
|
||||
<a href="/api/export-data" class="btn-secondary" download>
|
||||
<i data-lucide="download"></i>
|
||||
Export All My Data
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="privacy-note">
|
||||
<small>
|
||||
This includes your authorized devices, sessions, and hold memberships.
|
||||
Data stored on your PDS is already under your control.
|
||||
See our <a href="/privacy">Privacy Policy</a> for details.
|
||||
</small>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
// Default Hold Update - Dynamic display update
|
||||
// Hold data from server (for details panel)
|
||||
const holdData = {{ .HoldDataJSON }};
|
||||
|
||||
// Hold Selection and Details Display
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const holdSelect = document.getElementById('default-hold');
|
||||
const holdDetails = document.getElementById('hold-details');
|
||||
const holdForm = document.getElementById('hold-form');
|
||||
|
||||
holdForm.addEventListener('htmx:afterSwap', function(event) {
|
||||
// Check if the response contains success indicator
|
||||
if (event.detail.xhr.status === 200) {
|
||||
const holdInput = document.getElementById('hold-endpoint');
|
||||
const currentHoldDisplay = document.getElementById('current-hold');
|
||||
const newValue = holdInput.value.trim();
|
||||
if (holdSelect) {
|
||||
holdSelect.addEventListener('change', function() {
|
||||
const selectedDID = this.value;
|
||||
|
||||
// Update the current hold display
|
||||
currentHoldDisplay.textContent = newValue || 'Not set';
|
||||
if (!selectedDID || !holdData[selectedDID]) {
|
||||
holdDetails.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const hold = holdData[selectedDID];
|
||||
|
||||
document.getElementById('hold-did').textContent = hold.did;
|
||||
document.getElementById('hold-region').textContent = hold.region || 'Unknown';
|
||||
|
||||
// Set access level with badge
|
||||
const accessEl = document.getElementById('hold-access');
|
||||
const accessLabel = {
|
||||
'owner': 'Owner (Full Control)',
|
||||
'crew': 'Crew Member',
|
||||
'eligible': 'Open Registration',
|
||||
'public': 'Public Access'
|
||||
}[hold.membership] || hold.membership;
|
||||
|
||||
const accessClass = 'access-' + hold.membership;
|
||||
accessEl.innerHTML = '<span class="access-badge ' + accessClass + '">' + accessLabel + '</span>';
|
||||
|
||||
// Show permissions for crew members
|
||||
if (hold.membership === 'crew' && hold.permissions && hold.permissions.length > 0) {
|
||||
accessEl.innerHTML += '<br><small>Permissions: ' + hold.permissions.join(', ') + '</small>';
|
||||
}
|
||||
|
||||
holdDetails.style.display = 'block';
|
||||
});
|
||||
|
||||
// Trigger on page load if a hold is already selected
|
||||
if (holdSelect.value) {
|
||||
holdSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// HTMX success handler
|
||||
if (holdForm) {
|
||||
holdForm.addEventListener('htmx:afterSwap', function(event) {
|
||||
if (event.detail.xhr.status === 200) {
|
||||
// Reinitialize Lucide icons if any were added
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Device Management JavaScript
|
||||
@@ -399,6 +524,140 @@
|
||||
.devices-list {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
/* Hold Selection Styles */
|
||||
.hold-section .select-wrapper {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
.hold-section .form-select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 2.5rem 0.75rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
}
|
||||
.hold-section .select-icon {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: var(--fg-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
.hold-section .form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px var(--primary-bg, rgba(59, 130, 246, 0.1));
|
||||
}
|
||||
.hold-section .form-select:focus + .select-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
.hold-section .form-select optgroup {
|
||||
font-weight: bold;
|
||||
color: var(--fg-muted);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.hold-section .form-select option {
|
||||
padding: 0.5rem;
|
||||
font-weight: normal;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* Hold Details Panel */
|
||||
.hold-details {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--code-bg);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.hold-details h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--fg-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.hold-details dl {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.5rem 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
.hold-details dt {
|
||||
color: var(--fg-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
.hold-details dd {
|
||||
margin: 0;
|
||||
font-family: monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Access Level Badges */
|
||||
.access-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.access-owner {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.access-crew {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.access-eligible {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
.access-public {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
/* Privacy Section Styles */
|
||||
.privacy-section .privacy-actions {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.privacy-section .btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--code-bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
.privacy-section .btn-secondary:hover {
|
||||
background: var(--border);
|
||||
border-color: var(--fg-muted);
|
||||
}
|
||||
.privacy-section .privacy-note {
|
||||
color: var(--fg-muted);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.privacy-section .privacy-note a {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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":
|
||||
|
||||
|
||||
@@ -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.*)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
®ion,
|
||||
&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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
65
pkg/hold/metadata.go
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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, "", "")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user