diff --git a/.air.toml b/.air.toml deleted file mode 100644 index b348371..0000000 --- a/.air.toml +++ /dev/null @@ -1,31 +0,0 @@ -root = "." -tmp_dir = "tmp" - -[build] - bin = "./tmp/main" - cmd = "go build -o ./tmp/main main.go" - delay = 1000 - exclude_dir = ["assets", "tmp", "vendor"] - include_dir = ["lib"] - exclude_regex = ["_test.go"] - exclude_unchanged = false - follow_symlink = false - full_bin = "" - include_ext = ["go", "tpl", "tmpl", "html"] - kill_delay = "0s" - log = "build-errors.log" - send_interrupt = false - stop_on_error = true - -[color] - app = "" - build = "yellow" - main = "magenta" - runner = "green" - watcher = "cyan" - -[log] - time = false - -[misc] - clean_on_exit = false diff --git a/Dockerfile.development b/Dockerfile.development deleted file mode 100644 index 86df8d1..0000000 --- a/Dockerfile.development +++ /dev/null @@ -1,9 +0,0 @@ -FROM --platform=linux/amd64 golang:1.26.1-alpine - -WORKDIR /app - -RUN go install github.com/air-verse/air@latest - -EXPOSE 2323 - -CMD ["air", "-c", ".air.toml"] diff --git a/content/data.catalog.go b/content/data.catalog.go deleted file mode 100644 index 83ee75e..0000000 --- a/content/data.catalog.go +++ /dev/null @@ -1,45 +0,0 @@ -package content - -import ( - "encoding/json" - "net/http" - "time" -) - -const GamesAPIURL = "https://games.teletype.hu/api/software" - -type softwareEntry struct { - Software struct { - Title string `json:"title"` - Platform string `json:"platform"` - Author string `json:"author"` - Desc string `json:"desc"` - } `json:"software"` - Releases []interface{} `json:"releases"` - LatestRelease *struct { - Version string `json:"version"` - HTMLFolderPath string `json:"htmlFolderPath"` - CartridgePath string `json:"cartridgePath"` - SourcePath string `json:"sourcePath"` - DocsFolderPath string `json:"docsFolderPath"` - } `json:"latestRelease"` -} - -type catalogRepository struct{} - -func (r *catalogRepository) fetchGames() ([]softwareEntry, error) { - client := &http.Client{Timeout: 12 * time.Second} - resp, err := client.Get(GamesAPIURL) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var result struct { - Softwares []softwareEntry `json:"softwares"` - } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - return result.Softwares, nil -} diff --git a/content/data.message.go b/content/data.message.go deleted file mode 100644 index c577487..0000000 --- a/content/data.message.go +++ /dev/null @@ -1,66 +0,0 @@ -package content - -import ( - "encoding/csv" - "os" - "sync" -) - -type message struct { - Timestamp string - User string - Text string -} - -// MessageBoard holds message board data -type MessageBoard struct { - messages []message - mu sync.Mutex - path string -} - -// NewMessageBoard loads messages from the given .dat file -func NewMessageBoard(path string) *MessageBoard { - b := &MessageBoard{path: path} - b.load() - return b -} - -func (b *MessageBoard) load() { - f, err := os.Open(b.path) - if err != nil { - return - } - defer f.Close() - - r := csv.NewReader(f) - records, err := r.ReadAll() - if err != nil { - return - } - for _, row := range records { - if len(row) != 3 { - continue - } - b.messages = append(b.messages, message{Timestamp: row[0], User: row[1], Text: row[2]}) - } -} - -func (b *MessageBoard) append(msg message) error { - f, err := os.OpenFile(b.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return err - } - defer f.Close() - - w := csv.NewWriter(f) - err = w.Write([]string{msg.Timestamp, msg.User, msg.Text}) - w.Flush() - return err -} - -func (b *MessageBoard) Count() int { - b.mu.Lock() - defer b.mu.Unlock() - return len(b.messages) -} diff --git a/content/data.wiki.go b/content/data.wiki.go deleted file mode 100644 index 29ad226..0000000 --- a/content/data.wiki.go +++ /dev/null @@ -1,133 +0,0 @@ -package content - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "time" -) - -const WikiJSBaseURL = "https://wiki.teletype.hu" - -type wikiPage struct { - ID interface{} `json:"id"` - Path string `json:"path"` - Title string `json:"title"` - Description string `json:"description"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` - Locale string `json:"locale"` -} - -type wikiRepository struct { - token string -} - -func (r *wikiRepository) graphql(query string) (map[string]interface{}, error) { - payload := map[string]string{"query": query} - data, _ := json.Marshal(payload) - - req, err := http.NewRequest("POST", WikiJSBaseURL+"/graphql", bytes.NewBuffer(data)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - if r.token != "" { - req.Header.Set("Authorization", "Bearer "+r.token) - } - - client := &http.Client{Timeout: 12 * time.Second} - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - var result map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, err - } - return result, nil -} - -func (r *wikiRepository) fetchList(tag string) ([]wikiPage, error) { - q := fmt.Sprintf(`{ pages { list(orderBy: CREATED, orderByDirection: DESC, tags: ["%s"]) { id path title description createdAt updatedAt locale } } }`, tag) - res, err := r.graphql(q) - if err != nil { - return nil, err - } - - if errorsRaw, ok := res["errors"].([]interface{}); ok && len(errorsRaw) > 0 { - if firstErr, ok := errorsRaw[0].(map[string]interface{}); ok { - if msg, ok := firstErr["message"].(string); ok { - return nil, fmt.Errorf("wiki error: %s", msg) - } - } - return nil, fmt.Errorf("wiki returned an error") - } - - data, ok := res["data"].(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid response format") - } - pages, ok := data["pages"].(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("invalid response format") - } - listRaw, ok := pages["list"].([]interface{}) - if !ok { - return nil, fmt.Errorf("invalid response format") - } - - var list []wikiPage - jsonBytes, _ := json.Marshal(listRaw) - json.Unmarshal(jsonBytes, &list) - return list, nil -} - -func (r *wikiRepository) fetchContent(pageID interface{}) (string, error) { - var idStr string - switch v := pageID.(type) { - case string: - idStr = v - case float64: - idStr = fmt.Sprintf("%.0f", v) - default: - idStr = fmt.Sprintf("%v", v) - } - - q := fmt.Sprintf(`{ pages { single(id: %s) { content } } }`, idStr) - res, err := r.graphql(q) - if err != nil { - return "", err - } - - if errorsRaw, ok := res["errors"].([]interface{}); ok && len(errorsRaw) > 0 { - if firstErr, ok := errorsRaw[0].(map[string]interface{}); ok { - if msg, ok := firstErr["message"].(string); ok { - return "", fmt.Errorf("wiki error: %s", msg) - } - } - return "", fmt.Errorf("wiki returned an error") - } - - data, ok := res["data"].(map[string]interface{}) - if !ok { - return "", fmt.Errorf("invalid response format: missing data") - } - pages, ok := data["pages"].(map[string]interface{}) - if !ok { - return "", fmt.Errorf("invalid response format: missing pages") - } - single, ok := pages["single"].(map[string]interface{}) - if !ok { - return "", fmt.Errorf("page not found") - } - content, ok := single["content"].(string) - if !ok { - return "", fmt.Errorf("invalid response format: missing content") - } - return content, nil -} diff --git a/content/handler.blog.go b/content/handler.blog.go deleted file mode 100644 index 094511f..0000000 --- a/content/handler.blog.go +++ /dev/null @@ -1,108 +0,0 @@ -package content - -import ( - "bbs-server/engine" - "fmt" - "strconv" - "strings" -) - -// BlogHandler handles the Blog Posts menu item -type BlogHandler struct { - repo *wikiRepository -} - -func NewBlogHandler(token string) *BlogHandler { - return &BlogHandler{repo: &wikiRepository{token: token}} -} - -func (h *BlogHandler) Handle(s *engine.Session) { - renderWikiList(s, h.repo, "blog", "Blog Posts", engine.COLOR_BLUE) -} - -// renderWikiList is a helper used by wiki-based handlers -func renderWikiList(s *engine.Session, repo *wikiRepository, tag, title, color string) { - s.Printer.BoxHeader(title, color) - s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.COLOR_GRAY, s.Lang["WikiLoading"], engine.COLOR_RESET)) - - pages, err := repo.fetchList(tag) - if err != nil { - s.Printer.Send(fmt.Sprintf("\r\n%s%s: %v%s\r\n", engine.COLOR_RED, s.Lang["WikiConnError"], err, engine.COLOR_RESET)) - s.Printer.Pause(s.Lang) - return - } - - s.Printer.Send("\r\033[A\033[2K") - - if len(pages) == 0 { - s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.COLOR_GRAY, fmt.Sprintf(s.Lang["WikiNoResults"], tag), engine.COLOR_RESET)) - s.Printer.Pause(s.Lang) - return - } - - maxIdx := 25 - if len(pages) < maxIdx { - maxIdx = len(pages) - } - - for i, p := range pages[:maxIdx] { - date := s.Printer.FmtDate(p.CreatedAt) - if date == "–" { - date = s.Printer.FmtDate(p.UpdatedAt) - } - titleP := p.Title - if titleP == "" { - titleP = p.Path - } - if len(titleP) > 55 { - titleP = titleP[:55] - } - desc := p.Description - if len(desc) > 60 { - desc = desc[:60] - } - s.Printer.Send(fmt.Sprintf(" %s%2d%s %s%s%s\r\n", color, i+1, engine.COLOR_RESET, engine.COLOR_WHITE, titleP, engine.COLOR_RESET)) - if desc != "" { - s.Printer.Send(fmt.Sprintf(" %s%s %s%s%s\r\n", engine.COLOR_GRAY, date, engine.COLOR_DIM, desc, engine.COLOR_RESET)) - } else { - s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.COLOR_GRAY, date, engine.COLOR_RESET)) - } - } - - s.Printer.Send(fmt.Sprintf("\r\n%s%s%s ", engine.COLOR_GRAY, s.Lang["WikiEnterNum"], engine.COLOR_RESET)) - choice, _ := s.Printer.ReadLine() - choice = strings.TrimSpace(choice) - idx, err := strconv.Atoi(choice) - if err != nil || idx < 1 || idx > len(pages) { - return - } - - page := pages[idx-1] - s.Printer.Send(fmt.Sprintf("\r\n%s%s%s", engine.COLOR_GRAY, s.Lang["WikiFetchContent"], engine.COLOR_RESET)) - pageContent, err := repo.fetchContent(page.ID) - if err != nil { - s.Printer.Send(fmt.Sprintf("\r\n%sHiba: %v%s\r\n", engine.COLOR_RED, err, engine.COLOR_RESET)) - s.Printer.Pause(s.Lang) - return - } - - s.Printer.Send("\r\033[A\033[2K") - s.Printer.Send("\r\n") - s.Printer.HR("═", color) - s.Printer.Send("\r\n") - s.Printer.Send(fmt.Sprintf(" %s%s%s%s\r\n", engine.COLOR_WHITE, engine.COLOR_BOLD, page.Title, engine.COLOR_RESET)) - url := fmt.Sprintf("%s/%s/%s", WikiJSBaseURL, page.Locale, page.Path) - s.Printer.Send(fmt.Sprintf(" %s%s %s%s%s\r\n", engine.COLOR_GRAY, s.Printer.FmtDate(page.CreatedAt), engine.COLOR_DIM, url, engine.COLOR_RESET)) - s.Printer.HR("─", engine.COLOR_GRAY) - s.Printer.Send("\r\n\r\n") - - body := s.Printer.Wrapped(pageContent, 2, 5000) - for _, line := range strings.Split(body, "\r\n") { - s.Printer.Send(line + "\r\n") - } - - s.Printer.Send("\r\n") - s.Printer.HR("─", engine.COLOR_GRAY) - s.Printer.Send("\r\n") - s.Printer.Pause(s.Lang) -} diff --git a/content/handler.catalog.go b/content/handler.catalog.go deleted file mode 100644 index 49c8043..0000000 --- a/content/handler.catalog.go +++ /dev/null @@ -1,91 +0,0 @@ -package content - -import ( - "bbs-server/engine" - "fmt" - "strings" -) - -// CatalogHandler handles the Game Catalog menu item -type CatalogHandler struct { - repo *catalogRepository -} - -func NewCatalogHandler() *CatalogHandler { - return &CatalogHandler{repo: &catalogRepository{}} -} - -func (h *CatalogHandler) Handle(s *engine.Session) { - s.Printer.BoxHeader(s.Lang["CatTitle"], engine.COLOR_YELLOW) - s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.COLOR_GRAY, s.Lang["WikiLoading"], engine.COLOR_RESET)) - - softwares, err := h.repo.fetchGames() - if err != nil { - s.Printer.Send(fmt.Sprintf("\r\n%s%s: %v%s\r\n", engine.COLOR_RED, s.Lang["WikiConnError"], err, engine.COLOR_RESET)) - s.Printer.Pause(s.Lang) - return - } - - s.Printer.Send("\r\033[A\033[2K") - - if len(softwares) == 0 { - s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.COLOR_GRAY, s.Lang["CatNoGames"], engine.COLOR_RESET)) - s.Printer.Pause(s.Lang) - return - } - - base := "https://games.teletype.hu" - - for i, entry := range softwares { - sw := entry.Software - lr := entry.LatestRelease - - s.Printer.Send(fmt.Sprintf("\r\n %s%s%s\r\n", engine.COLOR_YELLOW, strings.Repeat("─", engine.W-4), engine.COLOR_RESET)) - s.Printer.Send(fmt.Sprintf(" %s%2d.%s %s%s%s%s %s[%s] by %s%s\r\n", - engine.COLOR_YELLOW, i+1, engine.COLOR_RESET, - engine.COLOR_WHITE, engine.COLOR_BOLD, sw.Title, engine.COLOR_RESET, - engine.COLOR_GREEN, sw.Platform, sw.Author, engine.COLOR_RESET)) - - if sw.Desc != "" { - wrappedDesc := s.Printer.Wrapped(sw.Desc, 7, 1000) - for _, line := range strings.Split(wrappedDesc, "\r\n") { - s.Printer.Send(fmt.Sprintf("%s%s\r\n", engine.COLOR_GRAY, line)) - } - } - - if lr != nil { - badges := []string{} - if lr.HTMLFolderPath != "" { - badges = append(badges, fmt.Sprintf("%s[▶ Play]%s", engine.COLOR_GREEN, engine.COLOR_RESET)) - } - if lr.CartridgePath != "" { - badges = append(badges, fmt.Sprintf("%s[⬇ Download]%s", engine.COLOR_BLUE, engine.COLOR_RESET)) - } - if lr.SourcePath != "" { - badges = append(badges, fmt.Sprintf("%s[Source]%s", engine.COLOR_MAGENTA, engine.COLOR_RESET)) - } - if lr.DocsFolderPath != "" { - badges = append(badges, fmt.Sprintf("%s[Docs]%s", engine.COLOR_YELLOW, engine.COLOR_RESET)) - } - - badgeStr := "–" - if len(badges) > 0 { - badgeStr = strings.Join(badges, " ") - } - s.Printer.Send(fmt.Sprintf(" %s%s: v%s%s %s\r\n", - engine.COLOR_GRAY, s.Lang["CatLatest"], lr.Version, engine.COLOR_RESET, badgeStr)) - - if lr.HTMLFolderPath != "" { - url := base + lr.HTMLFolderPath - s.Printer.Send(fmt.Sprintf(" %s▶ %s%s\r\n", engine.COLOR_DIM, url, engine.COLOR_RESET)) - } - } - s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", - engine.COLOR_GRAY, fmt.Sprintf(s.Lang["CatVersions"], len(entry.Releases)), engine.COLOR_RESET)) - } - - s.Printer.HR("─", engine.COLOR_YELLOW) - s.Printer.Send("\r\n") - s.Printer.Send(fmt.Sprintf(" %s%s: %s%s\r\n", engine.COLOR_GREEN, s.Lang["CatFull"], base, engine.COLOR_RESET)) - s.Printer.Pause(s.Lang) -} diff --git a/content/handler.go b/content/handler.go deleted file mode 100644 index 969394d..0000000 --- a/content/handler.go +++ /dev/null @@ -1,8 +0,0 @@ -package content - -import "bbs-server/engine" - -// Handler is the common interface for all BBS menu handlers -type Handler interface { - Handle(s *engine.Session) -} diff --git a/content/handler.howtos.go b/content/handler.howtos.go deleted file mode 100644 index ff30f31..0000000 --- a/content/handler.howtos.go +++ /dev/null @@ -1,16 +0,0 @@ -package content - -import "bbs-server/engine" - -// HowToHandler handles the HowTo Guides menu item -type HowToHandler struct { - repo *wikiRepository -} - -func NewHowToHandler(token string) *HowToHandler { - return &HowToHandler{repo: &wikiRepository{token: token}} -} - -func (h *HowToHandler) Handle(s *engine.Session) { - renderWikiList(s, h.repo, "howto", "HowTo Guides", engine.COLOR_MAGENTA) -} diff --git a/content/handler.messageboard.index.go b/content/handler.messageboard.index.go deleted file mode 100644 index dcf10ec..0000000 --- a/content/handler.messageboard.index.go +++ /dev/null @@ -1,40 +0,0 @@ -package content - -import ( - "bbs-server/engine" - "fmt" -) - -// MessageBoardIndexHandler displays the message board posts -type MessageBoardIndexHandler struct { - board *MessageBoard -} - -func NewMessageBoardIndexHandler(board *MessageBoard) *MessageBoardIndexHandler { - return &MessageBoardIndexHandler{board: board} -} - -func (h *MessageBoardIndexHandler) Handle(s *engine.Session) { - s.Printer.BoxHeader(s.Lang["MsgBoardTitle"], engine.COLOR_GRAY) - - h.board.mu.Lock() - snap := make([]message, len(h.board.messages)) - copy(snap, h.board.messages) - h.board.mu.Unlock() - - if len(snap) == 0 { - s.Printer.Send(fmt.Sprintf(" %s%s%s\r\n", engine.COLOR_GRAY, s.Lang["MsgNoMessages"], engine.COLOR_RESET)) - } else { - start := 0 - if len(snap) > 30 { - start = len(snap) - 30 - } - for i, msg := range snap[start:] { - s.Printer.Send(fmt.Sprintf(" %s%02d%s %s%s%s %s%s:%s %s\r\n", - engine.COLOR_GRAY, i+1, engine.COLOR_RESET, - engine.COLOR_GRAY, msg.Timestamp, engine.COLOR_RESET, - engine.COLOR_WHITE, msg.User, engine.COLOR_RESET, msg.Text)) - } - } - s.Printer.Pause(s.Lang) -} diff --git a/content/handler.messageboard.new.go b/content/handler.messageboard.new.go deleted file mode 100644 index cb6a247..0000000 --- a/content/handler.messageboard.new.go +++ /dev/null @@ -1,38 +0,0 @@ -package content - -import ( - "bbs-server/engine" - "fmt" - "strings" - "time" -) - -// MessageBoardNewHandler handles posting new messages to the board -type MessageBoardNewHandler struct { - board *MessageBoard -} - -func NewMessageBoardNewHandler(board *MessageBoard) *MessageBoardNewHandler { - return &MessageBoardNewHandler{board: board} -} - -func (h *MessageBoardNewHandler) Handle(s *engine.Session) { - s.Printer.Send(fmt.Sprintf("\r\n%s%s%s ", engine.COLOR_WHITE, s.Lang["MsgEnterText"], engine.COLOR_RESET)) - msgText, _ := s.Printer.ReadLine() - msgText = strings.TrimSpace(msgText) - if msgText != "" { - if len(msgText) > 200 { - msgText = msgText[:200] - } - ts := time.Now().Format("01-02 15:04") - msg := message{Timestamp: ts, User: s.Username, Text: msgText} - h.board.mu.Lock() - h.board.messages = append(h.board.messages, msg) - h.board.mu.Unlock() - h.board.append(msg) - s.Printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n", engine.COLOR_GREEN, s.Lang["MsgSent"], engine.COLOR_RESET)) - } else { - s.Printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n", engine.COLOR_GRAY, s.Lang["MsgEmpty"], engine.COLOR_RESET)) - } - s.Printer.Pause(s.Lang) -} diff --git a/content/handler.online.go b/content/handler.online.go deleted file mode 100644 index 1b7a40e..0000000 --- a/content/handler.online.go +++ /dev/null @@ -1,38 +0,0 @@ -package content - -import ( - "bbs-server/engine" - "fmt" - "sort" -) - -// OnlineHandler displays currently logged-in users -type OnlineHandler struct{} - -func NewOnlineHandler() *OnlineHandler { - return &OnlineHandler{} -} - -func (h *OnlineHandler) Handle(s *engine.Session) { - s.Printer.BoxHeader(s.Lang["OnlineTitle"], engine.COLOR_YELLOW) - - snap := s.State.Snapshot() - keys := make([]string, 0, len(snap)) - for k := range snap { - keys = append(keys, k) - } - sort.Strings(keys) - - for _, addr := range keys { - user := snap[addr] - marker := "" - if addr == s.Addr { - marker = fmt.Sprintf(" %s%s%s", engine.COLOR_GREEN, s.Lang["OnlineYou"], engine.COLOR_RESET) - } - s.Printer.Send(fmt.Sprintf(" %s•%s %s%s%s%s\r\n", engine.COLOR_YELLOW, engine.COLOR_RESET, engine.COLOR_WHITE, user, engine.COLOR_RESET, marker)) - } - - s.Printer.Send(fmt.Sprintf("\r\n %s%s\r\n", engine.COLOR_GRAY, - fmt.Sprintf(s.Lang["OnlineTotal"], engine.COLOR_WHITE, len(snap), engine.COLOR_GRAY, engine.COLOR_RESET))) - s.Printer.Pause(s.Lang) -} diff --git a/content/handler.sysinfo.go b/content/handler.sysinfo.go deleted file mode 100644 index b179cfc..0000000 --- a/content/handler.sysinfo.go +++ /dev/null @@ -1,27 +0,0 @@ -package content - -import ( - "bbs-server/engine" - "fmt" - "runtime" -) - -// SysinfoHandler displays system information -type SysinfoHandler struct { - board *MessageBoard -} - -func NewSysinfoHandler(board *MessageBoard) *SysinfoHandler { - return &SysinfoHandler{board: board} -} - -func (h *SysinfoHandler) Handle(s *engine.Session) { - s.Printer.BoxHeader(s.Lang["SysTitle"], engine.COLOR_GRAY) - - s.Printer.Send(fmt.Sprintf(" %s%-15s%s %d\r\n", engine.COLOR_GRAY, s.Lang["SysUsers"], engine.COLOR_RESET, s.State.UserCount())) - s.Printer.Send(fmt.Sprintf(" %s%-15s%s %d\r\n", engine.COLOR_GRAY, s.Lang["SysMessages"], engine.COLOR_RESET, h.board.Count())) - s.Printer.Send(fmt.Sprintf(" %s%-15s%s %s\r\n", engine.COLOR_GRAY, s.Lang["SysOS"], engine.COLOR_RESET, runtime.GOOS)) - s.Printer.Send(fmt.Sprintf(" %s%-15s%s %s\r\n", engine.COLOR_GRAY, s.Lang["SysArch"], engine.COLOR_RESET, runtime.GOARCH)) - - s.Printer.Pause(s.Lang) -} diff --git a/engine/i18n.go b/engine/i18n.go deleted file mode 100644 index 778acd9..0000000 --- a/engine/i18n.go +++ /dev/null @@ -1,40 +0,0 @@ -package engine - -// T is the i18n dictionary type -type T map[string]string - -// En is the default English dictionary -var En = T{ - "AskName": "Enter your name:", - "Greeting": "Hello, %s%s%s! Welcome to Teletype BBS!", - "Goodbye": "Goodbye, %s! 👋", - "Pause": "Press ENTER...", - "Choice": "Choice: ", - "MsgBoardTitle": "📋 MESSAGE BOARD", - "MsgNoMessages": "(No messages yet — be the first!)", - "MsgNew": "Write new message", - "MsgBack": "Back", - "MsgEnterText": "Message text:", - "MsgSent": "✓ Sent!", - "MsgEmpty": "(Empty – not sent)", - "WikiLoading": "Loading...", - "WikiConnError": "Connection error", - "WikiNoResults": "No results for tag '%s'.", - "WikiEnterNum": "Enter number to open, ENTER to go back:", - "WikiFetchContent": "Fetching content...", - "CatTitle": "🎮 GAME CATALOG", - "CatNoGames": "No games available.", - "CatLatest": "Latest", - "CatVersions": "%d versions available", - "CatFull": "Full catalog", - "OnlineTitle": "👥 ONLINE USERS", - "OnlineYou": "← you", - "OnlineTotal": "Total: %s%d%s users online", - "SysInfoTitle": "ℹ SYSTEM INFO", - "SysServerTime": "Server time", - "SysOnlineUsers": "Online users", - "SysMsgCount": "Message count", - "SysWikiURL": "Wiki URL", - "SysGamesAPI": "Games API", - "SysPlatform": "Platform", -} diff --git a/engine/menu.go b/engine/menu.go deleted file mode 100644 index 43f407d..0000000 --- a/engine/menu.go +++ /dev/null @@ -1,78 +0,0 @@ -package engine - -import ( - "fmt" - "strings" -) - -// HandlerFunc is the handler type for a menu item -type HandlerFunc func(*Session) - -// Exit is a built-in handler that terminates the session -var Exit HandlerFunc = func(s *Session) { s.Quit = true } - -type menuItem struct { - Key string - Label string - Color string - Handler HandlerFunc -} - -// Menu defines the BBS main menu using a declarative DSL -type Menu struct { - title string - items []menuItem -} - -// Title sets the menu header -func (m *Menu) Title(t string) { - m.title = t -} - -// Item adds a menu entry (key, display label, ANSI color, handler) -func (m *Menu) Item(key, label, color string, handler HandlerFunc) { - m.items = append(m.items, menuItem{ - Key: strings.ToUpper(key), - Label: label, - Color: color, - Handler: handler, - }) -} - -// Dispatch finds and calls the handler for the given key -func (m *Menu) Dispatch(s *Session, key string) { - key = strings.ToUpper(strings.TrimSpace(key)) - for _, item := range m.items { - if item.Key == key { - item.Handler(s) - return - } - } -} - -// Render returns the rendered menu string -func (m *Menu) Render(p *Printer, lang T, username string) string { - headerLine := strings.Repeat("═", W) - l1 := p.PadLine(fmt.Sprintf(" %s%s%s %s@%s%s", COLOR_YELLOW, m.title, COLOR_RESET, COLOR_GRAY, username, COLOR_RESET), W) - - var rows []string - for i := 0; i < len(m.items); i += 2 { - if i+1 < len(m.items) { - left := p.PadLine(fmt.Sprintf(" %s[%s]%s %s", m.items[i].Color, m.items[i].Key, COLOR_RESET, m.items[i].Label), W/2) - right := p.PadLine(fmt.Sprintf(" %s[%s]%s %s", m.items[i+1].Color, m.items[i+1].Key, COLOR_RESET, m.items[i+1].Label), W/2) - rows = append(rows, left+right) - } else { - rows = append(rows, p.PadLine(fmt.Sprintf(" %s[%s]%s %s", m.items[i].Color, m.items[i].Key, COLOR_RESET, m.items[i].Label), W)) - } - } - - var sb strings.Builder - sb.WriteString(fmt.Sprintf("\n%s╔%s╗%s\n", COLOR_WHITE, headerLine, COLOR_RESET)) - sb.WriteString(fmt.Sprintf("%s║%s%s║%s\n", COLOR_WHITE, l1, COLOR_WHITE, COLOR_RESET)) - sb.WriteString(fmt.Sprintf("%s╠%s╣%s\n", COLOR_WHITE, headerLine, COLOR_RESET)) - for _, row := range rows { - sb.WriteString(fmt.Sprintf("%s║%s%s║%s\n", COLOR_WHITE, row, COLOR_WHITE, COLOR_RESET)) - } - sb.WriteString(fmt.Sprintf("%s╚%s╝%s\n%s", COLOR_WHITE, headerLine, COLOR_RESET, lang["Choice"])) - return sb.String() -} diff --git a/engine/printer.go b/engine/printer.go deleted file mode 100644 index e2cbeec..0000000 --- a/engine/printer.go +++ /dev/null @@ -1,197 +0,0 @@ -package engine - -import ( - "bytes" - "fmt" - "net" - "regexp" - "strings" - "time" - "unicode/utf8" -) - -// ANSI color codes -const ( - COLOR_RESET = "\033[0m" - COLOR_BOLD = "\033[1m" - COLOR_DIM = "\033[2m" - COLOR_CYAN = "\033[1;36m" - COLOR_YELLOW = "\033[1;33m" - COLOR_GREEN = "\033[1;32m" - COLOR_RED = "\033[1;31m" - COLOR_MAGENTA = "\033[1;35m" - COLOR_WHITE = "\033[1;37m" - COLOR_BLUE = "\033[1;34m" - COLOR_GRAY = "\033[0;37m" -) - -// W is the default terminal width -const W = 70 - -type Printer struct { - Conn net.Conn -} - -func NewPrinter(conn net.Conn) *Printer { - return &Printer{Conn: conn} -} - -func (p *Printer) Send(text string) { - normalized := strings.ReplaceAll(text, "\r\n", "\n") - normalized = strings.ReplaceAll(normalized, "\n", "\r\n") - p.Conn.Write([]byte(normalized)) -} - -func (p *Printer) ReadLine() (string, error) { - var buf bytes.Buffer - for { - b := make([]byte, 1) - _, err := p.Conn.Read(b) - if err != nil { - return "", err - } - ch := b[0] - - if ch == 255 { - cmd := make([]byte, 2) - _, err := p.Conn.Read(cmd) - if err != nil { - return "", err - } - if cmd[0] == 250 { - for { - tmp := make([]byte, 1) - _, err := p.Conn.Read(tmp) - if err != nil || tmp[0] == 240 { - break - } - } - } - continue - } - - if ch == '\r' || ch == '\n' { - res := buf.String() - p.Send("\r\n") - return res, nil - } - - if ch == 8 || ch == 127 { - if buf.Len() > 0 { - curr := buf.Bytes() - buf.Reset() - buf.Write(curr[:len(curr)-1]) - p.Send("\b \b") - } - continue - } - - if ch >= 32 && ch < 127 { - buf.WriteByte(ch) - p.Send(string(ch)) - } - } -} - -func (p *Printer) Pause(lang T) { - p.Send(fmt.Sprintf("\r\n%s [ %s ]%s ", COLOR_GRAY, lang["Pause"], COLOR_RESET)) - p.ReadLine() -} - -func (p *Printer) VisibleLen(s string) int { - re := regexp.MustCompile(`\033\[[0-9;]*m`) - return utf8.RuneCountInString(re.ReplaceAllString(s, "")) -} - -func (p *Printer) PadLine(content string, width int) string { - vLen := p.VisibleLen(content) - padding := width - vLen - if padding < 0 { - padding = 0 - } - return content + strings.Repeat(" ", padding) -} - -func (p *Printer) BoxHeader(title string, color string) { - line := strings.Repeat("═", W) - titleLen := utf8.RuneCountInString(title) - padding := (W - 2 - titleLen) / 2 - if padding < 0 { - padding = 0 - } - inner := strings.Repeat(" ", padding) + title + strings.Repeat(" ", W-2-titleLen-padding) - p.Send(fmt.Sprintf( - "\n%s%s%s\n%s║%s %s%s%s %s║%s\n%s%s%s\n", - color, line, COLOR_RESET, - color, COLOR_RESET, COLOR_BOLD, inner, COLOR_RESET, color, COLOR_RESET, - color, line, COLOR_RESET, - )) -} - -func (p *Printer) HR(char string, color string) { - p.Send(fmt.Sprintf("%s%s%s", color, strings.Repeat(char, W), COLOR_RESET)) -} - -func (p *Printer) FmtDate(s string) string { - t, err := time.Parse(time.RFC3339, strings.Replace(s, "Z", "+00:00", 1)) - if err != nil { - if len(s) >= 10 { - return s[:10] - } - return "–" - } - return t.Format("2006-01-02") -} - -func (p *Printer) StripMD(text string) string { - reImg := regexp.MustCompile(`!\[.*?\]\(.*?\)|\[(.*?)\]\(.*?\)|#{1,6}\s*|[*_` + "`" + `~>|]`) - text = reImg.ReplaceAllStringFunc(text, func(s string) string { - if strings.HasPrefix(s, "[") { - sub := regexp.MustCompile(`\[(.*?)\]\(.*?\)`) - matches := sub.FindStringSubmatch(s) - if len(matches) > 1 { - return matches[1] - } - } - if strings.HasPrefix(s, "!") || strings.HasPrefix(s, "#") || strings.ContainsAny(s, "*_`~>|") { - return "" - } - return s - }) - return strings.TrimSpace(text) -} - -func (p *Printer) Wrapped(text string, indent int, maxChars int) string { - if len(text) > maxChars { - text = text[:maxChars] - } - text = p.StripMD(text) - prefix := strings.Repeat(" ", indent) - var lines []string - - paras := strings.Split(text, "\n") - for _, para := range paras { - para = strings.TrimSpace(para) - if para == "" { - lines = append(lines, "") - continue - } - - words := strings.Fields(para) - if len(words) == 0 { - continue - } - - currentLine := prefix + words[0] - for _, word := range words[1:] { - if len(currentLine)+1+len(word) > W { - lines = append(lines, currentLine) - currentLine = prefix + word - } else { - currentLine += " " + word - } - } - lines = append(lines, currentLine) - } - return strings.Join(lines, "\r\n") -} diff --git a/engine/server.go b/engine/server.go deleted file mode 100644 index f775ac3..0000000 --- a/engine/server.go +++ /dev/null @@ -1,123 +0,0 @@ -package engine - -import ( - "fmt" - "net" - "os" - "strings" -) - -// Config holds the BBS server configuration -type Config struct { - Host string - Port string - Banner string - Lang T -} - -// BBS is the main server struct -type BBS struct { - config Config - state *State - menu *Menu -} - -// New creates a new BBS server with the given configuration -func New(cfg Config) *BBS { - return &BBS{ - config: cfg, - state: newState(), - menu: &Menu{}, - } -} - -// State returns the shared server state (for use by content handlers) -func (b *BBS) State() *State { - return b.state -} - -// Menu configures the main menu using the DSL -func (b *BBS) Menu(fn func(*Menu)) { - fn(b.menu) -} - -// Start launches the TCP server and blocks waiting for connections -func (b *BBS) Start() { - ln, err := net.Listen("tcp", b.config.Host+":"+b.config.Port) - if err != nil { - fmt.Printf("Server start error: %v\n", err) - os.Exit(1) - } - defer ln.Close() - - fmt.Printf("BBS running → telnet localhost %s\n", b.config.Port) - fmt.Println("Stop: Ctrl+C") - - for { - conn, err := ln.Accept() - if err != nil { - fmt.Printf("Accept error: %v\n", err) - continue - } - fmt.Printf("[+] Connected: %s\n", conn.RemoteAddr().String()) - go b.handleClient(conn) - } -} - -func (b *BBS) handleClient(conn net.Conn) { - defer conn.Close() - addr := conn.RemoteAddr().String() - lang := b.config.Lang - printer := NewPrinter(conn) - - // Telnet negotiation (IAC WILL ECHO, IAC WILL SGA, IAC WONT LINEMODE) - printer.Send("\xff\xfb\x01\xff\xfb\x03\xff\xfe\x22") - - printer.Send(COLOR_CYAN + b.config.Banner + COLOR_RESET) - printer.Send(fmt.Sprintf("\n%s%s%s ", COLOR_WHITE, lang["AskName"], COLOR_RESET)) - - username, err := printer.ReadLine() - if err != nil { - return - } - username = strings.TrimSpace(username) - if username == "" { - username = "Anonymous" - } - if len(username) > 20 { - username = username[:20] - } - - b.state.Mu.Lock() - b.state.OnlineUsers[addr] = username - b.state.Mu.Unlock() - - defer func() { - b.state.Mu.Lock() - delete(b.state.OnlineUsers, addr) - b.state.Mu.Unlock() - }() - - printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n", COLOR_GREEN, fmt.Sprintf(lang["Greeting"], COLOR_WHITE, username, COLOR_GREEN), COLOR_RESET)) - - session := &Session{ - State: b.state, - Printer: printer, - Username: username, - Addr: addr, - Lang: lang, - } - - for !session.Quit { - printer.Send(b.menu.Render(printer, lang, username)) - choice, err := printer.ReadLine() - if err != nil { - break - } - b.menu.Dispatch(session, choice) - } - - if session.Quit { - printer.Send(fmt.Sprintf("\r\n%s%s%s\r\n\r\n", COLOR_RED, fmt.Sprintf(lang["Goodbye"], username), COLOR_RESET)) - } -} diff --git a/engine/session.go b/engine/session.go deleted file mode 100644 index 152912e..0000000 --- a/engine/session.go +++ /dev/null @@ -1,43 +0,0 @@ -package engine - -import "sync" - -// State holds shared server state (thread-safe) -type State struct { - OnlineUsers map[string]string // addr -> username - Mu sync.Mutex -} - -func newState() *State { - return &State{ - OnlineUsers: make(map[string]string), - } -} - -// UserCount returns the number of online users in a thread-safe manner -func (st *State) UserCount() int { - st.Mu.Lock() - defer st.Mu.Unlock() - return len(st.OnlineUsers) -} - -// Snapshot returns a thread-safe copy of the online users map -func (st *State) Snapshot() map[string]string { - st.Mu.Lock() - defer st.Mu.Unlock() - snap := make(map[string]string) - for k, v := range st.OnlineUsers { - snap[k] = v - } - return snap -} - -// Session represents an active user connection -type Session struct { - State *State - Printer *Printer - Username string - Addr string - Lang T - Quit bool -} diff --git a/go.mod b/go.mod deleted file mode 100644 index 533020e..0000000 --- a/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module bbs-server - -go 1.26.1 diff --git a/main.go b/main.go deleted file mode 100644 index b4d36e7..0000000 --- a/main.go +++ /dev/null @@ -1,74 +0,0 @@ -package main - -import ( - "bbs-server/content" - "bbs-server/engine" - "fmt" - "os" -) - -const banner = ` - ████████╗███████╗██╗ ███████╗████████╗██╗ ██╗██████╗ ███████╗ - ██╔══╝██╔════╝██║ ██╔════╝╚══██╔══╝╚██╗ ██╔╝██╔══██╗██╔════╝ - ██║ █████╗ ██║ █████╗ ██║ ╚████╔╝ ██████╔╝█████╗ - ██║ ██╔══╝ ██║ ██╔══╝ ██║ ╚██╔╝ ██╔═══╝ ██╔══╝ - ██║ ███████╗███████╗███████╗ ██║ ██║ ██║ ███████╗ - ╚═╝ ╚══════╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ - - ██████╗ █████╗ ███╗ ███╗███████╗███████╗ - ██╔════╝ ██╔══██╗████╗ ████║██╔════╝██╔════╝ - ██║ ███╗███████║██╔████╔██║█████╗ ███████╗ - ██║ ██║██╔══██║██║╚██╔╝██║██╔══╝ ╚════██║ - ╚██████╔╝██║ ██║██║ ╚═╝ ██║███████╗███████║ - ╚═════╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ - - ░░ BBS v2.0 ░░ games.teletype.hu ░░ - Welcome to the Teletype community bulletin board! -` - -func main() { - wikiToken := os.Getenv("WEBAPP_WIKIJS_TOKEN") - - boardPath := os.Getenv("MESSAGES_PATH") - if boardPath == "" { - boardPath = "messages.dat" - } - - tokenStatus := "✗ not set" - if wikiToken != "" { - tokenStatus = "✓ set" - } - fmt.Printf("Wiki: %s\n", content.WikiJSBaseURL) - fmt.Printf("Games API: %s\n", content.GamesAPIURL) - fmt.Printf("Token: %s\n", tokenStatus) - - messageBoard := content.NewMessageBoard(boardPath) - blogHandler := content.NewBlogHandler(wikiToken) - howtoHandler := content.NewHowToHandler(wikiToken) - catalogHandler := content.NewCatalogHandler() - messageBoardIndexHandler := content.NewMessageBoardIndexHandler(messageBoard) - messageBoardNewHandler := content.NewMessageBoardNewHandler(messageBoard) - onlineHandler := content.NewOnlineHandler() - sysinfoHandler := content.NewSysinfoHandler(messageBoard) - - bbs := engine.New(engine.Config{ - Host: "0.0.0.0", - Port: "2323", - Banner: banner, - Lang: engine.En, - }) - - bbs.Menu(func(m *engine.Menu) { - m.Title("MAIN MENU") - m.Item("1", "Message Board", engine.COLOR_GREEN, messageBoardIndexHandler.Handle) - m.Item("N", "New Message", engine.COLOR_WHITE, messageBoardNewHandler.Handle) - m.Item("2", "Blog Posts", engine.COLOR_BLUE, blogHandler.Handle) - m.Item("3", "HowTo Guides", engine.COLOR_MAGENTA, howtoHandler.Handle) - m.Item("4", "Game Catalog", engine.COLOR_YELLOW, catalogHandler.Handle) - m.Item("5", "Online Users", engine.COLOR_CYAN, onlineHandler.Handle) - m.Item("6", "System Info", engine.COLOR_GRAY, sysinfoHandler.Handle) - m.Item("Q", "Exit", engine.COLOR_RED, engine.Exit) - }) - - bbs.Start() -}