// http.go implements the HTTP file server. // // The original TAG-BBS had no HTTP — file transfers used ZMODEM or // other serial protocols via the library commands. Our HTTP server // is the modern replacement: users can browse libraries and download // files in their web browser. // // Step 14a: infrastructure — listener, health endpoint, info page, // static screen file serving. Later steps add library browsing (14b), // authenticated downloads (14c), and uploads (14d). package server import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "html/template" "io" "log" "net/http" "os" "path/filepath" "strconv" "strings" "time" "golang.org/x/crypto/bcrypt" "github.com/urit/urit/internal/config" "github.com/urit/urit/internal/models" "github.com/urit/urit/internal/store" ) const ( sessionCookieName = "urit_session" sessionDuration = 24 * time.Hour ) // HTTPServer serves files and system info over HTTP. type HTTPServer struct { cfg *config.Config store store.Store bbs *Server // Reference to the BBS server for node info server *http.Server } // NewHTTP creates an HTTPServer wired to the BBS server and store. func NewHTTP(cfg *config.Config, db store.Store, bbs *Server) *HTTPServer { h := &HTTPServer{ cfg: cfg, store: db, bbs: bbs, } mux := http.NewServeMux() // Health check — lightweight JSON endpoint for monitoring mux.HandleFunc("/health", h.handleHealth) // Auth — login/logout for HTTP file server access mux.HandleFunc("GET /login", h.handleLoginPage) mux.HandleFunc("POST /login", h.handleLoginSubmit) mux.HandleFunc("/logout", h.handleLogout) // Library browsing and downloads mux.HandleFunc("GET /libraries", h.handleLibraries) mux.HandleFunc("GET /libraries/{id}", h.handleLibraryFiles) mux.HandleFunc("GET /libraries/{id}/files/{fileID}", h.handleDownload) mux.HandleFunc("GET /libraries/{id}/upload", h.handleUploadPage) mux.HandleFunc("POST /libraries/{id}/upload", h.handleUploadSubmit) // Sysop console — web-based admin dashboard h.registerAdminRoutes(mux) // System info — landing page with BBS identity and status mux.HandleFunc("/", h.handleIndex) // Static screen files — serves ANSI art files from the screens dir. // These can be viewed in a browser or fetched by terminal clients. if cfg.System.Screens != "" { mux.Handle("/screens/", http.StripPrefix("/screens/", http.FileServer(http.Dir(cfg.System.Screens)))) } h.server = &http.Server{ Addr: cfg.HTTP.Address, Handler: mux, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } return h } // ListenAndServe starts the HTTP listener. This should be called in // its own goroutine alongside the telnet server. func (h *HTTPServer) ListenAndServe() error { log.Printf("HTTP listening on %s", h.cfg.HTTP.Address) err := h.server.ListenAndServe() if err == http.ErrServerClosed { return nil } return err } // Close shuts down the HTTP server gracefully. func (h *HTTPServer) Close() error { if h.server != nil { return h.server.Close() } return nil } // handleHealth returns a simple JSON health check. func (h *HTTPServer) handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "status": "ok", "system": h.cfg.System.Name, "version": "0.2.0", }) } // --- Auth handlers --- // --- Auth helpers --- // httpIdentity holds the resolved identity for an HTTP request. // Populated from either a cookie session or a telnet-generated token. type httpIdentity struct { UserID int64 UserName string SecLibrary int } // resolveAuth checks both authentication mechanisms and returns // the user's identity if authenticated. Checks cookie session first // (database-backed login), then ?token= query param (telnet-generated). // Returns nil for anonymous visitors. func (h *HTTPServer) resolveAuth(r *http.Request) *httpIdentity { // 1. Cookie-based session (from /login) if user := h.getSessionUser(r); user != nil { return &httpIdentity{ UserID: user.ID, UserName: user.Name, SecLibrary: user.SecLibrary, } } // 2. Token from query string (from telnet [D] command) if tok := r.URL.Query().Get("token"); tok != "" { if info := h.bbs.Tokens.Validate(tok); info != nil { return &httpIdentity{ UserID: info.UserID, UserName: info.UserName, SecLibrary: info.SecLibrary, } } } return nil } // getSessionUser extracts the current user from the session cookie. // Returns nil if not logged in or session expired. func (h *HTTPServer) getSessionUser(r *http.Request) *models.User { cookie, err := r.Cookie(sessionCookieName) if err != nil { return nil } ws, err := h.store.GetWebSession(cookie.Value) if err != nil || ws == nil { return nil } user, err := h.store.GetUser(ws.UserID) if err != nil || user == nil { return nil } return user } // generateToken creates a cryptographically random hex token. func generateToken() (string, error) { b := make([]byte, 32) if _, err := rand.Read(b); err != nil { return "", err } return hex.EncodeToString(b), nil } // handleLoginPage renders the login form. func (h *HTTPServer) handleLoginPage(w http.ResponseWriter, r *http.Request) { // Already logged in? Redirect to libraries. if user := h.getSessionUser(r); user != nil { http.Redirect(w, r, "/libraries", http.StatusSeeOther) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") loginTmpl.Execute(w, loginData{ SystemName: h.cfg.System.Name, }) } // handleLoginSubmit validates credentials and creates a session. func (h *HTTPServer) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { username := r.FormValue("username") password := r.FormValue("password") if username == "" || password == "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") loginTmpl.Execute(w, loginData{ SystemName: h.cfg.System.Name, Error: "Username and password required.", }) return } user, err := h.store.GetUserByName(username) if err != nil || user == nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") loginTmpl.Execute(w, loginData{ SystemName: h.cfg.System.Name, Error: "Invalid username or password.", }) return } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { w.Header().Set("Content-Type", "text/html; charset=utf-8") loginTmpl.Execute(w, loginData{ SystemName: h.cfg.System.Name, Error: "Invalid username or password.", }) return } token, err := generateToken() if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } now := time.Now() ws := &models.WebSession{ Token: token, UserID: user.ID, UserName: user.Name, CreatedAt: now, ExpiresAt: now.Add(sessionDuration), } if err := h.store.CreateWebSession(ws); err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: token, Path: "/", Expires: ws.ExpiresAt, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) log.Printf("HTTP login: %s (ID=%d)", user.Name, user.ID) // Clean up expired sessions periodically (best-effort) go h.store.CleanExpiredWebSessions() http.Redirect(w, r, "/libraries", http.StatusSeeOther) } // handleLogout clears the session and redirects to the index. func (h *HTTPServer) handleLogout(w http.ResponseWriter, r *http.Request) { if cookie, err := r.Cookie(sessionCookieName); err == nil { h.store.DeleteWebSession(cookie.Value) } http.SetCookie(w, &http.Cookie{ Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, }) http.Redirect(w, r, "/", http.StatusSeeOther) } // handleIndex renders the BBS landing page with system info and status. func (h *HTTPServer) handleIndex(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } // Gather stats stats, _ := h.store.GetAllStats() userCount, _ := h.store.CountUsers() boards, _ := h.store.ListBoards() libs, _ := h.store.ListLibraries() nodes := h.bbs.ActiveNodes() data := indexData{ Name: h.cfg.System.Name, Sysop: h.cfg.System.Sysop, TelnetAddr: h.cfg.Telnet.Address, UserCount: userCount, BoardCount: len(boards), LibCount: len(libs), NodesOnline: len(nodes), TotalCalls: stats["total_calls"], } w.Header().Set("Content-Type", "text/html; charset=utf-8") indexTmpl.Execute(w, data) } type indexData struct { Name string Sysop string TelnetAddr string UserCount int BoardCount int LibCount int NodesOnline int TotalCalls int64 } type loginData struct { SystemName string Error string } // handleLibraries lists libraries accessible to the current user. // Anonymous visitors see only public libraries (DownloadLow=0). // Authenticated users see libraries matching their security level. func (h *HTTPServer) handleLibraries(w http.ResponseWriter, r *http.Request) { identity := h.resolveAuth(r) secLevel := 0 if identity != nil { secLevel = identity.SecLibrary } allLibs, err := h.store.ListLibraries() if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } var libs []*libraryListItem for _, lib := range allLibs { if lib.CanDownload(secLevel) { libs = append(libs, &libraryListItem{ ID: lib.ID, Name: lib.Name, FileCount: lib.FileCount, }) } } // Propagate token to library links so auth carries through tokenParam := r.URL.Query().Get("token") w.Header().Set("Content-Type", "text/html; charset=utf-8") librariesTmpl.Execute(w, librariesData{ SystemName: h.cfg.System.Name, Libraries: libs, UserName: identityName(identity), Token: tokenParam, }) } type librariesData struct { SystemName string Libraries []*libraryListItem UserName string // Empty if anonymous Token string // Telnet token to propagate in links } // TokenQuery returns the token as a query string fragment, or empty. func (d librariesData) TokenQuery() string { if d.Token != "" { return "?token=" + d.Token } return "" } // identityName returns the username or empty string for nil identity. func identityName(id *httpIdentity) string { if id != nil { return id.UserName } return "" } type libraryListItem struct { ID int64 Name string FileCount int } // handleLibraryFiles lists files in a specific library. // Access is checked against the user's security level. func (h *HTTPServer) handleLibraryFiles(w http.ResponseWriter, r *http.Request) { idStr := r.PathValue("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil || id < 1 { http.NotFound(w, r) return } lib, err := h.store.GetLibrary(id) if err != nil || lib == nil { http.NotFound(w, r) return } identity := h.resolveAuth(r) secLevel := 0 if identity != nil { secLevel = identity.SecLibrary } if !lib.CanDownload(secLevel) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusForbidden) forbiddenTmpl.Execute(w, forbiddenData{ SystemName: h.cfg.System.Name, Message: "You don't have access to this library.", UserName: identityName(identity), }) return } files, err := h.store.ListLibraryFiles(lib.ID, 0, lib.MaxFiles) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } tokenParam := r.URL.Query().Get("token") var items []*fileListItem for _, f := range files { items = append(items, &fileListItem{ ID: f.ID, Filename: f.Filename, Description: f.Description, Uploader: f.Uploader, Size: formatSize(f.FileSize), Downloads: f.Downloads, Date: f.CreatedAt.Format("Jan 02, 2006"), }) } w.Header().Set("Content-Type", "text/html; charset=utf-8") libraryFilesTmpl.Execute(w, libraryFilesData{ SystemName: h.cfg.System.Name, Library: lib.Name, LibraryID: lib.ID, Files: items, UserName: identityName(identity), Token: tokenParam, }) } type forbiddenData struct { SystemName string Message string UserName string } // handleDownload serves a file from a library for download. // This is the ZMODEM replacement — instead of a serial transfer // protocol, files are served over HTTP with proper headers. // // Path: GET /libraries/{id}/files/{fileID} // Auth: checks download access via resolveAuth func (h *HTTPServer) handleDownload(w http.ResponseWriter, r *http.Request) { libIDStr := r.PathValue("id") fileIDStr := r.PathValue("fileID") libID, err := strconv.ParseInt(libIDStr, 10, 64) if err != nil || libID < 1 { http.NotFound(w, r) return } fileID, err := strconv.ParseInt(fileIDStr, 10, 64) if err != nil || fileID < 1 { http.NotFound(w, r) return } // Look up library and file lib, err := h.store.GetLibrary(libID) if err != nil || lib == nil { http.NotFound(w, r) return } file, err := h.store.GetLibraryFile(fileID) if err != nil || file == nil || file.LibraryID != lib.ID { http.NotFound(w, r) return } // Auth check identity := h.resolveAuth(r) secLevel := 0 if identity != nil { secLevel = identity.SecLibrary } if !lib.CanDownload(secLevel) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusForbidden) forbiddenTmpl.Execute(w, forbiddenData{ SystemName: h.cfg.System.Name, Message: "You don't have access to download from this library.", UserName: identityName(identity), }) return } // Resolve the file path on disk: library's base directory + filename. // Security: Clean the filename to prevent directory traversal. safeName := filepath.Base(file.Filename) diskPath := filepath.Join(lib.FilePath, safeName) f, err := os.Open(diskPath) if err != nil { log.Printf("Download error: %s: %v", diskPath, err) http.NotFound(w, r) return } defer f.Close() stat, err := f.Stat() if err != nil || stat.IsDir() { http.NotFound(w, r) return } // Increment download counter (best-effort, don't block the download) h.store.IncrementDownloads(file.ID) h.store.IncrementStat("files_downloaded", 1) userName := "anonymous" if identity != nil { userName = identity.UserName } log.Printf("HTTP download: %s/%s by %s", lib.Name, file.Filename, userName) // Serve the file with Content-Disposition so browsers download // rather than trying to render it inline. w.Header().Set("Content-Disposition", "attachment; filename=\""+safeName+"\"") http.ServeContent(w, r, safeName, stat.ModTime(), f) } type libraryFilesData struct { SystemName string Library string LibraryID int64 Files []*fileListItem UserName string Token string } // TokenQuery returns the token as a query string fragment, or empty. func (d libraryFilesData) TokenQuery() string { if d.Token != "" { return "?token=" + d.Token } return "" } type fileListItem struct { ID int64 Filename string Description string Uploader string Size string Downloads int Date string } // --- Upload handlers --- const maxUploadSize = 50 << 20 // 50 MB // handleUploadPage renders the file upload form for a library. // Requires authentication and upload permission. func (h *HTTPServer) handleUploadPage(w http.ResponseWriter, r *http.Request) { libID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || libID < 1 { http.NotFound(w, r) return } lib, err := h.store.GetLibrary(libID) if err != nil || lib == nil { http.NotFound(w, r) return } identity := h.resolveAuth(r) if identity == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } if !lib.CanUpload(identity.SecLibrary) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusForbidden) forbiddenTmpl.Execute(w, forbiddenData{ SystemName: h.cfg.System.Name, Message: "You don't have upload access to this library.", UserName: identity.UserName, }) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") uploadTmpl.Execute(w, uploadData{ SystemName: h.cfg.System.Name, Library: lib.Name, LibraryID: lib.ID, UserName: identity.UserName, Token: r.URL.Query().Get("token"), }) } // handleUploadSubmit processes a multipart file upload. func (h *HTTPServer) handleUploadSubmit(w http.ResponseWriter, r *http.Request) { libID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || libID < 1 { http.NotFound(w, r) return } lib, err := h.store.GetLibrary(libID) if err != nil || lib == nil { http.NotFound(w, r) return } identity := h.resolveAuth(r) if identity == nil { http.Redirect(w, r, "/login", http.StatusSeeOther) return } if !lib.CanUpload(identity.SecLibrary) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusForbidden) forbiddenTmpl.Execute(w, forbiddenData{ SystemName: h.cfg.System.Name, Message: "You don't have upload access to this library.", UserName: identity.UserName, }) return } // Check library capacity count, err := h.store.CountLibraryFiles(lib.ID) if err == nil && lib.MaxFiles > 0 && count >= lib.MaxFiles { h.renderUploadError(w, lib, identity, r, "This library is full.") return } // Parse multipart form with size limit r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize) if err := r.ParseMultipartForm(maxUploadSize); err != nil { h.renderUploadError(w, lib, identity, r, "File too large. Maximum size is 50 MB.") return } file, header, err := r.FormFile("file") if err != nil { h.renderUploadError(w, lib, identity, r, "No file selected.") return } defer file.Close() description := strings.TrimSpace(r.FormValue("description")) // Sanitize filename: strip path components, reject empty or dot-files safeName := filepath.Base(header.Filename) if safeName == "." || safeName == ".." || safeName == "" || strings.HasPrefix(safeName, ".") { h.renderUploadError(w, lib, identity, r, "Invalid filename.") return } // Check if filename already exists in this library existing, _ := h.store.ListLibraryFiles(lib.ID, 0, lib.MaxFiles) for _, ef := range existing { if strings.EqualFold(ef.Filename, safeName) { h.renderUploadError(w, lib, identity, r, fmt.Sprintf("A file named '%s' already exists in this library.", safeName)) return } } // Ensure library directory exists if err := os.MkdirAll(lib.FilePath, 0755); err != nil { log.Printf("Upload error: cannot create library dir %s: %v", lib.FilePath, err) h.renderUploadError(w, lib, identity, r, "Server error saving file.") return } // Write file to disk diskPath := filepath.Join(lib.FilePath, safeName) dst, err := os.Create(diskPath) if err != nil { log.Printf("Upload error: cannot create file %s: %v", diskPath, err) h.renderUploadError(w, lib, identity, r, "Server error saving file.") return } written, err := io.Copy(dst, file) dst.Close() if err != nil { os.Remove(diskPath) // Clean up partial file log.Printf("Upload error: write failed for %s: %v", diskPath, err) h.renderUploadError(w, lib, identity, r, "Server error saving file.") return } // Create database record lf := &models.LibraryFile{ LibraryID: lib.ID, Filename: safeName, Description: description, UploaderID: identity.UserID, Uploader: identity.UserName, FileSize: written, } if err := h.store.CreateLibraryFile(lf); err != nil { os.Remove(diskPath) // Clean up on DB failure log.Printf("Upload error: DB insert failed for %s: %v", safeName, err) h.renderUploadError(w, lib, identity, r, "Server error recording file.") return } h.store.IncrementStat("files_uploaded", 1) log.Printf("HTTP upload: %s/%s (%s) by %s", lib.Name, safeName, formatSize(written), identity.UserName) // Redirect back to library file list tokenParam := r.URL.Query().Get("token") target := fmt.Sprintf("/libraries/%d", lib.ID) if tokenParam != "" { target += "?token=" + tokenParam } http.Redirect(w, r, target, http.StatusSeeOther) } // renderUploadError re-renders the upload form with an error message. func (h *HTTPServer) renderUploadError(w http.ResponseWriter, lib *models.Library, identity *httpIdentity, r *http.Request, msg string) { w.Header().Set("Content-Type", "text/html; charset=utf-8") uploadTmpl.Execute(w, uploadData{ SystemName: h.cfg.System.Name, Library: lib.Name, LibraryID: lib.ID, UserName: identityName(identity), Token: r.URL.Query().Get("token"), Error: msg, }) } type uploadData struct { SystemName string Library string LibraryID int64 UserName string Token string Error string } // formatSize returns a human-readable file size string. func formatSize(bytes int64) string { switch { case bytes >= 1<<30: return strconv.FormatFloat(float64(bytes)/float64(1<<30), 'f', 1, 64) + " GB" case bytes >= 1<<20: return strconv.FormatFloat(float64(bytes)/float64(1<<20), 'f', 1, 64) + " MB" case bytes >= 1<<10: return strconv.FormatFloat(float64(bytes)/float64(1<<10), 'f', 1, 64) + " KB" default: return strconv.FormatInt(bytes, 10) + " B" } } var indexTmpl = template.Must(template.New("index").Parse(` {{.Name}}

{{.Name}}

Operated by {{.Sysop}}
Powered by URIT BBS
Users{{.UserCount}} registered
Boards{{.BoardCount}}
Libraries{{.LibCount}}
Online Now{{.NodesOnline}} user(s)
Total Calls{{.TotalCalls}}

Connect via telnet:

telnet {{.TelnetAddr}}

{{if .LibCount}}
> Browse File Libraries
{{end}}
> Log In
> Sysop Console
`)) // --- Library list template --- var librariesTmpl = template.Must(template.New("libraries").Parse(pageHead("Libraries") + `

{{.SystemName}} — File Libraries

< Back to main {{if .UserName}} | Logged in as {{.UserName}} | Log out {{else}} | Log in for full access{{end}}
{{if .Libraries}} {{range $.Libraries}} {{end}}
LibraryFiles
{{.Name}} {{.FileCount}} browse >
{{else}}

No libraries available.{{if not $.UserName}} Log in to see more.{{end}}

{{end}}
`)) // --- Library files template --- var libraryFilesTmpl = template.Must(template.New("files").Parse(pageHead("Files") + `

{{.Library}}

< Back to libraries {{if .UserName}} | Logged in as {{.UserName}} | Log out {{else}} | Log in for full access{{end}}
{{if .Files}} {{range .Files}} {{end}}
Filename Description Size DLs Date
{{.Filename}} {{.Description}} {{.Size}} {{.Downloads}} {{.Date}} download
{{else}}

No files in this library yet.

{{end}} {{if .UserName}}
> Upload a file
{{end}}
`)) // --- Forbidden template --- var forbiddenTmpl = template.Must(template.New("forbidden").Parse(pageHead("Access Denied") + `

{{.SystemName}} — Access Denied

< Back to libraries {{if .UserName}} | Logged in as {{.UserName}} | Log out {{else}} | Log in for access{{end}}

{{.Message}}

{{if not .UserName}}

Log in to access restricted libraries.

{{end}}
`)) // --- Upload template --- var uploadTmpl = template.Must(template.New("upload").Parse(pageHead("Upload") + `

Upload to {{.Library}}

< Back to files | Logged in as {{.UserName}} | Log out
{{if .Error}}

{{.Error}}

{{end}}
`)) // TokenQuery returns the token as a query string fragment, or empty. func (d uploadData) TokenQuery() string { if d.Token != "" { return "?token=" + d.Token } return "" } // pageHead returns the shared HTML head and CSS used by all pages. // Keeps the green-on-black terminal aesthetic consistent. func pageHead(title string) string { return ` ` + title + ` ` } // --- Login template --- var loginTmpl = template.Must(template.New("login").Parse(pageHead("Login") + `

{{.SystemName}} — Login

{{if .Error}}

{{.Error}}

{{end}}

Log in with your BBS account to access restricted file libraries.

`))