Oauth. Car Backups.

This commit is contained in:
2026-02-04 17:01:54 -06:00
parent 486c07f2d4
commit 4ed5d821da
14 changed files with 524 additions and 62 deletions

View File

@@ -1,52 +0,0 @@
/*
Copyright © Brian Buller <brian@bullercodeworks.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
*/
package data
import (
"fmt"
"time"
)
type AppLog struct {
tm time.Time
log string
err error
}
func NewAppError(err error) AppLog {
return AppLog{
tm: time.Now(),
log: err.Error(),
err: err,
}
}
func NewAppLog(txt string) AppLog {
return AppLog{
tm: time.Now(),
log: txt,
}
}
func (a AppLog) String() string {
return fmt.Sprintf("%s: %s", a.tm, a.log)
}

50
data/app_log_handler.go Normal file
View File

@@ -0,0 +1,50 @@
package data
import (
"context"
"errors"
"log/slog"
"time"
)
type AppLogHandler struct {
level slog.Level
logFunc func(string, ...any)
attrs []slog.Attr
groups []string
}
func NewAppLogHandler(f func(string, ...any)) *AppLogHandler {
return &AppLogHandler{logFunc: f}
}
func (a *AppLogHandler) SetLevel(l slog.Level) { a.level = l }
func (a *AppLogHandler) setAttrs(attrs ...slog.Attr) { a.attrs = attrs }
func (a *AppLogHandler) addAttrs(attrs ...slog.Attr) { a.attrs = append(a.attrs, attrs...) }
func (a *AppLogHandler) setGroups(groups ...string) { a.groups = groups }
func (a *AppLogHandler) addGroups(groups ...string) { a.groups = append(a.groups, groups...) }
// AppLogHandler can handle all levels
func (a *AppLogHandler) Enabled(_ context.Context, lvl slog.Level) bool { return lvl >= a.level }
func (a *AppLogHandler) Handle(ctx context.Context, rcd slog.Record) error {
if a.logFunc == nil {
return errors.New("no log func defined")
}
a.logFunc("%s %s %s", rcd.Time.Format(time.TimeOnly), rcd.Level, rcd.Message)
return nil
}
func (a *AppLogHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
attrs = append(attrs, a.attrs...)
ret := NewAppLogHandler(a.logFunc)
ret.addAttrs(attrs...)
return ret
}
func (a *AppLogHandler) WithGroup(name string) slog.Handler {
groups := append(a.groups, name)
ret := NewAppLogHandler(a.logFunc)
ret.setGroups(groups...)
return ret
}

View File

@@ -28,4 +28,5 @@ const (
KeyDataDir = "data"
KeyVimMode = "vimMode"
KeyRecNmInfer = "inferRecNm"
KeyBackupDir = "backup"
)

View File

@@ -25,6 +25,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"slices"
"time"
@@ -104,6 +105,32 @@ func NewPdsFromDid(id string) (*Pds, error) {
return ret, ret.unpack()
}
func (p *Pds) Backup() (string, int64, error) {
carPath := helpers.Path(viper.GetString("data"), p.Did.String()+".car")
srcStat, err := os.Stat(carPath)
if err != nil {
return "", 0, fmt.Errorf("car file not found: %w", err)
}
if !srcStat.Mode().IsRegular() {
return "", 0, fmt.Errorf("%s is not a regular file", carPath)
}
source, err := os.Open(carPath)
if err != nil {
return "", 0, fmt.Errorf("error opening car file: %w", err)
}
defer source.Close()
bkupName := fmt.Sprintf("%s%s.car", p.Did.String(), time.Now().Format("20060102150405"))
bkupPath := helpers.Path(viper.GetString("backup"), bkupName)
dest, err := os.Create(bkupPath)
if err != nil {
return "", 0, fmt.Errorf("error creating backup file: %w", err)
}
defer dest.Close()
bt, err := io.Copy(dest, source)
return bkupName, bt, err
}
func (p *Pds) unpack() error {
ctx := context.Background()
fi, err := os.Open(p.localPath)

View File

@@ -22,22 +22,40 @@ THE SOFTWARE.
package data
import (
"log/slog"
"time"
"git.bullercodeworks.com/brian/expds/data/models"
"github.com/spf13/viper"
)
type Repo struct {
LoadedPDSs map[string]*models.Pds
BestBy time.Duration
BestBy time.Duration
Auth *AuthRepo
handler *AppLogHandler
logFunc func(string, ...any)
}
func NewRepo() (*Repo, error) {
return &Repo{
r := &Repo{
LoadedPDSs: make(map[string]*models.Pds),
BestBy: time.Minute * 15,
}, nil
handler: NewAppLogHandler(nil),
}
if viper.GetBool(KeyDebug) {
r.handler.SetLevel(slog.LevelDebug)
} else {
r.handler.SetLevel(slog.LevelWarn)
}
var err error
r.Auth, err = NewAuthRepo(r)
if err != nil {
return nil, err
}
return r, nil
}
func (r *Repo) GetPDS(atId string) (*models.Pds, error) {
@@ -51,3 +69,9 @@ func (r *Repo) GetPDS(atId string) (*models.Pds, error) {
r.LoadedPDSs[atId] = p
return p, nil
}
func (r *Repo) SetLogFunc(l func(string, ...any)) {
r.logFunc = l
r.handler = NewAppLogHandler(r.logFunc)
slog.SetDefault(slog.New(r.handler))
}

112
data/repo_auth.go Normal file
View File

@@ -0,0 +1,112 @@
package data
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/spf13/viper"
)
type AuthRepo struct {
r *Repo
oauthClient *oauth.ClientApp
oauthConfig *oauth.ClientConfig
store *SqliteStore
session *oauth.ClientSessionData
}
func NewAuthRepo(r *Repo) (*AuthRepo, error) {
a := &AuthRepo{r: r}
var err error
a.oauthConfig, a.oauthClient, a.store, err = a.buildOAuthClient()
if err != nil {
return nil, err
}
return a, nil
}
// Build the OAuthClient connected to our sqlite db
func (r *AuthRepo) buildOAuthClient() (*oauth.ClientConfig, *oauth.ClientApp, *SqliteStore, error) {
config := oauth.ClientConfig{
ClientID: "https://expds.bullercodeworks.com/oauth-client-metadata.json",
Scopes: []string{"atproto", "repo:*"},
UserAgent: "expds",
}
store, err := NewSqliteStore(&SqliteStoreConfig{
DatabasePath: r.prepareDbPath(),
SessionExpiryDuration: time.Hour * 24 * 90,
SessionInactivityDuration: time.Hour * 24 * 14,
AuthRequestExpiryDuration: time.Minute * 30,
})
if err != nil {
return nil, nil, nil, err
}
oauthClient := oauth.NewClientApp(&config, store)
return &config, oauthClient, store, nil
}
func (r *AuthRepo) StartAuthFlow(port int, ctx context.Context, identifier string, callbackRes chan url.Values) (string, error) {
r.oauthConfig.CallbackURL = fmt.Sprintf("http://127.0.0.1:%d/callback", port)
authUrl, err := r.oauthClient.StartAuthFlow(ctx, identifier)
if err != nil {
return "", fmt.Errorf("error logging in: %w", err)
}
if !strings.HasPrefix(authUrl, "https://") {
return "", fmt.Errorf("non-https authUrl")
}
exec.Command("xdg-open", authUrl).Run()
r.session, err = r.oauthClient.ProcessCallback(ctx, <-callbackRes)
if err != nil {
return "", err
}
return authUrl, nil
}
// Follows XDG conventions and creates the directories if necessary.
// By default, on linux, this will be "~/.local/share/go-oauth-cli-app/oauth_sessions.sqlite3"
func (r *AuthRepo) prepareDbPath() string {
return filepath.Join(viper.GetString(KeyDataDir), "expds.sqlite3")
}
// HTTP Server listening for OAuth Response
func (r *AuthRepo) ListenForCallback(ctx context.Context, res chan url.Values) (int, error) {
listener, err := net.Listen("tcp", ":0")
if err != nil {
return 0, err
}
mux := http.NewServeMux()
server := &http.Server{
Handler: mux,
}
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
res <- r.URL.Query()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(200)
w.Write([]byte("<!DOCTYPE html><html><body><h2>expds</h2><p>You can safely close this window and return to your application.</p></body></html>\n"))
go server.Shutdown(ctx)
})
go func() {
err := server.Serve(listener)
if !errors.Is(err, http.ErrServerClosed) {
panic(err)
}
}()
return listener.Addr().(*net.TCPAddr).Port, nil
}

158
data/repo_auth_store.go Normal file
View File

@@ -0,0 +1,158 @@
package data
import (
"context"
"fmt"
"time"
"github.com/bluesky-social/indigo/atproto/auth/oauth"
"github.com/bluesky-social/indigo/atproto/syntax"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Taken from https://github.com/bluesky-social/cookbook/blob/main/go-oauth-cli-app/sqlitestore.go
type SqliteStoreConfig struct {
DatabasePath string
// The purpose of these limits is to avoid dead sessions hanging around in the db indefinitely.
// The durations here should be *at least as long as* the expected duration of the oauth session itself.
SessionExpiryDuration time.Duration // duration since session creation
SessionInactivityDuration time.Duration // duration since last session update
AuthRequestExpiryDuration time.Duration // duration since auth request creation
}
// Implements the [oauth.ClientAuthStore] interface, backed by sqlite via gorm
//
// gorm might be overkill here, but it means it's easy to port this to a different db backend
type SqliteStore struct {
db *gorm.DB
cfg *SqliteStoreConfig
// gorm itself is thread-safe, so no need for a lock
}
var _ oauth.ClientAuthStore = &SqliteStore{}
type storedSessionData struct {
AccountDid syntax.DID `gorm:"primaryKey"`
SessionID string `gorm:"primaryKey"`
Data oauth.ClientSessionData `gorm:"serializer:json"`
CreatedAt time.Time `gorm:"index"`
UpdatedAt time.Time `gorm:"index"`
}
type storedAuthRequest struct {
State string `gorm:"primaryKey"`
Data oauth.AuthRequestData `gorm:"serializer:json"`
CreatedAt time.Time `gorm:"index"`
}
func NewSqliteStore(cfg *SqliteStoreConfig) (*SqliteStore, error) {
if cfg == nil {
return nil, fmt.Errorf("missing cfg")
}
if cfg.DatabasePath == "" {
return nil, fmt.Errorf("missing DatabasePath")
}
if cfg.SessionExpiryDuration == 0 {
return nil, fmt.Errorf("missing SessionExpiryDuration")
}
if cfg.SessionInactivityDuration == 0 {
return nil, fmt.Errorf("missing SessionInactivityDuration")
}
if cfg.AuthRequestExpiryDuration == 0 {
return nil, fmt.Errorf("missing AuthRequestExpiryDuration")
}
db, err := gorm.Open(sqlite.Open(cfg.DatabasePath), &gorm.Config{})
if err != nil {
return nil, fmt.Errorf("failed opening db: %w", err)
}
db.AutoMigrate(&storedSessionData{})
db.AutoMigrate(&storedAuthRequest{})
return &SqliteStore{db, cfg}, nil
}
func (m *SqliteStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
// bookkeeping: delete expired sessions
expiry_threshold := time.Now().Add(-m.cfg.SessionExpiryDuration)
inactive_threshold := time.Now().Add(-m.cfg.SessionInactivityDuration)
m.db.WithContext(ctx).Where(
"created_at < ? OR updated_at < ?", expiry_threshold, inactive_threshold,
).Delete(&storedSessionData{})
// finally, the query itself
var row storedSessionData
res := m.db.WithContext(ctx).Where(&storedSessionData{
AccountDid: did,
SessionID: sessionID,
}).First(&row)
if res.Error != nil {
return nil, res.Error
}
return &row.Data, nil
}
// not part of the ClientAuthStore interface, just used for the CLI app
func (m *SqliteStore) GetMostRecentSession(ctx context.Context) (*oauth.ClientSessionData, error) {
var row storedSessionData
res := m.db.WithContext(ctx).Order(clause.OrderByColumn{
Column: clause.Column{Name: "updated_at"},
Desc: true,
}).First(&row)
if res.Error != nil {
return nil, res.Error
}
return &row.Data, nil
}
func (m *SqliteStore) SaveSession(ctx context.Context, sess oauth.ClientSessionData) error {
// upsert
res := m.db.WithContext(ctx).Clauses(clause.OnConflict{
UpdateAll: true,
}).Create(&storedSessionData{
AccountDid: sess.AccountDID,
SessionID: sess.SessionID,
Data: sess,
})
return res.Error
}
func (m *SqliteStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error {
res := m.db.WithContext(ctx).Delete(&storedSessionData{
AccountDid: did,
SessionID: sessionID,
})
return res.Error
}
func (m *SqliteStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) {
// bookkeeping: delete expired auth requests
threshold := time.Now().Add(-m.cfg.AuthRequestExpiryDuration)
m.db.WithContext(ctx).Where("created_at < ?", threshold).Delete(&storedAuthRequest{})
// finally, the query itself
var row storedAuthRequest
res := m.db.WithContext(ctx).Where(&storedAuthRequest{State: state}).First(&row)
if res.Error != nil {
return nil, res.Error
}
return &row.Data, nil
}
func (m *SqliteStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error {
// will fail if an auth request already exists for the same state
res := m.db.WithContext(ctx).Create(&storedAuthRequest{
State: info.State,
Data: info,
})
return res.Error
}
func (m *SqliteStore) DeleteAuthRequestInfo(ctx context.Context, state string) error {
res := m.db.WithContext(ctx).Delete(&storedAuthRequest{State: state})
return res.Error
}