1145 lines
32 KiB
Go
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">> Browse File Libraries</a>
|
|
</div>{{end}}
|
|
<div class="connect">
|
|
<a href="/login">> Log In</a>
|
|
</div>
|
|
<div class="connect" style="font-size:0.75rem;">
|
|
<a href="/admin" style="color:#1a7a1a;">> Sysop Console</a>
|
|
</div>
|
|
<div class="footer">
|
|
URIT BBS — 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}} — File Libraries</h1>
|
|
<div class="subtitle">
|
|
<a href="/">< 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 ></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}}">< 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}}">> 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}} — Access Denied</h1>
|
|
<div class="subtitle">
|
|
<a href="/libraries">< 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}}">< 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}} — Login</h1>
|
|
<div class="subtitle"><a href="/">< 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>
|
|
`)) |