package main import ( "context" "crypto/rand" _ "embed" "encoding/base64" "encoding/json" "fmt" "html" "net" "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "sync" "time" ) //go:embed assets/qrcode.min.js var qrcodeJS []byte type activeShare struct { server *http.Server listener net.Listener port int token string files []sharedFile } type sharedFile struct { buf []byte filename string } var ( activeMu sync.Mutex active *activeShare ) func stopActiveShare() { activeMu.Lock() defer activeMu.Unlock() if active != nil { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = active.server.Shutdown(ctx) active = nil } } func MobileQrScript(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/javascript; charset=utf-8") _, _ = w.Write(qrcodeJS) } func MobilePage(w http.ResponseWriter, r *http.Request) { body := `

Send To Mobile

Pick HTML files, then start sharing. Scan the QR with a phone on the same WiFi.

Inputs

or drop HTML files or folders here
(no files selected)
` w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(Page("/mobile", "Send To Mobile", body))) } func MobilePick(w http.ResponseWriter, r *http.Request) { picked := PickFiles( "Select HTML", []FileFilter{{Name: "HTML", Extensions: []string{"html", "htm"}}}, true, "", ) if len(picked) == 0 { writeJSON(w, map[string]any{"files": []any{}}) return } files := make([]map[string]string, 0, len(picked)) for _, p := range picked { files = append(files, map[string]string{"path": p, "name": filepath.Base(p)}) } writeJSON(w, map[string]any{"files": files}) } func MobilePickFolder(w http.ResponseWriter, r *http.Request) { dir := PickFolder("Select folder", "") if dir == "" { writeJSON(w, map[string]any{"files": []any{}}) return } paths := collectHTMLFiles(dir) files := make([]map[string]string, 0, len(paths)) for _, p := range paths { files = append(files, map[string]string{"path": p, "name": filepath.Base(p)}) } writeJSON(w, map[string]any{"files": files}) } func MobileStart(w http.ResponseWriter, r *http.Request) { stopActiveShare() var req struct { Path string `json:"path"` Paths []string `json:"paths"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSON(w, map[string]any{"error": err.Error()}) return } paths := req.Paths if len(paths) == 0 && req.Path != "" { paths = []string{req.Path} } if len(paths) == 0 { writeJSON(w, map[string]any{"error": "File missing."}) return } files := make([]sharedFile, 0, len(paths)) for _, p := range paths { if p == "" || !fileExists(p) { writeJSON(w, map[string]any{"error": "File missing."}) return } fileBuf, err := os.ReadFile(p) if err != nil { writeJSON(w, map[string]any{"error": "Read failed: " + err.Error()}) return } files = append(files, sharedFile{buf: fileBuf, filename: filepath.Base(p)}) } // 6 random bytes -> base64url, no padding tokenBytes := make([]byte, 6) if _, err := rand.Read(tokenBytes); err != nil { writeJSON(w, map[string]any{"error": "Token gen failed: " + err.Error()}) return } token := strings.TrimRight(base64.URLEncoding.EncodeToString(tokenBytes), "=") cfgPort := LoadConfig().SendToMobile.Port listener, err := net.Listen("tcp", "0.0.0.0:"+strconv.Itoa(cfgPort)) if err != nil { writeJSON(w, map[string]any{"error": "Failed to bind: " + err.Error()}) return } port := listener.Addr().(*net.TCPAddr).Port share := &activeShare{ listener: listener, port: port, token: token, files: files, } share.server = &http.Server{Handler: shareHandler(share)} go func() { _ = share.server.Serve(listener) }() ips := getLanIPs() if len(ips) == 0 { _ = share.server.Close() writeJSON(w, map[string]any{"error": "No non-loopback IPv4 interface found. Are you on a network?"}) return } activeMu.Lock() active = share activeMu.Unlock() type urlInfo struct { IP string `json:"ip"` Iface string `json:"iface"` URL string `json:"url"` } urls := make([]urlInfo, 0, len(ips)) for _, ip := range ips { urls = append(urls, urlInfo{ IP: ip.address, Iface: ip.iface, URL: fmt.Sprintf("http://%s:%d/s/%s/", ip.address, port, token), }) } filename := fmt.Sprintf("%d files", len(files)) if len(files) == 1 { filename = files[0].filename } writeJSON(w, map[string]any{"urls": urls, "filename": filename}) } func MobileStop(w http.ResponseWriter, r *http.Request) { stopActiveShare() writeJSON(w, map[string]any{"ok": true}) } func shareHandler(s *activeShare) http.Handler { prefix := "/s/" + s.token return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet || !strings.HasPrefix(r.URL.Path, prefix) { http.NotFound(w, r) return } rest := strings.TrimPrefix(r.URL.Path, prefix) switch rest { case "", "/": w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _, _ = w.Write([]byte(chooserPage(s.files))) case "/view": fallthrough case "/view/0": if len(s.files) == 0 { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(s.files[0].buf) case "/file": fallthrough case "/file/0": if len(s.files) == 0 { http.NotFound(w, r) return } safeName := safeFilename(s.files[0].filename) w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`) w.Header().Set("Content-Length", strconv.Itoa(len(s.files[0].buf))) w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(s.files[0].buf) default: if strings.HasPrefix(rest, "/view/") || strings.HasPrefix(rest, "/file/") { parts := strings.Split(strings.Trim(rest, "/"), "/") if len(parts) == 2 { idx, err := strconv.Atoi(parts[1]) if err == nil && idx >= 0 && idx < len(s.files) { file := s.files[idx] if parts[0] == "view" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(file.buf) return } safeName := safeFilename(file.filename) w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Disposition", `attachment; filename="`+safeName+`"`) w.Header().Set("Content-Length", strconv.Itoa(len(file.buf))) w.Header().Set("Cache-Control", "no-store") _, _ = w.Write(file.buf) return } } } http.NotFound(w, r) } }) } var unsafeFilenameRx = regexp.MustCompile(`[^A-Za-z0-9._-]`) func safeFilename(s string) string { return unsafeFilenameRx.ReplaceAllString(s, "_") } func chooserPage(files []sharedFile) string { var items strings.Builder for i, file := range files { escName := html.EscapeString(file.filename) items.WriteString(`
` + escName + `
View in browser Download .html
`) } return ` Send To Mobile

HPL Toolbox

` + items.String() + ` ` } type lanIP struct { address string iface string } func getLanIPs() []lanIP { var out []lanIP ifaces, err := net.Interfaces() if err != nil { return out } for _, iface := range ifaces { if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { continue } addrs, err := iface.Addrs() if err != nil { continue } for _, a := range addrs { ipNet, ok := a.(*net.IPNet) if !ok { continue } ip4 := ipNet.IP.To4() if ip4 == nil || ip4.IsLoopback() { continue } out = append(out, lanIP{address: ip4.String(), iface: iface.Name}) } } return out }