Oauth. Car Backups.
This commit is contained in:
@@ -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
50
data/app_log_handler.go
Normal 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
|
||||
}
|
||||
@@ -28,4 +28,5 @@ const (
|
||||
KeyDataDir = "data"
|
||||
KeyVimMode = "vimMode"
|
||||
KeyRecNmInfer = "inferRecNm"
|
||||
KeyBackupDir = "backup"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
30
data/repo.go
30
data/repo.go
@@ -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
112
data/repo_auth.go
Normal 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
158
data/repo_auth_store.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user