urit/internal/server/http.go
2026-05-02 21:11:50 -04:00

1145 lines
32 KiB
Go

// 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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Name}}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Courier New", Courier, monospace;
background: #0a0a0a;
color: #33ff33;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
}
.terminal {
max-width: 600px;
width: 100%;
padding: 2rem;
border: 1px solid #33ff33;
margin: 1rem;
}
.header {
text-align: center;
margin-bottom: 1.5rem;
border-bottom: 1px solid #1a7a1a;
padding-bottom: 1rem;
}
h1 { color: #33ff33; font-size: 1.4rem; }
.subtitle { color: #1a7a1a; font-size: 0.85rem; margin-top: 0.25rem; }
.stats { margin: 1rem 0; }
.row { display: flex; justify-content: space-between; padding: 0.2rem 0; }
.label { color: #1a7a1a; }
.val { color: #33ff33; }
.connect {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #1a7a1a;
text-align: center;
font-size: 0.85rem;
}
.connect a { color: #33ff33; }
.footer {
margin-top: 1rem;
text-align: center;
color: #1a7a1a;
font-size: 0.75rem;
}
</style>
</head>
<body>
<div class="terminal">
<div class="header">
<h1>{{.Name}}</h1>
<div class="subtitle">Operated by {{.Sysop}}</div>
<div class="subtitle">Powered by URIT BBS</div>
</div>
<div class="stats">
<div class="row"><span class="label">Users</span><span class="val">{{.UserCount}} registered</span></div>
<div class="row"><span class="label">Boards</span><span class="val">{{.BoardCount}}</span></div>
<div class="row"><span class="label">Libraries</span><span class="val">{{.LibCount}}</span></div>
<div class="row"><span class="label">Online Now</span><span class="val">{{.NodesOnline}} user(s)</span></div>
<div class="row"><span class="label">Total Calls</span><span class="val">{{.TotalCalls}}</span></div>
</div>
<div class="connect">
<p>Connect via telnet:</p>
<p><code>telnet {{.TelnetAddr}}</code></p>
</div>
{{if .LibCount}}<div class="connect">
<a href="/libraries">&gt; Browse File Libraries</a>
</div>{{end}}
<div class="connect">
<a href="/login">&gt; Log In</a>
</div>
<div class="connect" style="font-size:0.75rem;">
<a href="/admin" style="color:#1a7a1a;">&gt; Sysop Console</a>
</div>
<div class="footer">
URIT BBS &mdash; a modern reimplementation of T.A.G.-BBS (1986)
</div>
</div>
</body>
</html>
`))
// --- Library list template ---
var librariesTmpl = template.Must(template.New("libraries").Parse(pageHead("Libraries") + `
<div class="terminal">
<div class="header">
<h1>{{.SystemName}} &mdash; File Libraries</h1>
<div class="subtitle">
<a href="/">&lt; Back to main</a>
{{if .UserName}} | Logged in as <span style="color:#33ff33">{{.UserName}}</span> | <a href="/logout">Log out</a>
{{else}} | <a href="/login">Log in for full access</a>{{end}}
</div>
</div>
{{if .Libraries}}
<table class="file-table">
<thead>
<tr><th class="left">Library</th><th>Files</th><th></th></tr>
</thead>
<tbody>
{{range $.Libraries}}
<tr>
<td><a href="/libraries/{{.ID}}{{$.TokenQuery}}">{{.Name}}</a></td>
<td class="center">{{.FileCount}}</td>
<td class="right"><a href="/libraries/{{.ID}}{{$.TokenQuery}}">browse &gt;</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="empty">No libraries available.{{if not $.UserName}} <a href="/login">Log in</a> to see more.{{end}}</p>
{{end}}
</div>
</body>
</html>
`))
// --- Library files template ---
var libraryFilesTmpl = template.Must(template.New("files").Parse(pageHead("Files") + `
<div class="terminal wide">
<div class="header">
<h1>{{.Library}}</h1>
<div class="subtitle">
<a href="/libraries{{.TokenQuery}}">&lt; Back to libraries</a>
{{if .UserName}} | Logged in as <span style="color:#33ff33">{{.UserName}}</span> | <a href="/logout">Log out</a>
{{else}} | <a href="/login">Log in for full access</a>{{end}}
</div>
</div>
{{if .Files}}
<table class="file-table">
<thead>
<tr>
<th class="left">Filename</th>
<th class="left">Description</th>
<th>Size</th>
<th>DLs</th>
<th>Date</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Files}}
<tr>
<td class="filename">{{.Filename}}</td>
<td class="desc">{{.Description}}</td>
<td class="center">{{.Size}}</td>
<td class="center">{{.Downloads}}</td>
<td class="center nowrap">{{.Date}}</td>
<td class="right"><a href="/libraries/{{$.LibraryID}}/files/{{.ID}}{{$.TokenQuery}}">download</a></td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="empty">No files in this library yet.</p>
{{end}}
{{if .UserName}}<div style="margin-top:1rem; text-align:center; font-size:0.85rem;">
<a href="/libraries/{{.LibraryID}}/upload{{.TokenQuery}}">&gt; Upload a file</a>
</div>{{end}}
</div>
</body>
</html>
`))
// --- Forbidden template ---
var forbiddenTmpl = template.Must(template.New("forbidden").Parse(pageHead("Access Denied") + `
<div class="terminal">
<div class="header">
<h1>{{.SystemName}} &mdash; Access Denied</h1>
<div class="subtitle">
<a href="/libraries">&lt; Back to libraries</a>
{{if .UserName}} | Logged in as <span style="color:#33ff33">{{.UserName}}</span> | <a href="/logout">Log out</a>
{{else}} | <a href="/login">Log in for access</a>{{end}}
</div>
</div>
<p class="empty" style="color:#ff3333">{{.Message}}</p>
{{if not .UserName}}<p class="empty"><a href="/login">Log in</a> to access restricted libraries.</p>{{end}}
</div>
</body>
</html>
`))
// --- Upload template ---
var uploadTmpl = template.Must(template.New("upload").Parse(pageHead("Upload") + `
<div class="terminal">
<div class="header">
<h1>Upload to {{.Library}}</h1>
<div class="subtitle">
<a href="/libraries/{{.LibraryID}}{{.TokenQuery}}">&lt; Back to files</a>
| Logged in as <span style="color:#33ff33">{{.UserName}}</span> | <a href="/logout">Log out</a>
</div>
</div>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="POST" action="/libraries/{{.LibraryID}}/upload{{.TokenQuery}}" enctype="multipart/form-data">
<div class="form-group">
<label for="file">File (max 50 MB)</label>
<input type="file" id="file" name="file" style="color:#33ff33;" />
</div>
<div class="form-group">
<label for="description">Description</label>
<input type="text" id="description" name="description" maxlength="200"
placeholder="Brief description of the file" />
</div>
<div style="margin-top:1rem; text-align:center;">
<button type="submit" class="btn">Upload</button>
</div>
</form>
</div>
</body>
</html>
`))
// 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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>` + title + `</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Courier New", Courier, monospace;
background: #0a0a0a;
color: #33ff33;
min-height: 100vh;
display: flex;
justify-content: center;
padding: 1rem;
}
a { color: #33ff33; text-decoration: none; }
a:hover { text-decoration: underline; }
.terminal {
max-width: 600px;
width: 100%;
padding: 2rem;
border: 1px solid #33ff33;
margin: 1rem auto;
align-self: flex-start;
}
.terminal.wide { max-width: 900px; }
.header {
text-align: center;
margin-bottom: 1.5rem;
border-bottom: 1px solid #1a7a1a;
padding-bottom: 1rem;
}
h1 { color: #33ff33; font-size: 1.2rem; }
.subtitle { color: #1a7a1a; font-size: 0.85rem; margin-top: 0.5rem; }
.subtitle a { color: #1a7a1a; }
.subtitle a:hover { color: #33ff33; }
.file-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.file-table th {
color: #1a7a1a;
border-bottom: 1px solid #1a7a1a;
padding: 0.4rem 0.5rem;
font-weight: normal;
}
.file-table td { padding: 0.3rem 0.5rem; border-bottom: 1px solid #111; }
.file-table tr:hover td { background: #111; }
.left { text-align: left; }
.center { text-align: center; }
.right { text-align: right; }
.nowrap { white-space: nowrap; }
.filename { color: #33ff33; }
.desc { color: #1a7a1a; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty { color: #1a7a1a; text-align: center; padding: 2rem 0; }
.form-group { margin: 1rem 0; }
.form-group label { display: block; color: #1a7a1a; margin-bottom: 0.3rem; font-size: 0.85rem; }
.form-group input {
width: 100%;
padding: 0.5rem;
background: #111;
border: 1px solid #1a7a1a;
color: #33ff33;
font-family: "Courier New", Courier, monospace;
font-size: 0.9rem;
}
.form-group input:focus { outline: none; border-color: #33ff33; }
.btn {
display: inline-block;
padding: 0.5rem 1.5rem;
background: #111;
border: 1px solid #33ff33;
color: #33ff33;
font-family: "Courier New", Courier, monospace;
cursor: pointer;
font-size: 0.9rem;
}
.btn:hover { background: #1a3a1a; }
.error { color: #ff3333; margin: 0.5rem 0; font-size: 0.85rem; }
</style>
</head>
<body>
`
}
// --- Login template ---
var loginTmpl = template.Must(template.New("login").Parse(pageHead("Login") + `
<div class="terminal">
<div class="header">
<h1>{{.SystemName}} &mdash; Login</h1>
<div class="subtitle"><a href="/">&lt; Back to main</a></div>
</div>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
<form method="POST" action="/login">
<p style="color:#1a7a1a; font-size:0.85rem; margin-bottom:1rem;">
Log in with your BBS account to access restricted file libraries.
</p>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" autocomplete="username" />
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" />
</div>
<div style="margin-top:1rem; text-align:center;">
<button type="submit" class="btn">Log In</button>
</div>
</form>
</div>
</body>
</html>
`))