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

@@ -42,8 +42,6 @@ type App struct {
screen AppScreen
repo *data.Repo
AppLogs []data.AppLog
style tcell.Style
}

View File

@@ -22,7 +22,9 @@ THE SOFTWARE.
package app
import (
"context"
"fmt"
"net/url"
"strings"
"git.bullercodeworks.com/brian/expds/data"
@@ -42,6 +44,9 @@ type ScreenHome struct {
w, h int
style tcell.Style
alert *w.Alert
showAlert bool
menuLayout *w.TopMenuLayout
openPdsEntry *w.Field
@@ -66,8 +71,12 @@ type ScreenHome struct {
func (s *ScreenHome) Init(a *App) {
s.a, s.r = a, a.repo
s.r.SetLogFunc(s.Log)
s.style = a.style
s.alert = w.NewAlert("expds.alert", s.style)
s.openPdsEntry = w.NewField("home.openpds.field", s.style)
s.openPdsEntry.SetLabel("ID")
s.openPdsEntry.SetActive(true)
@@ -149,12 +158,16 @@ func (s *ScreenHome) GetName() string { return "home" }
func (s *ScreenHome) HandleResize(ev *tcell.EventResize) {
s.w, s.h = ev.Size()
s.menuLayout.HandleResize(w.Coord{X: s.w, Y: s.h - 1}.ResizeEvent())
s.alert.HandleResize(ev)
s.status.SetPos(w.Coord{X: 0, Y: s.h - 1})
s.status.HandleResize(w.Coord{X: s.w, Y: 1}.ResizeEvent())
}
func (s *ScreenHome) HandleKey(ev *tcell.EventKey) bool {
if s.showAlert {
return s.alert.HandleKey(ev)
}
if ev.Key() == tcell.KeyF12 {
s.toggleCli()
return true
@@ -201,6 +214,9 @@ func (s *ScreenHome) Draw() {
s.a.DrawWidget(s.loading)
}
s.a.DrawWidget(s.status)
if s.showAlert {
s.a.DrawWidget(s.alert)
}
}
func (s *ScreenHome) Exit() error { return nil }
func (s *ScreenHome) Log(t string, a ...any) {
@@ -293,6 +309,8 @@ func (s *ScreenHome) update() {
func (s *ScreenHome) initCli() {
s.cli.SetVisible(true)
s.cli.AddCommand(w.NewCliCommand("getpds", s.cliGetPds))
s.cli.AddCommand(w.NewCliCommand("authpds", s.cliAuthPds))
s.cli.AddCommand(w.NewCliCommand("backuppds", s.cliBackupPds))
}
func (s *ScreenHome) toggleCli() {
@@ -325,14 +343,13 @@ func (s *ScreenHome) cliGetPds(args ...string) bool {
return true
}
go func() {
defer func() { s.isLoading = false }()
pds, err := s.r.GetPDS(args[1])
if err != nil {
s.Log(err.Error())
s.isLoading = false
return
} else if pds == nil {
s.Log("PDS (%s) Not Found.", args[1])
s.isLoading = false
return
}
s.doOpen = false
@@ -346,7 +363,53 @@ func (s *ScreenHome) cliGetPds(args ...string) bool {
s.layout.ActivateWidget(s.columns)
s.columns.ActivateWidget(s.pdsListing)
s.isLoading = false
}()
return true
}
func (s *ScreenHome) cliAuthPds(args ...string) bool {
if s.activePds == nil {
s.Log("No active PDS.")
return true
}
s.isLoading = true
ctx := context.Background()
go func() {
defer func() { s.isLoading = false }()
atid := s.activePds.AtId.String()
callbackRes := make(chan url.Values, 1)
listenPort, err := s.r.Auth.ListenForCallback(ctx, callbackRes)
if err != nil {
s.Log("Error Instantiating HTTP Server for Callback: %w", err)
return
}
s.Log("Listening on %d", listenPort)
var authUrl string
authUrl, err = s.r.Auth.StartAuthFlow(listenPort, ctx, atid, callbackRes)
if err != nil {
s.Log("Error starting auth flow: %w", err)
}
s.alert.SetTitle("Authentication Started")
s.alert.SetMessage(fmt.Sprintf("OAuth Process Started.\nIf a browser window didn't open, you can open this URL manually:\n%s", authUrl))
s.showAlert = true
}()
return true
}
func (s *ScreenHome) cliBackupPds(args ...string) bool {
if s.activePds == nil {
s.Log("No active PDS.")
return true
}
s.isLoading = true
go func() {
defer func() { s.isLoading = false }()
nm, sz, err := s.activePds.Backup()
if err != nil {
s.Log("Error: %w", err)
return
}
s.Log("Backup Created: %s (%d bytes)", nm, sz)
}()
return true
}

View File

@@ -24,9 +24,11 @@ package cmd
import (
"fmt"
"os"
"path/filepath"
"git.bullercodeworks.com/brian/expds/data"
"git.bullercodeworks.com/brian/expds/helpers"
"github.com/adrg/xdg"
gap "github.com/muesli/go-app-paths"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@@ -78,9 +80,12 @@ func initConfig() {
viper.SetConfigType("yaml")
}
// Default Data dir:
appDataDir := filepath.Join(xdg.DataHome, "expds")
viper.SetDefault(data.KeyConfigDir, firstDir)
viper.SetDefault(data.KeyDebug, false)
viper.SetDefault(data.KeyDataDir, helpers.Path(firstDir, "data"))
viper.SetDefault(data.KeyDataDir, appDataDir)
viper.SetDefault(data.KeyBackupDir, filepath.Join(appDataDir, "backups"))
viper.SetDefault(data.KeyVimMode, false)
viper.SetConfigFile(cfgFile)
viper.AutomaticEnv()
@@ -119,7 +124,18 @@ func initConfig() {
_, err := os.Stat(dDir)
if os.IsNotExist(err) {
fmt.Println("Creating Data Directory:", dDir)
err := os.Mkdir(dDir, 0o755)
err := os.MkdirAll(dDir, 0o755)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}
bDir := viper.GetString(data.KeyBackupDir)
_, err = os.Stat(bDir)
if os.IsNotExist(err) {
fmt.Println("Creating Backups Directory:", bDir)
err := os.MkdirAll(bDir, 0o755)
if err != nil {
fmt.Println(err)
os.Exit(1)

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
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
}

8
go.mod
View File

@@ -4,6 +4,7 @@ go 1.25.1
require (
git.bullercodeworks.com/brian/tcell-widgets v0.3.2
github.com/adrg/xdg v0.5.3
github.com/bluesky-social/indigo v0.0.0-20260120225912-12d69fa4d209
github.com/gdamore/tcell v1.4.1
github.com/ipfs/go-cid v0.4.1
@@ -12,6 +13,8 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
golang.design/x/clipboard v0.7.1
gorm.io/driver/sqlite v1.5.5
gorm.io/gorm v1.25.9
)
require (
@@ -27,6 +30,8 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/uuid v1.4.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
@@ -53,10 +58,13 @@ require (
github.com/ipld/go-codec-dagpb v1.6.0 // indirect
github.com/ipld/go-ipld-prime v0.21.0 // indirect
github.com/jbenet/goprocess v0.1.4 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/minio/sha256-simd v1.0.1 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect

18
go.sum
View File

@@ -17,6 +17,8 @@ git.bullercodeworks.com/brian/tcell-widgets v0.3.1/go.mod h1:3TlKbuGjY8nrKL5Qcp2
git.bullercodeworks.com/brian/tcell-widgets v0.3.2 h1:N2WdJmMhbQKXFaB2inbxtK9pjaj/WCY/O8s15uCJtOQ=
git.bullercodeworks.com/brian/tcell-widgets v0.3.2/go.mod h1:3TlKbuGjY8nrKL5Qcp28h+KnEsXBl3iCwACTy79bdPg=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
@@ -53,6 +55,12 @@ github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlnd
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -112,6 +120,10 @@ github.com/ipld/go-ipld-prime v0.21.0/go.mod h1:3RLqy//ERg/y5oShXXdx5YIp50cFGOan
github.com/jbenet/go-cienv v0.1.0/go.mod h1:TqNnHUmJgXau0nCzC7kXWeotg3J9W34CUv5Djy1+FlA=
github.com/jbenet/goprocess v0.1.4 h1:DRGOFReOMqqDNXwW70QkacFW0YN9QnwLV0Vqk+3oU0o=
github.com/jbenet/goprocess v0.1.4/go.mod h1:5yspPrukOVuOLORacaBi858NqyClJPQxYZlqdZVfqY4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
@@ -129,6 +141,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
@@ -326,6 +340,10 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8=
gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
lukechampine.com/blake3 v1.2.1 h1:YuqqRuaqsGV71BV/nm9xlI0MKUv4QC54jQnBChWbGnI=
lukechampine.com/blake3 v1.2.1/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=

20
static/index.html Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en_US">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">
<meta http-equiv="Content-Script-Type" content="text/javascript">
<meta name="keywords" content="PDS,Explorer,Command Line,CLI">
<meta name="description" content="expds CLI PDS Explorer">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>expds - CLI PDS Explorer</title>
</head>
<body>
<h1>expds</h1>
<footer>
<div id="credits">
<p>© Brian Buller</p>
</div>
</footer>
</body>
</html>

View File

@@ -0,0 +1,19 @@
{
"client_id": "https://expds.bullercodeworks.com/oauth-client-metadata.json",
"application_type": "native",
"grant_types": [
"authorization_code",
"refresh_token"
],
"scope": "atproto repo:*",
"response_types": [
"code"
],
"redirect_uris": [
"http://127.0.0.1/callback"
],
"token_endpoint_auth_method": "none",
"dpop_bound_access_tokens": true,
"client_name": "expds"
}