package users import ( "context" "encoding/base64" "errors" "fmt" "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) const maxAvatarBytes = 512 * 1024 var ( ErrAvatarTooLarge = errors.New("avatar too large") ErrAvatarInvalid = errors.New("avatar invalid") ErrAvatarNotFound = errors.New("avatar not found") ) var allowedAvatarMIME = map[string]struct{}{ "image/jpeg": {}, "image/png": {}, "image/gif": {}, "image/webp": {}, } // GetAvatarURL returns the stored avatar URL/data URI for external_id. func GetAvatarURL(ctx context.Context, db *pgxpool.Pool, externalID string) (string, error) { if db == nil || strings.TrimSpace(externalID) == "" { return "", nil } var avatarURL *string err := db.QueryRow(ctx, ` SELECT avatar_url FROM users WHERE external_id = $1 `, externalID).Scan(&avatarURL) if errors.Is(err, pgx.ErrNoRows) { return "", nil } if err != nil { return "", err } if avatarURL == nil { return "", nil } return strings.TrimSpace(*avatarURL), nil } // SetAvatarURL validates and stores avatar_url for external_id. func SetAvatarURL(ctx context.Context, db *pgxpool.Pool, externalID, avatarURL string) error { if db == nil { return fmt.Errorf("database not configured") } if strings.TrimSpace(externalID) == "" { return fmt.Errorf("missing external id") } normalized, err := normalizeAvatarURL(avatarURL) if err != nil { return err } tag, err := db.Exec(ctx, ` UPDATE users SET avatar_url = $2, updated_at = NOW() WHERE external_id = $1 `, externalID, normalized) if err != nil { return err } if tag.RowsAffected() == 0 { return pgx.ErrNoRows } return nil } // ClearAvatarURL removes the stored avatar for external_id. func ClearAvatarURL(ctx context.Context, db *pgxpool.Pool, externalID string) error { if db == nil { return fmt.Errorf("database not configured") } if strings.TrimSpace(externalID) == "" { return fmt.Errorf("missing external id") } tag, err := db.Exec(ctx, ` UPDATE users SET avatar_url = NULL, updated_at = NOW() WHERE external_id = $1 `, externalID) if err != nil { return err } if tag.RowsAffected() == 0 { return pgx.ErrNoRows } return nil } func normalizeAvatarURL(raw string) (string, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { return "", ErrAvatarInvalid } if strings.HasPrefix(trimmed, "https://") || strings.HasPrefix(trimmed, "http://") { if len(trimmed) > 2048 { return "", ErrAvatarTooLarge } return trimmed, nil } if !strings.HasPrefix(trimmed, "data:") { return "", ErrAvatarInvalid } comma := strings.Index(trimmed, ",") if comma == -1 { return "", ErrAvatarInvalid } meta := trimmed[:comma] payload := strings.TrimSpace(trimmed[comma+1:]) if !strings.Contains(meta, ";base64") { return "", ErrAvatarInvalid } mimePart := strings.TrimPrefix(meta, "data:") mimePart = strings.Split(mimePart, ";")[0] if _, ok := allowedAvatarMIME[strings.ToLower(mimePart)]; !ok { return "", ErrAvatarInvalid } decoded, err := base64.StdEncoding.DecodeString(payload) if err != nil { return "", ErrAvatarInvalid } if len(decoded) == 0 || len(decoded) > maxAvatarBytes { return "", ErrAvatarTooLarge } return trimmed, nil }