Files
dostalgia/upload.go
T
David Alvarez 545e2d2bb5 Fix Play button 404: bundle path and field name
- .jsdos bundles saved flat in data/games/ so /games/{id}.jsdos serves them
- bundleUrl() uses game.id (matches JSON) instead of game.slug (undefined)
- Delete handler also removes flat .jsdos bundle on game deletion
2026-05-23 18:38:53 +02:00

400 lines
9.6 KiB
Go

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 flat in data/games/
bundleFile = gameID + ".jsdos"
bundlePath := filepath.Join(filepath.Dir(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