···11+package db
22+33+import (
44+ "context"
55+ "database/sql"
66+ "fmt"
77+ "os"
88+ "path/filepath"
99+ "time"
1010+1111+ sqlite3 "github.com/mattn/go-sqlite3"
1212+)
1313+1414+const (
1515+ // ReadOnlyDriverName is the name of the custom SQLite driver with table authorization
1616+ ReadOnlyDriverName = "sqlite3_readonly_public"
1717+)
1818+1919+// sensitiveTables defines tables that should never be accessible from public queries
2020+var sensitiveTables = map[string]bool{
2121+ "oauth_sessions": true, // OAuth tokens
2222+ "ui_sessions": true, // Session IDs
2323+ "oauth_auth_requests": true, // OAuth state
2424+ "devices": true, // Device secret hashes
2525+ "pending_device_auth": true, // Pending device secrets
2626+}
2727+2828+// readOnlyAuthorizerCallback blocks access to sensitive tables
2929+func readOnlyAuthorizerCallback(action int, arg1, arg2, dbName string) int {
3030+ // arg1 contains the table name for most operations
3131+ tableName := arg1
3232+3333+ // Block any access to sensitive tables
3434+ if action == sqlite3.SQLITE_READ || action == sqlite3.SQLITE_UPDATE ||
3535+ action == sqlite3.SQLITE_INSERT || action == sqlite3.SQLITE_DELETE ||
3636+ action == sqlite3.SQLITE_SELECT {
3737+ if sensitiveTables[tableName] {
3838+ fmt.Printf("SECURITY: Blocked access to sensitive table '%s' (action=%d)\n", tableName, action)
3939+ return sqlite3.SQLITE_DENY
4040+ }
4141+ }
4242+4343+ // Allow everything else
4444+ return sqlite3.SQLITE_OK
4545+}
4646+4747+func init() {
4848+ // Register a custom SQLite driver with authorizer for read-only public queries
4949+ sql.Register(ReadOnlyDriverName,
5050+ &sqlite3.SQLiteDriver{
5151+ ConnectHook: func(conn *sqlite3.SQLiteConn) error {
5252+ conn.RegisterAuthorizer(readOnlyAuthorizerCallback)
5353+ return nil
5454+ },
5555+ })
5656+}
5757+5858+// InitializeDatabase initializes the SQLite database and session store
5959+// Returns: (read-write DB, read-only DB, session store)
6060+func InitializeDatabase(uiEnabled bool, dbPath string) (*sql.DB, *sql.DB, *SessionStore) {
6161+ if !uiEnabled {
6262+ return nil, nil, nil
6363+ }
6464+6565+ // Ensure directory exists
6666+ dbDir := filepath.Dir(dbPath)
6767+ if err := os.MkdirAll(dbDir, 0700); err != nil {
6868+ fmt.Printf("Warning: Failed to create UI database directory: %v\n", err)
6969+ return nil, nil, nil
7070+ }
7171+7272+ // Initialize read-write database (for writes and auth operations)
7373+ database, err := InitDB(dbPath)
7474+ if err != nil {
7575+ fmt.Printf("Warning: Failed to initialize UI database: %v\n", err)
7676+ return nil, nil, nil
7777+ }
7878+7979+ // Open read-only connection for public queries (search, user pages, etc.)
8080+ // Uses custom driver with SQLite authorizer that blocks sensitive tables
8181+ // This prevents accidental writes and blocks access to sensitive tables even if SQL injection occurs
8282+ readOnlyDB, err := sql.Open(ReadOnlyDriverName, "file:"+dbPath+"?mode=ro")
8383+ if err != nil {
8484+ fmt.Printf("Warning: Failed to open read-only database connection: %v\n", err)
8585+ return nil, nil, nil
8686+ }
8787+8888+ fmt.Printf("UI database (readonly) initialized at %s\n", dbPath)
8989+9090+ // Create SQLite-backed session store
9191+ sessionStore := NewSessionStore(database)
9292+9393+ // Start cleanup goroutines for all SQLite stores
9494+ go func() {
9595+ ticker := time.NewTicker(5 * time.Minute)
9696+ defer ticker.Stop()
9797+ for range ticker.C {
9898+ ctx := context.Background()
9999+100100+ // Cleanup UI sessions
101101+ sessionStore.Cleanup()
102102+103103+ // Cleanup OAuth sessions (older than 30 days)
104104+ oauthStore := NewOAuthStore(database)
105105+ oauthStore.CleanupOldSessions(ctx, 30*24*time.Hour)
106106+ oauthStore.CleanupExpiredAuthRequests(ctx)
107107+108108+ // Cleanup device pending auths
109109+ deviceStore := NewDeviceStore(database)
110110+ deviceStore.CleanupExpired()
111111+ }
112112+ }()
113113+114114+ return database, readOnlyDB, sessionStore
115115+}
+9
pkg/appview/handlers/common.go
···2233import (
44 "net/http"
55+ "strings"
5667 "atcr.io/pkg/appview/db"
78 "atcr.io/pkg/appview/middleware"
···2223 RegistryURL: registryURL,
2324 }
2425}
2626+2727+// TrimRegistryURL removes http:// or https:// prefix from a URL
2828+// for use in Docker commands where only the host:port is needed
2929+func TrimRegistryURL(url string) string {
3030+ url = strings.TrimPrefix(url, "https://")
3131+ url = strings.TrimPrefix(url, "http://")
3232+ return url
3333+}
-11
pkg/appview/handlers/util.go
···11-package handlers
22-33-import "strings"
44-55-// TrimRegistryURL removes http:// or https:// prefix from a URL
66-// for use in Docker commands where only the host:port is needed
77-func TrimRegistryURL(url string) string {
88- url = strings.TrimPrefix(url, "https://")
99- url = strings.TrimPrefix(url, "http://")
1010- return url
1111-}
+4-4
pkg/appview/storage/proxy_blob_store.go
···76767777 // Use HTTP for localhost/IP addresses with ports, HTTPS for domains
7878 if strings.Contains(hostname, ":") ||
7979- strings.Contains(hostname, "127.0.0.1") ||
8080- strings.Contains(hostname, "localhost") ||
8181- // Check if it's an IP address (contains only digits and dots)
8282- (len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) {
7979+ strings.Contains(hostname, "127.0.0.1") ||
8080+ strings.Contains(hostname, "localhost") ||
8181+ // Check if it's an IP address (contains only digits and dots)
8282+ (len(hostname) > 0 && (hostname[0] >= '0' && hostname[0] <= '9')) {
8383 return "http://" + hostname
8484 }
8585 return "https://" + hostname
···11package atproto
2233-//go:generate go run github.com/whyrusleeping/cbor-gen --map-encoding CrewRecord CaptainRecord
44-53import (
64 "encoding/base64"
75 "encoding/json"
···221219 }
222220}
223221224224-// HoldCrewRecord represents membership in a storage hold
225225-// Stored in the hold owner's PDS (not the crew member's PDS) to ensure owner maintains full control
226226-// Owner can add/remove crew members by creating/deleting these records in their own PDS
227227-// Supports both explicit DIDs (with backlinks) and pattern-based matching (wildcards, handle globs)
228228-type HoldCrewRecord struct {
229229- // Type should be "io.atcr.hold.crew"
230230- Type string `json:"$type"`
231231-232232- // Hold is the AT URI of the hold record
233233- // e.g., "at://did:plc:owner/io.atcr.hold/hold1"
234234- Hold string `json:"hold"`
235235-236236- // Member is the DID of the crew member (optional, for explicit access)
237237- // Exactly one of Member or MemberPattern must be set
238238- Member *string `json:"member,omitempty"`
239239-240240- // MemberPattern is a pattern for matching multiple users (optional, for pattern-based access)
241241- // Supports wildcards: "*" (all users), "*.domain.com" (handle glob)
242242- // Exactly one of Member or MemberPattern must be set
243243- MemberPattern *string `json:"memberPattern,omitempty"`
244244-245245- // Role defines permissions: "owner", "write", "read"
246246- Role string `json:"role"`
247247-248248- // ExpiresAt is optional expiration for this membership
249249- ExpiresAt *time.Time `json:"expiresAt,omitempty"`
250250-251251- // AddedAt timestamp
252252- AddedAt time.Time `json:"createdAt"`
253253-}
254254-255255-// NewHoldCrewRecord creates a new hold crew record with explicit DID
256256-func NewHoldCrewRecord(hold, member, role string) *HoldCrewRecord {
257257- return &HoldCrewRecord{
258258- Type: HoldCrewCollection,
259259- Hold: hold,
260260- Member: &member,
261261- Role: role,
262262- AddedAt: time.Now(),
263263- }
264264-}
265265-266266-// NewHoldCrewRecordWithPattern creates a new hold crew record with pattern matching
267267-func NewHoldCrewRecordWithPattern(hold, pattern, role string) *HoldCrewRecord {
268268- return &HoldCrewRecord{
269269- Type: HoldCrewCollection,
270270- Hold: hold,
271271- MemberPattern: &pattern,
272272- Role: role,
273273- AddedAt: time.Now(),
274274- }
275275-}
276276-277222// SailorProfileRecord represents a user's profile with registry preferences
278223// Stored in the user's PDS to configure default hold and other settings
279224type SailorProfileRecord struct {
···388333 // Convert to did:web
389334 // did:web uses hostname directly (port included if non-standard)
390335 return "did:web:" + hostname
336336+}
337337+338338+// isDID checks if a string is a DID (starts with "did:")
339339+func isDID(s string) bool {
340340+ return len(s) > 4 && s[:4] == "did:"
391341}
392342393343// =============================================================================
···2121// ManifestStore implements distribution.ManifestService
2222// It stores manifests in ATProto as records
2323type ManifestStore struct {
2424- client *Client
2525- repository string
2626- holdEndpoint string // Hold service endpoint URL (for legacy, to be deprecated)
2727- holdDID string // Hold service DID (primary reference)
2828- did string // User's DID for cache key
2929- lastFetchedHoldDID string // Hold DID from most recently fetched manifest (for pull)
3030- blobStore distribution.BlobStore // Blob store for fetching config during push
3131- database DatabaseMetrics // Database for metrics tracking
2424+ client *Client
2525+ repository string
2626+ holdEndpoint string // Hold service endpoint URL (for legacy, to be deprecated)
2727+ holdDID string // Hold service DID (primary reference)
2828+ did string // User's DID for cache key
2929+ lastFetchedHoldDID string // Hold DID from most recently fetched manifest (for pull)
3030+ blobStore distribution.BlobStore // Blob store for fetching config during push
3131+ database DatabaseMetrics // Database for metrics tracking
3232}
33333434// NewManifestStore creates a new ATProto-backed manifest store
+1-11
pkg/atproto/profile.go
···5555 record, err := client.GetRecord(ctx, SailorProfileCollection, ProfileRKey)
5656 if err != nil {
5757 // Check if it's a 404 (profile doesn't exist)
5858- if isNotFoundError(err) {
5858+ if errors.Is(err, ErrRecordNotFound) {
5959 return nil, nil
6060 }
6161 return nil, fmt.Errorf("failed to get profile: %w", err)
···104104 return &profile, nil
105105}
106106107107-// isDID checks if a string is a DID (starts with "did:")
108108-func isDID(s string) bool {
109109- return len(s) > 4 && s[:4] == "did:"
110110-}
111111-112107// UpdateProfile updates the user's profile
113108// Normalizes defaultHold to DID format before saving
114109func UpdateProfile(ctx context.Context, client *Client, profile *SailorProfileRecord) error {
···125120 }
126121 return nil
127122}
128128-129129-// isNotFoundError checks if an error is a record not found error
130130-func isNotFoundError(err error) bool {
131131- return errors.Is(err, ErrRecordNotFound)
132132-}