package main import ( "archive/zip" "fmt" "io" "io/fs" "net/http" "os" "path/filepath" "strings" "time" ) const dosboxConfTemplate = `[autoexec] @echo off mount c . c: cls %s ` // handleUploadGame handles POST /api/upload — receives a ZIP, creates a .jsdos bundle. func handleUploadGame(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Parse multipart form (max 500MB) if err := r.ParseMultipartForm(500 << 20); err != nil { http.Error(w, "Failed to parse upload: "+err.Error(), http.StatusBadRequest) return } file, header, err := r.FormFile("file") if err != nil { http.Error(w, "No file provided", http.StatusBadRequest) return } defer file.Close() title := r.FormValue("title") if title == "" { title = strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)) } gameID := sanitizeID(title) // Ensure uniqueness baseID := gameID counter := 2 for { if _, err := loadGame(gameID); err != nil { break // not found, we can use this ID } gameID = fmt.Sprintf("%s-%d", baseID, counter) counter++ } // Save uploaded ZIP to temp file tmpDir, err := os.MkdirTemp("", "dostalgia-upload-") if err != nil { http.Error(w, "Server error", http.StatusInternalServerError) return } defer os.RemoveAll(tmpDir) zipPath := filepath.Join(tmpDir, header.Filename) dst, err := os.Create(zipPath) if err != nil { http.Error(w, "Server error", http.StatusInternalServerError) return } if _, err := io.Copy(dst, file); err != nil { dst.Close() http.Error(w, "Failed to save upload", http.StatusInternalServerError) return } dst.Close() // Extract ZIP extractDir := filepath.Join(tmpDir, "extracted") if err := os.MkdirAll(extractDir, 0755); err != nil { http.Error(w, "Server error", http.StatusInternalServerError) return } if err := extractZip(zipPath, extractDir); err != nil { http.Error(w, "Invalid ZIP file: "+err.Error(), http.StatusBadRequest) return } // Find main executable mainExe := findMainExe(extractDir) if mainExe == "" { http.Error(w, "No executable (.exe/.com/.bat) found in the archive", http.StatusBadRequest) return } // Determine size and strategy sizeMB := dirSizeMB(extractDir) bundleType := "standard" if sizeMB > 80 { bundleType = "sockdrive" } // Set up game directory gameDirPath := gameDir(gameID) if err := os.MkdirAll(gameDirPath, 0755); err != nil { http.Error(w, "Server error", http.StatusInternalServerError) return } var bundleFile string if bundleType == "sockdrive" { // Copy all files loose if err := copyDir(extractDir, gameDirPath); err != nil { http.Error(w, "Failed to store game files", http.StatusInternalServerError) return } // Create dosbox.conf confDir := filepath.Join(gameDirPath, ".jsdos") os.MkdirAll(confDir, 0755) relExe, _ := filepath.Rel(extractDir, mainExe) os.WriteFile(filepath.Join(confDir, "dosbox.conf"), []byte(fmt.Sprintf(dosboxConfTemplate, relExe)), 0644) bundleFile = ".jsdos/dosbox.conf" } else { // Create .jsdos ZIP bundle bundleFile = gameID + ".jsdos" bundlePath := filepath.Join(gameDirPath, bundleFile) if err := createJSDOSBundle(extractDir, mainExe, bundlePath); err != nil { http.Error(w, "Failed to create bundle: "+err.Error(), http.StatusInternalServerError) return } } // Save metadata game := &Game{ ID: gameID, Title: title, BundleType: bundleType, BundleFile: bundleFile, Ready: true, } if err := saveGame(game); err != nil { http.Error(w, "Failed to save metadata", http.StatusInternalServerError) return } writeJSON(w, http.StatusCreated, game) } // extractZip extracts a ZIP file to the given directory. func extractZip(zipPath, dest string) error { r, err := zip.OpenReader(zipPath) if err != nil { return err } defer r.Close() for _, f := range r.File { // Prevent path traversal target := filepath.Join(dest, f.Name) if !strings.HasPrefix(filepath.Clean(target), filepath.Clean(dest)+string(os.PathSeparator)) { continue } if f.FileInfo().IsDir() { os.MkdirAll(target, 0755) continue } os.MkdirAll(filepath.Dir(target), 0755) src, err := f.Open() if err != nil { return err } dst, err := os.Create(target) if err != nil { src.Close() return err } _, err = io.Copy(dst, src) src.Close() dst.Close() if err != nil { return err } } return nil } // mainExeCandidate is used for sorting executables by relevance. type mainExeCandidate struct { depth int isInstaller bool size int64 name string path string } // findMainExe finds the most likely main executable in an extracted game directory. func findMainExe(dir string) string { var candidates []mainExeCandidate filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } ext := strings.ToLower(filepath.Ext(d.Name())) if ext != ".exe" && ext != ".com" && ext != ".bat" { return nil } info, _ := d.Info() name := strings.ToLower(d.Name()) isInstaller := strings.Contains(name, "install") || strings.Contains(name, "setup") || strings.Contains(name, "config") depth := strings.Count(path[len(dir):], string(os.PathSeparator)) candidates = append(candidates, mainExeCandidate{ depth: depth, isInstaller: isInstaller, size: info.Size(), name: name, path: path, }) return nil }) if len(candidates) == 0 { return "" } // Sort: shallow depth, non-installer, larger files first sortCandidates(candidates) return candidates[0].path } func sortCandidates(c []mainExeCandidate) { for i := 0; i < len(c); i++ { for j := i + 1; j < len(c); j++ { // Compare: depth (lower first), installer (false first), size (larger first) swap := false if c[i].depth != c[j].depth { swap = c[i].depth > c[j].depth } else if c[i].isInstaller != c[j].isInstaller { swap = c[i].isInstaller } else { swap = c[i].size < c[j].size } if swap { c[i], c[j] = c[j], c[i] } } } } // dirSizeMB returns the total size of a directory in megabytes. func dirSizeMB(dir string) float64 { var total int64 filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } info, _ := d.Info() total += info.Size() return nil }) return float64(total) / (1024 * 1024) } // copyDir copies a directory recursively. func copyDir(src, dst string) error { return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error { if err != nil { return err } rel, _ := filepath.Rel(src, path) target := filepath.Join(dst, rel) if d.IsDir() { return os.MkdirAll(target, 0755) } data, err := os.ReadFile(path) if err != nil { return err } return os.WriteFile(target, data, 0644) }) } // createJSDOSBundle creates a .jsdos bundle ZIP from extracted game files. func createJSDOSBundle(extractDir, mainExe, bundlePath string) error { // Create a temp directory for bundling tmpDir, err := os.MkdirTemp("", "dostalgia-bundle-") if err != nil { return err } defer os.RemoveAll(tmpDir) // Copy game files if err := copyDir(extractDir, tmpDir); err != nil { return err } // Create .jsdos config jsdosDir := filepath.Join(tmpDir, ".jsdos") if err := os.MkdirAll(jsdosDir, 0755); err != nil { return err } relExe, _ := filepath.Rel(extractDir, mainExe) conf := fmt.Sprintf(dosboxConfTemplate, relExe) if err := os.WriteFile(filepath.Join(jsdosDir, "dosbox.conf"), []byte(conf), 0644); err != nil { return err } // Create the ZIP zf, err := os.Create(bundlePath) if err != nil { return err } defer zf.Close() zw := zip.NewWriter(zf) defer zw.Close() return filepath.WalkDir(tmpDir, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return err } rel, _ := filepath.Rel(tmpDir, path) w, err := zw.Create(rel) if err != nil { return err } data, err := os.ReadFile(path) if err != nil { return err } _, err = w.Write(data) return err }) } // handleUploadCover handles POST /api/games/{id}/cover — upload cover art. func handleUploadCover(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } id := r.PathValue("id") if _, err := loadGame(id); err != nil { http.Error(w, "Game not found", http.StatusNotFound) return } if err := r.ParseMultipartForm(10 << 20); err != nil { http.Error(w, "File too large", http.StatusBadRequest) return } file, header, err := r.FormFile("file") if err != nil { http.Error(w, "No file provided", http.StatusBadRequest) return } defer file.Close() // Determine extension ext := strings.ToLower(filepath.Ext(header.Filename)) if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" { http.Error(w, "Only JPG, PNG, or WebP images accepted", http.StatusBadRequest) return } coverPath := filepath.Join(gameDir(id), "cover"+ext) // Remove old cover files for _, oldExt := range []string{".jpg", ".jpeg", ".png", ".webp"} { os.Remove(filepath.Join(gameDir(id), "cover"+oldExt)) } dst, err := os.Create(coverPath) if err != nil { http.Error(w, "Server error", http.StatusInternalServerError) return } defer dst.Close() if _, err := io.Copy(dst, file); err != nil { http.Error(w, "Failed to save cover", http.StatusInternalServerError) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "path": coverPath}) } var _ = time.Now // keep import