Skip to content

Commit 7ad135d

Browse files
committed
feat: add stats screen, already played screen and implement a blacklist of common usernames to not record stats for those names
1 parent 65160ec commit 7ad135d

File tree

15 files changed

+908
-44
lines changed

15 files changed

+908
-44
lines changed

.dockerignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,8 @@ go.work
3232
# Environment files
3333
.env
3434
.env.local
35+
36+
# Database files
37+
*.db
38+
*.sqlite
39+
*.sqlite3

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
.idea/
22
.ssh/
33
bin/
4+
wordle-stats.db

Dockerfile

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,34 @@
11
# --- Build ---
22
FROM golang:1.23-alpine AS builder
33

4+
RUN apk --no-cache add gcc musl-dev sqlite-dev
5+
46
WORKDIR /app
57

68
COPY go.mod go.sum ./
79
RUN go mod download
810

911
COPY . .
1012

11-
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o wordle-ssh .
13+
RUN CGO_ENABLED=1 GOOS=linux go build -a -ldflags '-linkmode external -extldflags "-static"' -o wordle-ssh ./cmd/main.go
1214

1315
# --- Run ---
1416
FROM alpine:latest
1517

16-
RUN apk --no-cache add ca-certificates
18+
RUN apk --no-cache add ca-certificates sqlite-libs
1719

1820
WORKDIR /root/
1921

2022
COPY --from=builder /app/wordle-ssh .
2123

22-
RUN mkdir -p .ssh
24+
# Create directories for SSH keys and database
25+
RUN mkdir -p .ssh /data
2326

2427
EXPOSE 23234
2528

2629
ENV WORDLE_SSH_HOST=0.0.0.0
2730
ENV WORDLE_SSH_PORT=23234
2831
ENV WORDLE_SSH_HOST_KEY_PATH=.ssh/id_ed25519
32+
ENV WORDLE_SSH_DB_PATH=/data/wordle-stats.db
2933

3034
CMD ["./wordle-ssh"]

cmd/main.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,27 @@ func main() {
1111
// Load configuration
1212
config := server.LoadConfigFromEnv()
1313

14+
// Initialize logger
15+
logger := log.NewWithOptions(os.Stderr, log.Options{
16+
ReportTimestamp: true,
17+
ReportCaller: config.LogLevel == log.DebugLevel,
18+
TimeFormat: "2006/01/02 15:04:05",
19+
Prefix: "[wordle-ssh]",
20+
Level: config.LogLevel,
21+
})
22+
23+
// Set the logger in config
24+
config.Logger = logger
25+
1426
// Create and start the server
1527
srv, err := server.New(config)
1628
if err != nil {
17-
log.Fatal(err)
29+
logger.Fatal("Failed to create server", "error", err)
1830
os.Exit(1)
1931
}
2032

2133
if err := srv.Start(); err != nil {
22-
log.Fatal(err)
34+
logger.Fatal("Failed to start server", "error", err)
2335
os.Exit(1)
2436
}
2537
}

docker-compose.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@ services:
1010
- WORDLE_SSH_HOST=0.0.0.0
1111
- WORDLE_SSH_PORT=23234
1212
- WORDLE_SSH_HOST_KEY_PATH=.ssh/id_ed25519
13+
- WORDLE_SSH_DB_PATH=/data/wordle-stats.db
14+
- WORDLE_SSH_LOG_LEVEL=info
1315
volumes:
1416
- ssh-keys:/root/.ssh
17+
- wordle-data:/data
1518
restart: unless-stopped
1619

1720
volumes:
1821
ssh-keys:
22+
wordle-data:

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/charmbracelet/log v0.4.2
99
github.com/charmbracelet/ssh v0.0.0-20250826160808-ebfa259c7309
1010
github.com/charmbracelet/wish v1.4.7
11+
github.com/mattn/go-sqlite3 v1.14.32
1112
)
1213

1314
require (

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
4848
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
4949
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
5050
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
51+
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
52+
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
5153
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
5254
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
5355
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=

internal/server/server.go

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/charmbracelet/wish"
1515
"github.com/charmbracelet/wish/bubbletea"
1616
"github.com/charmbracelet/wish/logging"
17+
"github.com/f-gillmann/wordle-ssh/internal/stats"
1718
"github.com/f-gillmann/wordle-ssh/internal/ui"
1819
"github.com/f-gillmann/wordle-ssh/internal/wordle"
1920
)
@@ -22,13 +23,15 @@ const (
2223
defaultHost = "0.0.0.0"
2324
defaultPort = "23234"
2425
defaultHostKeyPath = ".ssh/id_ed25519"
26+
defaultDBPath = "./wordle-stats.db"
2527
)
2628

2729
// Config holds the server configuration
2830
type Config struct {
2931
Host string
3032
Port string
3133
HostKeyPath string
34+
DBPath string
3235
Logger *log.Logger
3336
LogLevel log.Level
3437
}
@@ -50,6 +53,11 @@ func LoadConfigFromEnv() Config {
5053
hostKeyPath = defaultHostKeyPath
5154
}
5255

56+
dbPath := os.Getenv("WORDLE_SSH_DB_PATH")
57+
if dbPath == "" {
58+
dbPath = defaultDBPath
59+
}
60+
5361
logLevel := os.Getenv("WORDLE_SSH_LOG_LEVEL")
5462
var level log.Level
5563

@@ -70,6 +78,7 @@ func LoadConfigFromEnv() Config {
7078
Host: host,
7179
Port: port,
7280
HostKeyPath: hostKeyPath,
81+
DBPath: dbPath,
7382
LogLevel: level,
7483
}
7584
}
@@ -80,6 +89,7 @@ type Server struct {
8089
wordleWord string
8190
wordleDate string
8291
wishServer *ssh.Server
92+
statsStore *stats.Store
8393
}
8494

8595
// New creates a new SSH server
@@ -96,20 +106,25 @@ func New(config Config) (*Server, error) {
96106
config.HostKeyPath = defaultHostKeyPath
97107
}
98108

109+
if config.DBPath == "" {
110+
config.DBPath = defaultDBPath
111+
}
112+
99113
if config.Logger == nil {
100-
config.Logger = log.NewWithOptions(os.Stderr, log.Options{
101-
ReportTimestamp: true,
102-
ReportCaller: config.LogLevel == log.DebugLevel,
103-
TimeFormat: "2006/01/02 15:04:05",
104-
Prefix: "[wordle-ssh]",
105-
Level: config.LogLevel,
106-
})
114+
return nil, fmt.Errorf("logger must be provided in config")
107115
}
108116

109117
s := &Server{
110118
config: config,
111119
}
112120

121+
// Initialize stats store
122+
statsStore, err := stats.NewStore(config.DBPath, config.Logger)
123+
if err != nil {
124+
return nil, fmt.Errorf("failed to initialize stats store: %w", err)
125+
}
126+
s.statsStore = statsStore
127+
113128
// Fetch today's Wordle word
114129
if err := s.refreshWordleWord(); err != nil {
115130
return nil, fmt.Errorf("failed to fetch wordle word: %w", err)
@@ -153,15 +168,35 @@ func (s *Server) refreshWordleWord() error {
153168
}
154169

155170
// teaHandler creates a bubbletea program for each SSH session
156-
func (s *Server) teaHandler(ssh.Session) (tea.Model, []tea.ProgramOption) {
171+
func (s *Server) teaHandler(sshSession ssh.Session) (tea.Model, []tea.ProgramOption) {
157172
// Refresh Wordle word if it's a new day
158173
if err := s.refreshWordleWord(); err != nil {
159174
s.config.Logger.Error("Failed to refresh Wordle word", "error", err)
160175
return nil, nil
161176
}
162177

163-
// Create the app model with the current word
164-
m := ui.NewAppModel(s.wordleWord)
178+
// Get username from SSH session
179+
username := sshSession.User()
180+
if username == "" {
181+
username = "anonymous"
182+
}
183+
184+
// Check if username is blacklisted
185+
isBlacklisted := stats.IsBlacklisted(username)
186+
187+
// Check if user has already played today (but not for blacklisted users)
188+
hasPlayed := false
189+
if !isBlacklisted {
190+
var err error
191+
hasPlayed, err = s.statsStore.HasPlayedToday(username, s.wordleDate)
192+
193+
if err != nil {
194+
s.config.Logger.Error("Failed to check if user played today", "error", err, "username", username)
195+
}
196+
}
197+
198+
// Create the app model with the current word, stats store, and logger
199+
m := ui.NewAppModel(s.wordleWord, s.wordleDate, username, s.statsStore, hasPlayed, isBlacklisted, s.config.Logger)
165200

166201
return m, []tea.ProgramOption{tea.WithAltScreen()}
167202
}
@@ -171,7 +206,7 @@ func (s *Server) Start() error {
171206
done := make(chan os.Signal, 1)
172207
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
173208

174-
s.config.Logger.Info("Starting SSH server", "host", s.config.Host, "port", s.config.Port)
209+
s.config.Logger.Info("Starting SSH server", "host", s.config.Host, "port", s.config.Port, "db", s.config.DBPath)
175210

176211
go func() {
177212
if err := s.wishServer.ListenAndServe(); err != nil {
@@ -185,6 +220,11 @@ func (s *Server) Start() error {
185220
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
186221
defer cancel()
187222

223+
// Close stats store
224+
if err := s.statsStore.Close(); err != nil {
225+
s.config.Logger.Error("Failed to close stats store", "error", err)
226+
}
227+
188228
if err := s.wishServer.Shutdown(ctx); err != nil {
189229
return fmt.Errorf("failed to shutdown server: %w", err)
190230
}

internal/stats/blacklist.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package stats
2+
3+
// BlacklistedUsernames contains usernames that should never record stats
4+
var BlacklistedUsernames = map[string]bool{
5+
"anonymous": true,
6+
"root": true,
7+
"toor": true,
8+
"admin": true,
9+
"user": true,
10+
"guest": true,
11+
"test": true,
12+
"demo": true,
13+
"ubuntu": true,
14+
"debian": true,
15+
"centos": true,
16+
"fedora": true,
17+
"oracle": true,
18+
"pi": true,
19+
"vagrant": true,
20+
"default": true,
21+
"1234": true,
22+
"ftp": true,
23+
}
24+
25+
// IsBlacklisted checks if a username is blacklisted
26+
func IsBlacklisted(username string) bool {
27+
return BlacklistedUsernames[username]
28+
}

0 commit comments

Comments
 (0)