+32
.gitignore
+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
+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
+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
+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
+11
appview/pages/templates/index.html
+18
appview/pages/templates/login.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
appview/pages/templates/notice.html
···
···
1
+
<div id="{{.ID}}" style="color: red;">{{.Message}}</div>
+69
appview/state/login.go
+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
+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
+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
+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
+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
+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
+
}