// library.go implements the file library subsystem. // // This replaces LCOM.C from the original TAG-BBS. The original stored // library metadata in Library.Keys files (arrays of Library_Data structs) // and the actual files on disk in per-library directories. File transfer // used XMODEM over serial (TRANSFER.C). // // Our version stores metadata in SQLite and keeps files on disk in the // library's FilePath directory. The catalog browsing experience is fully // preserved: three-level navigation matching the message board pattern // (library selection → per-library commands → between-file prompts). // // File transfer: the original's XMODEM is specific to serial. Over // telnet, the standard is ZMODEM (via external sz/rz tools). This step // implements full catalog browsing and file metadata management. Actual // binary transfer (ZMODEM integration) is noted as a future enhancement. // Text files can be viewed inline. package menu import ( "fmt" "os" "path/filepath" "strings" "github.com/urit/urit/internal/models" "github.com/urit/urit/internal/session" ) // cmdLibrary is the entry point for the file library subsystem. // Replaces LCom() from LCOM.C — the top-level library selection menu. func cmdLibrary(ctx *Context) error { libs, err := ctx.Store.ListLibraries() if err != nil { ctx.Sess.WriteString(" Error loading libraries.\r\n") return nil } // Filter to those the user can access var visible []*models.Library for _, l := range libs { if l.CanDownload(ctx.User.SecLibrary) || l.CanUpload(ctx.User.SecLibrary) { visible = append(visible, l) } } if len(visible) == 0 { ctx.Sess.WriteString(" No file libraries available.\r\n\r\n") return nil } ctx.Sess.NewLine() for { ctx.Sess.CheckTime() ctx.Sess.Color(session.AnsiFgCyan) ctx.Sess.WriteString("N>ew S>ome A>ll L>ist Q>uit\r\n") ctx.Sess.Printf("%s Library> ", ctx.Cfg.System.Name) ctx.Sess.Color(session.AnsiReset) ch, err := ctx.Sess.ReadKey(idleTimeout) if err != nil { return nil } switch toUpper(ch) { case 'L': ctx.Sess.WriteString("List\r\n\r\n") libList(ctx, visible) case 'A': ctx.Sess.WriteString("All Libraries\r\n\r\n") for i, l := range visible { ctx.Sess.Printf("[%d] %s\r\n", i+1, l.Name) quit := libPrompt(ctx, l, i+1) if quit { goto done } } ctx.Sess.WriteString("Completed visiting ALL\r\n") case 'N': ctx.Sess.WriteString("New Files\r\n\r\n") found := false for i, l := range visible { if !l.CanDownload(ctx.User.SecLibrary) { continue } if ctx.User.LastOn != nil && l.LatestFile != nil && !l.LatestFile.After(*ctx.User.LastOn) { continue } found = true ctx.Sess.Printf("\r\n[%d] %s\r\n", i+1, l.Name) libReadNew(ctx, l) quit := libPrompt(ctx, l, i+1) if quit { goto done } } if !found { ctx.Sess.WriteString("Nothing new.\r\n") } ctx.Sess.WriteString("Completed visiting NEW\r\n") case 'S': ctx.Sess.WriteString("Some Libraries\r\n\r\n") for i, l := range visible { ctx.Sess.Printf("Visit [%d] %s? ", i+1, l.Name) ych, err := ctx.Sess.ReadKey(idleTimeout) if err != nil { return nil } switch toUpper(ych) { case 'Y': ctx.Sess.WriteString("Yes\r\n") quit := libPrompt(ctx, l, i+1) if quit { goto done } case 'Q': ctx.Sess.WriteString("Quit\r\n") goto done default: ctx.Sess.WriteString("No\r\n") } } ctx.Sess.WriteString("Completed visiting SOME\r\n") case 'Q', '\x1b': ctx.Sess.WriteString("Quit\r\n") goto done case '?', '/': ctx.Sess.WriteString("Help\r\n") libList(ctx, visible) } } done: return nil } // libList displays the list of visible libraries with file counts. func libList(ctx *Context, libs []*models.Library) { ctx.Sess.Color(session.AnsiFgGreen, session.AnsiBold) ctx.Sess.WriteString("File Libraries\r\n") ctx.Sess.Color(session.AnsiReset) ctx.Sess.WriteString(strings.Repeat("─", 55) + "\r\n") for i, l := range libs { access := "" if l.CanDownload(ctx.User.SecLibrary) && l.CanUpload(ctx.User.SecLibrary) { access = "DL/UL" } else if l.CanDownload(ctx.User.SecLibrary) { access = "DL " } else if l.CanUpload(ctx.User.SecLibrary) { access = " UL" } latest := "no files" if l.LatestFile != nil { latest = l.LatestFile.Format("Jan 02") } ctx.Sess.Printf(" [%d] %-20s %3d files %s %s\r\n", i+1, l.Name, l.FileCount, access, latest) } ctx.Sess.NewLine() } // libPrompt is the per-library command loop. // Replaces Lib_Prompt() from LCOM.C. // Returns true if the user wants to return to the main menu (@). func libPrompt(ctx *Context, lib *models.Library, num int) bool { canDL := lib.CanDownload(ctx.User.SecLibrary) canUL := lib.CanUpload(ctx.User.SecLibrary) for { ctx.Sess.CheckTime() ctx.Sess.NewLine() ctx.Sess.Color(session.AnsiFgCyan) opts := "" if canDL { opts += "N>ew L>ist C>atalog D>ownload " } if canUL { opts += "U>pload " } if ctx.User.SecStatus >= 150 { opts += "R>emove " } opts += "Q>uit @>Main" ctx.Sess.WriteString(opts + "\r\n") ctx.Sess.Printf("[%d] %s> ", num, lib.Name) ctx.Sess.Color(session.AnsiReset) ch, err := ctx.Sess.ReadKey(idleTimeout) if err != nil { return true } switch toUpper(ch) { case 'N': if !canDL { continue } ctx.Sess.WriteString("New\r\n") libReadNew(ctx, lib) case 'L': if !canDL { continue } ctx.Sess.WriteString("List\r\n") libListFrontend(ctx, lib) case 'C': if !canDL { continue } ctx.Sess.WriteString("Catalog\r\n") libCatalog(ctx, lib) case 'D', 'I': if !canDL { continue } ctx.Sess.WriteString("Download\r\n") libDownload(ctx, lib) case 'U': if !canUL { continue } ctx.Sess.WriteString("Upload\r\n") libUpload(ctx, lib) case 'R': if ctx.User.SecStatus < 150 { continue } ctx.Sess.WriteString("Remove\r\n") libRemove(ctx, lib) case 'Q': ctx.Sess.WriteString("Quit\r\n") return false case '@': ctx.Sess.WriteString("Main Menu\r\n") return true case '?', '/': ctx.Sess.WriteString("Help\r\n") ctx.Sess.WriteString(" N - List new files since last login\r\n") ctx.Sess.WriteString(" L - List files by number range\r\n") ctx.Sess.WriteString(" C - Quick catalog (names only)\r\n") ctx.Sess.WriteString(" D - Download a file\r\n") ctx.Sess.WriteString(" U - Upload a file\r\n") if ctx.User.SecStatus >= 150 { ctx.Sess.WriteString(" R - Remove a file entry\r\n") } ctx.Sess.WriteString(" Q - Return to library selection\r\n") ctx.Sess.WriteString(" @ - Return to main menu\r\n") } } } // libReadNew lists files added since the user's last login. // Replaces Lib_ReadNew() from LCOM.C. func libReadNew(ctx *Context, lib *models.Library) { if lib.FileCount == 0 { ctx.Sess.WriteString(" No files in this library.\r\n") return } files, err := ctx.Store.ListLibraryFiles(lib.ID, 0, lib.MaxFiles) if err != nil { ctx.Sess.WriteString(" Error loading files.\r\n") return } // Filter to new files var newFiles []*models.LibraryFile for _, f := range files { if ctx.User.LastOn == nil || f.CreatedAt.After(*ctx.User.LastOn) { newFiles = append(newFiles, f) } } if len(newFiles) == 0 { ctx.Sess.WriteString(" Nothing new.\r\n") return } ctx.Sess.Printf(" %d new file(s)\r\n\r\n", len(newFiles)) libReadSequence(ctx, lib, newFiles) } // libListFrontend prompts for a range and lists files with full details. // Replaces Lib_Read_Frontend() from LCOM.C. func libListFrontend(ctx *Context, lib *models.Library) { if lib.FileCount == 0 { ctx.Sess.WriteString(" No files in this library.\r\n") return } ctx.Sess.Printf(" List FROM [1-%d]: ", lib.FileCount) fromStr, err := ctx.Sess.ReadLine("1", 5, inputTimeout) if err != nil { return } from := parseIntDefault(fromStr, 1) if from < 1 || from > lib.FileCount { ctx.Sess.Printf(" Invalid. Range is 1-%d\r\n", lib.FileCount) return } ctx.Sess.Printf(" List TO [%d-%d]: ", from, lib.FileCount) toStr, err := ctx.Sess.ReadLine(fmt.Sprintf("%d", lib.FileCount), 5, inputTimeout) if err != nil { return } to := parseIntDefault(toStr, lib.FileCount) if to < from || to > lib.FileCount { ctx.Sess.Printf(" Invalid. Range is %d-%d\r\n", from, lib.FileCount) return } files, err := ctx.Store.ListLibraryFiles(lib.ID, from-1, to-from+1) if err != nil { ctx.Sess.WriteString(" Error loading files.\r\n") return } libReadSequence(ctx, lib, files) } // libCatalog shows a quick filename-only listing. // Replaces Lib_Catalog() from LCOM.C. func libCatalog(ctx *Context, lib *models.Library) { if lib.FileCount == 0 { ctx.Sess.WriteString(" No files in this library.\r\n") return } files, err := ctx.Store.ListLibraryFiles(lib.ID, 0, lib.MaxFiles) if err != nil { ctx.Sess.WriteString(" Error loading files.\r\n") return } ctx.Sess.NewLine() var lines []string for i, f := range files { lines = append(lines, fmt.Sprintf(" [%d] %-30s %s %s", i+1, f.Filename, formatSize(f.FileSize), f.CreatedAt.Format("Jan 02"))) } ctx.Sess.Paginate(strings.Join(lines, "\n"), idleTimeout) ctx.Sess.NewLine() } // libReadSequence displays files in order with between-file prompts. // Replaces Lib_Read() from LCOM.C. func libReadSequence(ctx *Context, lib *models.Library, files []*models.LibraryFile) { if len(files) == 0 { return } idx := 0 for idx >= 0 && idx < len(files) { f := files[idx] libDisplayFile(ctx, lib, f, idx+1, len(files)) action := libBetweenPrompt(ctx, lib, f, idx+1) switch action { case libActNext: idx++ case libActAgain: // stay case libActLast: if idx > 0 { idx-- } case libActQuit: return case libActMainMenu: return } } } // libDisplayFile renders a single file entry. // Replaces Send_Message() (the library version) from LCOM.C. func libDisplayFile(ctx *Context, lib *models.Library, f *models.LibraryFile, num, total int) { ctx.Sess.NewLine() ctx.Sess.Color(session.AnsiFgBrightWhite) ctx.Sess.Printf("File Number: [%d] of [%d]\r\n", num, total) ctx.Sess.Color(session.AnsiReset) ctx.Sess.Color(session.AnsiFgCyan) ctx.Sess.Printf(" Filename: %s\r\n", f.Filename) ctx.Sess.Color(session.AnsiReset) ctx.Sess.Printf(" Origin: %s [%d]\r\n", f.Uploader, f.UploaderID) ctx.Sess.Printf(" Size: %s\r\n", formatSize(f.FileSize)) ctx.Sess.Printf(" Validated: %s\r\n", f.CreatedAt.Format("Mon Jan 02 15:04:05 2006")) ctx.Sess.Printf(" Downloads: %d\r\n", f.Downloads) if f.Description != "" { ctx.Sess.NewLine() ctx.Sess.WriteString(f.Description + "\r\n") } else { ctx.Sess.WriteString(" No description available.\r\n") } } type libAction int const ( libActNext libAction = iota libActAgain libActLast libActQuit libActMainMenu ) // libBetweenPrompt shows navigation options between file entries. // Replaces Between_Lib_Prompt() from LCOM.C. func libBetweenPrompt(ctx *Context, lib *models.Library, f *models.LibraryFile, num int) libAction { for { ctx.Sess.CheckTime() ctx.Sess.Color(session.AnsiFgCyan) opts := "N>ext A>gain L>ast S>tats D>ownload Q>uit @>Main" if ctx.User.SecStatus >= 150 { opts += " R>emove" } ctx.Sess.WriteString("\r\n" + opts + "\r\n") ctx.Sess.WriteString("File> ") ctx.Sess.Color(session.AnsiReset) ch, err := ctx.Sess.ReadKey(idleTimeout) if err != nil { return libActQuit } switch toUpper(ch) { case 'N', 'C', '\r': ctx.Sess.WriteString("Next\r\n") return libActNext case 'A': ctx.Sess.WriteString("Again\r\n") return libActAgain case 'L': ctx.Sess.WriteString("Last\r\n") return libActLast case 'S': ctx.Sess.WriteString("Statistics\r\n") libShowStats(ctx, lib, f) case 'D': ctx.Sess.WriteString("Download\r\n") libDoDownload(ctx, lib, f) return libActNext case 'R': if ctx.User.SecStatus < 150 { continue } ctx.Sess.WriteString("Remove\r\n") yes, err := ctx.Sess.Confirm(" Remove this file entry? ", inputTimeout) if err != nil { return libActQuit } if yes { ctx.Store.DeleteLibraryFile(f.ID) if updated, err := ctx.Store.GetLibrary(lib.ID); err == nil { *lib = *updated } ctx.Sess.Color(session.AnsiFgGreen) ctx.Sess.WriteString(" File entry removed.\r\n") ctx.Sess.WriteString(" (The file itself remains on disk.)\r\n") ctx.Sess.Color(session.AnsiReset) } return libActNext case 'Q': ctx.Sess.WriteString("Quit\r\n") return libActQuit case '@': ctx.Sess.WriteString("Main Menu\r\n") return libActMainMenu case '?', '/': ctx.Sess.WriteString("Help\r\n") ctx.Sess.WriteString(" N/Enter - Next file\r\n") ctx.Sess.WriteString(" A - View again\r\n") ctx.Sess.WriteString(" L - Previous file\r\n") ctx.Sess.WriteString(" S - File statistics\r\n") ctx.Sess.WriteString(" D - Download file\r\n") ctx.Sess.WriteString(" Q - Return to library menu\r\n") ctx.Sess.WriteString(" @ - Return to main menu\r\n") } } } // libShowStats displays detailed OS-level stats for a file. // Replaces the 'S' (statistics / report()) command from LCOM.C. func libShowStats(ctx *Context, lib *models.Library, f *models.LibraryFile) { fullPath := filepath.Join(lib.FilePath, f.Filename) info, err := os.Stat(fullPath) if err != nil { ctx.Sess.Printf(" File not found on disk: %s\r\n", fullPath) return } ctx.Sess.NewLine() ctx.Sess.Printf(" Filename: %s\r\n", f.Filename) ctx.Sess.Printf(" On disk: %s\r\n", fullPath) ctx.Sess.Printf(" Size: %s (%d bytes)\r\n", formatSize(info.Size()), info.Size()) ctx.Sess.Printf(" Modified: %s\r\n", info.ModTime().Format("Mon Jan 02 15:04:05 2006")) ctx.Sess.Printf(" DL count: %d\r\n", f.Downloads) ctx.Sess.NewLine() } // libDoDownload handles a file download request. // // The original used XMODEM over serial (Xmodem_Send from TRANSFER.C). // Over telnet, binary transfer requires ZMODEM or similar. For now: // - Text files (.txt, .nfo, .diz, .ans): displayed inline // - Binary files: show path and size, note ZMODEM needed // // Future enhancement: integrate external sz (ZMODEM send) tool. func libDoDownload(ctx *Context, lib *models.Library, f *models.LibraryFile) { fullPath := filepath.Join(lib.FilePath, f.Filename) info, err := os.Stat(fullPath) if err != nil { ctx.Sess.WriteString(" File not found on disk.\r\n") return } ext := strings.ToLower(filepath.Ext(f.Filename)) isText := ext == ".txt" || ext == ".nfo" || ext == ".diz" || ext == ".ans" || ext == ".asc" || ext == ".doc" || ext == ".1st" || ext == ".me" || ext == "" if isText && info.Size() < 64*1024 { // Small text files — display inline (like a bulletin) data, err := os.ReadFile(fullPath) if err != nil { ctx.Sess.WriteString(" Error reading file.\r\n") return } ctx.Sess.NewLine() ctx.Sess.Paginate(string(data), idleTimeout) ctx.Sess.NewLine() // Increment download counter f.Downloads++ ctx.User.Downloads++ ctx.Store.UpdateUser(ctx.User) } else { // Binary or large file — inform the user ctx.Sess.NewLine() ctx.Sess.Printf(" File: %s (%s)\r\n", f.Filename, formatSize(info.Size())) ctx.Sess.Printf(" Path: %s\r\n", fullPath) ctx.Sess.NewLine() ctx.Sess.Color(session.AnsiFgBrightYellow) ctx.Sess.WriteString(" Binary file transfer requires a ZMODEM-capable terminal.\r\n") ctx.Sess.WriteString(" (ZMODEM integration coming in a future update.)\r\n") ctx.Sess.Color(session.AnsiReset) } } // libDownload prompts for a file number and initiates download. // Replaces Lib_Immediate() from LCOM.C. func libDownload(ctx *Context, lib *models.Library) { if lib.FileCount == 0 { ctx.Sess.WriteString(" No files in this library.\r\n") return } ctx.Sess.Printf(" Download which [1-%d]: ", lib.FileCount) numStr, err := ctx.Sess.ReadLine("", 5, inputTimeout) if err != nil { return } num := parseIntDefault(numStr, 0) if num < 1 || num > lib.FileCount { ctx.Sess.WriteString(" Cancelled.\r\n") return } files, err := ctx.Store.ListLibraryFiles(lib.ID, num-1, 1) if err != nil || len(files) == 0 { ctx.Sess.WriteString(" File not found.\r\n") return } libDoDownload(ctx, lib, files[0]) } // libUpload registers a new file in the library. // Replaces Lib_Upload() from LCOM.C. // // The original received the file via XMODEM, then stored the metadata. // Our version records the metadata and expects the sysop to place the // actual file in the library directory (or, for future ZMODEM, receive // the file into that directory). func libUpload(ctx *Context, lib *models.Library) { if lib.FileCount >= lib.MaxFiles { ctx.Sess.WriteString(" Library is full.\r\n") return } ctx.Sess.NewLine() // Filename ctx.Sess.Color(session.AnsiFgCyan) ctx.Sess.WriteString("Filename (Q to quit): ") ctx.Sess.Color(session.AnsiReset) filename, err := ctx.Sess.ReadLine("", 30, inputTimeout) if err != nil { return } filename = strings.TrimSpace(filename) if filename == "" || strings.EqualFold(filename, "Q") { ctx.Sess.WriteString(" Cancelled.\r\n") return } // Security check: no path traversal // Matches original's check for : / * characters if strings.ContainsAny(filename, ":/\\*?\"<>|") { ctx.Sess.WriteString(" Filename cannot contain special characters (: / \\ * ? \" < > |).\r\n") return } // Check for duplicate — replaces Lib_Crossref() existing, _ := ctx.Store.ListLibraryFiles(lib.ID, 0, lib.MaxFiles) for _, f := range existing { if strings.EqualFold(f.Filename, filename) { ctx.Sess.WriteString(" A file with that name already exists.\r\n") return } } ctx.Sess.WriteString(" Filename accepted.\r\n\r\n") // Description ctx.Sess.WriteString("Description (79 chars): ") desc, err := ctx.Sess.ReadLine("", 79, inputTimeout) if err != nil { return } // Uploader name — sysops can override uploader := ctx.User.Name if ctx.User.SecStatus >= 150 { ctx.Sess.WriteString("Name to use: ") nameInput, err := ctx.Sess.ReadLine(uploader, 30, inputTimeout) if err != nil { return } nameInput = strings.TrimSpace(nameInput) if nameInput != "" { uploader = nameInput } } // Check if file actually exists on disk fullPath := filepath.Join(lib.FilePath, filename) var fileSize int64 if info, err := os.Stat(fullPath); err == nil { fileSize = info.Size() ctx.Sess.Printf(" File found on disk: %s\r\n", formatSize(fileSize)) } else { ctx.Sess.Color(session.AnsiFgBrightYellow) ctx.Sess.WriteString(" File not yet on disk.\r\n") ctx.Sess.Printf(" Place it at: %s\r\n", fullPath) ctx.Sess.Color(session.AnsiReset) } // Save the entry entry := &models.LibraryFile{ LibraryID: lib.ID, Filename: filename, Description: strings.TrimSpace(desc), UploaderID: ctx.User.ID, Uploader: uploader, FileSize: fileSize, } if err := ctx.Store.CreateLibraryFile(entry); err != nil { ctx.Sess.WriteString(" Error saving file entry.\r\n") return } ctx.User.Uploads++ ctx.Store.UpdateUser(ctx.User) // Refresh library if updated, err := ctx.Store.GetLibrary(lib.ID); err == nil { *lib = *updated } ctx.Sess.Color(session.AnsiFgGreen) ctx.Sess.Printf(" File entry #%d created.\r\n", entry.ID) ctx.Sess.Color(session.AnsiReset) } // libRemove deletes a file entry from the library. // Replaces Lib_Delete_Frontend() from LCOM.C. func libRemove(ctx *Context, lib *models.Library) { if lib.FileCount == 0 { ctx.Sess.WriteString(" No files to remove.\r\n") return } ctx.Sess.Printf(" Remove which [1-%d] (0 to cancel): ", lib.FileCount) numStr, err := ctx.Sess.ReadLine("", 5, inputTimeout) if err != nil { return } num := parseIntDefault(numStr, 0) if num < 1 || num > lib.FileCount { ctx.Sess.WriteString(" Cancelled.\r\n") return } files, err := ctx.Store.ListLibraryFiles(lib.ID, num-1, 1) if err != nil || len(files) == 0 { ctx.Sess.WriteString(" File not found.\r\n") return } f := files[0] ctx.Sess.Printf(" Remove \"%s\" by %s? ", f.Filename, f.Uploader) yes, err := ctx.Sess.Confirm("", inputTimeout) if err != nil || !yes { ctx.Sess.WriteString(" Not removed.\r\n") return } ctx.Store.DeleteLibraryFile(f.ID) if updated, err := ctx.Store.GetLibrary(lib.ID); err == nil { *lib = *updated } ctx.Sess.Color(session.AnsiFgGreen) ctx.Sess.WriteString(" File entry removed.\r\n") ctx.Sess.WriteString(" (The file itself remains on disk.)\r\n") ctx.Sess.Color(session.AnsiReset) } // formatSize formats a byte count as a human-readable string. func formatSize(bytes int64) string { switch { case bytes >= 1024*1024: return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024)) case bytes >= 1024: return fmt.Sprintf("%.1f KB", float64(bytes)/1024) default: return fmt.Sprintf("%d B", bytes) } }