this repo has no description

Initial boilerplate

Marius Kimmina 34741c1f

Changed files
+597
appview
cmd
appview
log
+32
.gitignore
···
··· 1 + *.exe 2 + *.exe~ 3 + *.dll 4 + *.so 5 + *.dylib 6 + 7 + # Test binary, built with `go test -c` 8 + *.test 9 + 10 + # Code coverage profiles and other test artifacts 11 + *.out 12 + coverage.* 13 + *.coverprofile 14 + profile.cov 15 + 16 + # Dependency directories (remove the comment below to include it) 17 + # vendor/ 18 + 19 + # Go workspace file 20 + go.work 21 + go.work.sum 22 + 23 + # env file 24 + .env 25 + 26 + # Editor/IDE 27 + # .idea/ 28 + # .vscode/ 29 + 30 + # Application-specific 31 + *.db 32 + /cmd/appview/appview
+53
appview/config/config.go
···
··· 1 + package config 2 + 3 + import ( 4 + "context" 5 + 6 + "github.com/sethvargo/go-envconfig" 7 + ) 8 + 9 + type CoreConfig struct { 10 + CookieSecret string `env:"COOKIE_SECRET, default=00000000000000000000000000000000"` 11 + DbPath string `env:"DB_PATH, default=appview.db"` 12 + ListenAddr string `env:"LISTEN_ADDR, default=0.0.0.0:3000"` 13 + AppviewHost string `env:"APPVIEW_HOST, default=https://recard.blue"` 14 + AppviewName string `env:"APPVIEW_Name, default=Recard"` 15 + Dev bool `env:"DEV, default=false"` 16 + DisallowedNicknamesFile string `env:"DISALLOWED_NICKNAMES_FILE"` 17 + } 18 + 19 + type OAuthConfig struct { 20 + ClientSecret string `env:"CLIENT_SECRET"` 21 + ClientKid string `env:"CLIENT_KID"` 22 + } 23 + 24 + type PlcConfig struct { 25 + PLCURL string `env:"URL, default=https://plc.directory"` 26 + } 27 + 28 + type JetstreamConfig struct { 29 + Endpoint string `env:"ENDPOINT, default=wss://jetstream1.us-east.bsky.network/subscribe"` 30 + } 31 + 32 + type PdsConfig struct { 33 + Host string `env:"HOST, default=https://tngl.sh"` 34 + AdminSecret string `env:"ADMIN_SECRET"` 35 + } 36 + 37 + type Config struct { 38 + Core CoreConfig `env:",prefix=RECARD_"` 39 + Jetstream JetstreamConfig `env:",prefix=RECARD_JETSTREAM_"` 40 + OAuth OAuthConfig `env:",prefix=RECARD_OAUTH_"` 41 + Plc PlcConfig `env:",prefix=RECARD_PLC_"` 42 + Pds PdsConfig `env:",prefix=RECARD_PDS_"` 43 + } 44 + 45 + func LoadConfig(ctx context.Context) (*Config, error) { 46 + var cfg Config 47 + err := envconfig.Process(ctx, &cfg) 48 + if err != nil { 49 + return nil, err 50 + } 51 + 52 + return &cfg, nil 53 + }
+105
appview/oauth/oauth.go
···
··· 1 + package oauth 2 + 3 + import ( 4 + "context" 5 + "crypto/rand" 6 + "encoding/base64" 7 + "fmt" 8 + "net/http" 9 + "sync" 10 + 11 + "recard.blue/appview/config" 12 + ) 13 + 14 + type OAuth struct { 15 + config *config.Config 16 + ClientApp *ClientApp 17 + sessions map[string]*Session 18 + mu sync.RWMutex 19 + } 20 + 21 + type ClientApp struct { 22 + oauth *OAuth 23 + } 24 + 25 + type Session struct { 26 + Handle string 27 + State string 28 + } 29 + 30 + func New(ctx context.Context, c *config.Config) (*OAuth, error) { 31 + o := &OAuth{ 32 + config: c, 33 + sessions: make(map[string]*Session), 34 + } 35 + o.ClientApp = &ClientApp{oauth: o} 36 + return o, nil 37 + } 38 + 39 + func (o *OAuth) Close() error { 40 + return nil 41 + } 42 + 43 + func (ca *ClientApp) StartAuthFlow(ctx context.Context, handle string) (string, error) { 44 + state := generateState() 45 + 46 + ca.oauth.mu.Lock() 47 + ca.oauth.sessions[state] = &Session{ 48 + Handle: handle, 49 + State: state, 50 + } 51 + ca.oauth.mu.Unlock() 52 + 53 + authURL := fmt.Sprintf("%s/oauth/callback?state=%s&handle=%s", 54 + ca.oauth.config.Core.AppviewHost, state, handle) 55 + 56 + return authURL, nil 57 + } 58 + 59 + func (o *OAuth) DeleteSession(w http.ResponseWriter, r *http.Request) error { 60 + cookie, err := r.Cookie("session") 61 + if err == nil { 62 + o.mu.Lock() 63 + delete(o.sessions, cookie.Value) 64 + o.mu.Unlock() 65 + } 66 + 67 + http.SetCookie(w, &http.Cookie{ 68 + Name: "session", 69 + Value: "", 70 + Path: "/", 71 + MaxAge: -1, 72 + }) 73 + 74 + return nil 75 + } 76 + 77 + func (o *OAuth) Callback(w http.ResponseWriter, r *http.Request) { 78 + state := r.URL.Query().Get("state") 79 + 80 + o.mu.RLock() 81 + _, exists := o.sessions[state] 82 + o.mu.RUnlock() 83 + 84 + if !exists { 85 + http.Error(w, "Invalid session", http.StatusBadRequest) 86 + return 87 + } 88 + 89 + http.SetCookie(w, &http.Cookie{ 90 + Name: "session", 91 + Value: state, 92 + Path: "/", 93 + HttpOnly: true, 94 + Secure: !o.config.Core.Dev, 95 + SameSite: http.SameSiteLaxMode, 96 + }) 97 + 98 + http.Redirect(w, r, "/", http.StatusFound) 99 + } 100 + 101 + func generateState() string { 102 + b := make([]byte, 32) 103 + rand.Read(b) 104 + return base64.URLEncoding.EncodeToString(b) 105 + }
+66
appview/pages/pages.go
···
··· 1 + package pages 2 + 3 + import ( 4 + "embed" 5 + "html/template" 6 + "net/http" 7 + ) 8 + 9 + //go:embed templates/*.html 10 + var templatesFS embed.FS 11 + 12 + type Pages struct { 13 + templates *template.Template 14 + } 15 + 16 + type LoginParams struct { 17 + ReturnUrl string 18 + ErrorCode string 19 + } 20 + 21 + type loginData struct { 22 + ErrorMsg string 23 + } 24 + 25 + type noticeData struct { 26 + ID string 27 + Message string 28 + } 29 + 30 + func New() *Pages { 31 + tmpl := template.Must(template.ParseFS(templatesFS, "templates/*.html")) 32 + return &Pages{ 33 + templates: tmpl, 34 + } 35 + } 36 + 37 + func (p *Pages) Index(w http.ResponseWriter) { 38 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 39 + p.templates.ExecuteTemplate(w, "index.html", nil) 40 + } 41 + 42 + func (p *Pages) Login(w http.ResponseWriter, params LoginParams) { 43 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 44 + 45 + data := loginData{ 46 + ErrorMsg: params.ErrorCode, 47 + } 48 + 49 + p.templates.ExecuteTemplate(w, "login.html", data) 50 + } 51 + 52 + func (p *Pages) Notice(w http.ResponseWriter, id string, message string) { 53 + w.Header().Set("Content-Type", "text/html; charset=utf-8") 54 + 55 + data := noticeData{ 56 + ID: id, 57 + Message: message, 58 + } 59 + 60 + p.templates.ExecuteTemplate(w, "notice.html", data) 61 + } 62 + 63 + func (p *Pages) HxRedirect(w http.ResponseWriter, url string) { 64 + w.Header().Set("HX-Redirect", url) 65 + w.WriteHeader(http.StatusOK) 66 + }
+11
appview/pages/templates/index.html
···
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <title>Recard</title> 5 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 6 + </head> 7 + <body> 8 + <h1>Welcome to Recard</h1> 9 + <p><a href="/login">Login</a></p> 10 + </body> 11 + </html>
+18
appview/pages/templates/login.html
···
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <title>Login - Recard</title> 5 + <script src="https://unpkg.com/htmx.org@1.9.10"></script> 6 + </head> 7 + <body> 8 + <h1>Login</h1> 9 + {{if .ErrorMsg}} 10 + <div id="login-msg" style="color: red;">Error: {{.ErrorMsg}}</div> 11 + {{end}} 12 + <form hx-post="/login" hx-target="body"> 13 + <label for="handle">Handle:</label> 14 + <input type="text" id="handle" name="handle" placeholder="user.bsky.social" required> 15 + <button type="submit">Login</button> 16 + </form> 17 + </body> 18 + </html>
+1
appview/pages/templates/notice.html
···
··· 1 + <div id="{{.ID}}" style="color: red;">{{.Message}}</div>
+69
appview/state/login.go
···
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "strings" 7 + 8 + "recard.blue/appview/pages" 9 + ) 10 + 11 + func (s *State) Login(w http.ResponseWriter, r *http.Request) { 12 + l := s.logger.With("handler", "Login") 13 + 14 + switch r.Method { 15 + case http.MethodGet: 16 + returnURL := r.URL.Query().Get("return_url") 17 + errorCode := r.URL.Query().Get("error") 18 + s.pages.Login(w, pages.LoginParams{ 19 + ReturnUrl: returnURL, 20 + ErrorCode: errorCode, 21 + }) 22 + case http.MethodPost: 23 + handle := r.FormValue("handle") 24 + 25 + // when users copy their handle from bsky.app, it tends to have these characters around it: 26 + // 27 + // @nelind.dk: 28 + // \u202a ensures that the handle is always rendered left to right and 29 + // \u202c reverts that so the rest of the page renders however it should 30 + handle = strings.TrimPrefix(handle, "\u202a") 31 + handle = strings.TrimSuffix(handle, "\u202c") 32 + 33 + // `@` is harmless 34 + handle = strings.TrimPrefix(handle, "@") 35 + 36 + // basic handle validation 37 + if !strings.Contains(handle, ".") { 38 + l.Error("invalid handle format", "raw", handle) 39 + s.pages.Notice( 40 + w, 41 + "login-msg", 42 + fmt.Sprintf("\"%s\" is an invalid handle. Did you mean %s.bsky.social or %s.tngl.sh?", handle, handle, handle), 43 + ) 44 + return 45 + } 46 + 47 + redirectURL, err := s.oauth.ClientApp.StartAuthFlow(r.Context(), handle) 48 + if err != nil { 49 + l.Error("failed to start auth", "err", err) 50 + http.Error(w, err.Error(), http.StatusInternalServerError) 51 + return 52 + } 53 + 54 + s.pages.HxRedirect(w, redirectURL) 55 + } 56 + } 57 + 58 + func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 59 + l := s.logger.With("handler", "Logout") 60 + 61 + err := s.oauth.DeleteSession(w, r) 62 + if err != nil { 63 + l.Error("failed to logout", "err", err) 64 + } else { 65 + l.Info("logged out successfully") 66 + } 67 + 68 + s.pages.HxRedirect(w, "/login") 69 + }
+64
appview/state/state.go
···
··· 1 + package state 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + "net/http" 7 + 8 + "recard.blue/appview/config" 9 + "recard.blue/appview/oauth" 10 + "recard.blue/appview/pages" 11 + tlog "recard.blue/log" 12 + ) 13 + 14 + type State struct { 15 + logger *slog.Logger 16 + config *config.Config 17 + pages *pages.Pages 18 + oauth *oauth.OAuth 19 + } 20 + 21 + func Make(ctx context.Context, c *config.Config) (*State, error) { 22 + logger := tlog.FromContext(ctx) 23 + 24 + oauthHandler, err := oauth.New(ctx, c) 25 + if err != nil { 26 + return nil, err 27 + } 28 + 29 + pagesHandler := pages.New() 30 + 31 + s := &State{ 32 + logger: logger, 33 + config: c, 34 + pages: pagesHandler, 35 + oauth: oauthHandler, 36 + } 37 + 38 + return s, nil 39 + } 40 + 41 + func (s *State) Close() error { 42 + if s.oauth != nil { 43 + return s.oauth.Close() 44 + } 45 + return nil 46 + } 47 + 48 + func (s *State) Router() http.Handler { 49 + mux := http.NewServeMux() 50 + 51 + mux.HandleFunc("/login", s.Login) 52 + mux.HandleFunc("/logout", s.Logout) 53 + mux.HandleFunc("/oauth/callback", s.oauth.Callback) 54 + 55 + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { 56 + if r.URL.Path != "/" { 57 + http.NotFound(w, r) 58 + return 59 + } 60 + s.pages.Index(w) 61 + }) 62 + 63 + return mux 64 + }
+41
cmd/appview/main.go
···
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "net/http" 6 + "os" 7 + 8 + "recard.blue/appview/config" 9 + "recard.blue/appview/state" 10 + tlog "recard.blue/log" 11 + ) 12 + 13 + func main() { 14 + ctx := context.Background() 15 + logger := tlog.New("appview") 16 + ctx = tlog.IntoContext(ctx, logger) 17 + 18 + c, err := config.LoadConfig(ctx) 19 + if err != nil { 20 + logger.Error("failed to load config", "error", err) 21 + return 22 + } 23 + 24 + state, err := state.Make(ctx, c) 25 + defer func() { 26 + if err := state.Close(); err != nil { 27 + logger.Error("failed to close state", "err", err) 28 + } 29 + }() 30 + 31 + if err != nil { 32 + logger.Error("failed to start appview", "err", err) 33 + os.Exit(-1) 34 + } 35 + 36 + logger.Info("starting server", "address", c.Core.ListenAddr) 37 + 38 + if err := http.ListenAndServe(c.Core.ListenAddr, state.Router()); err != nil { 39 + logger.Error("failed to start appview", "err", err) 40 + } 41 + }
+26
go.mod
···
··· 1 + module recard.blue 2 + 3 + go 1.25.5 4 + 5 + require ( 6 + github.com/charmbracelet/log v0.4.2 7 + github.com/sethvargo/go-envconfig v1.3.0 8 + ) 9 + 10 + require ( 11 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 12 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 13 + github.com/charmbracelet/lipgloss v1.1.0 // indirect 14 + github.com/charmbracelet/x/ansi v0.8.0 // indirect 15 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 16 + github.com/charmbracelet/x/term v0.2.1 // indirect 17 + github.com/go-logfmt/logfmt v0.6.0 // indirect 18 + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 19 + github.com/mattn/go-isatty v0.0.20 // indirect 20 + github.com/mattn/go-runewidth v0.0.16 // indirect 21 + github.com/muesli/termenv v0.16.0 // indirect 22 + github.com/rivo/uniseg v0.4.7 // indirect 23 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 24 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect 25 + golang.org/x/sys v0.30.0 // indirect 26 + )
+46
go.sum
···
··· 1 + github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 2 + github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 3 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 4 + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 5 + github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 6 + github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 7 + github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= 8 + github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= 9 + github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 10 + github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 11 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 12 + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 13 + github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 14 + github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 15 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 16 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 + github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= 18 + github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= 19 + github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 20 + github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 21 + github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 22 + github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 23 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 24 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 25 + github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 26 + github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 27 + github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 28 + github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 29 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 30 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 31 + github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 32 + github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 33 + github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 34 + github.com/sethvargo/go-envconfig v1.3.0 h1:gJs+Fuv8+f05omTpwWIu6KmuseFAXKrIaOZSh8RMt0U= 35 + github.com/sethvargo/go-envconfig v1.3.0/go.mod h1:JLd0KFWQYzyENqnEPWWZ49i4vzZo/6nRidxI8YvGiHw= 36 + github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 37 + github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 38 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 39 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 40 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 41 + golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 42 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 43 + golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 44 + golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 45 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 46 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+65
log/log.go
···
··· 1 + package log 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + "os" 7 + 8 + "github.com/charmbracelet/log" 9 + ) 10 + 11 + func NewHandler(name string) slog.Handler { 12 + return log.NewWithOptions(os.Stderr, log.Options{ 13 + ReportTimestamp: true, 14 + Prefix: name, 15 + Level: log.DebugLevel, 16 + }) 17 + } 18 + 19 + func New(name string) *slog.Logger { 20 + return slog.New(NewHandler(name)) 21 + } 22 + 23 + func NewContext(ctx context.Context, name string) context.Context { 24 + return IntoContext(ctx, New(name)) 25 + } 26 + 27 + type ctxKey struct{} 28 + 29 + // IntoContext adds a logger to a context. Use FromContext to 30 + // pull the logger out. 31 + func IntoContext(ctx context.Context, l *slog.Logger) context.Context { 32 + return context.WithValue(ctx, ctxKey{}, l) 33 + } 34 + 35 + // FromContext returns a logger from a context.Context; 36 + // if the passed context is nil, we return the default slog 37 + // logger. 38 + func FromContext(ctx context.Context) *slog.Logger { 39 + if ctx != nil { 40 + v := ctx.Value(ctxKey{}) 41 + if v == nil { 42 + return slog.Default() 43 + } 44 + return v.(*slog.Logger) 45 + } 46 + 47 + return slog.Default() 48 + } 49 + 50 + // sublogger derives a new logger from an existing one by appending a suffix to its prefix. 51 + func SubLogger(base *slog.Logger, suffix string) *slog.Logger { 52 + // try to get the underlying charmbracelet logger 53 + if cl, ok := base.Handler().(*log.Logger); ok { 54 + prefix := cl.GetPrefix() 55 + if prefix != "" { 56 + prefix = prefix + "/" + suffix 57 + } else { 58 + prefix = suffix 59 + } 60 + return slog.New(NewHandler(prefix)) 61 + } 62 + 63 + // Fallback: no known handler type 64 + return slog.New(NewHandler(suffix)) 65 + }