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 := `
First run on Windows may show a firewall prompt - allow access for Private networks.
`
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()
send := newJSONStream(w)
var req struct {
Path string `json:"path"`
Paths []string `json:"paths"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
send(map[string]any{"type": "error", "message": err.Error()})
return
}
paths := req.Paths
if len(paths) == 0 && req.Path != "" {
paths = []string{req.Path}
}
if len(paths) == 0 {
send(map[string]any{"type": "error", "message": "File missing."})
return
}
files := make([]sharedFile, 0, len(paths))
for i, p := range paths {
if p == "" || !fileExists(p) {
send(map[string]any{"type": "error", "message": "File missing."})
return
}
send(map[string]any{"type": "status", "message": fmt.Sprintf("Loading %d/%d %s", i+1, len(paths), filepath.Base(p))})
fileBuf, err := os.ReadFile(p)
if err != nil {
send(map[string]any{"type": "error", "message": "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 {
send(map[string]any{"type": "error", "message": "Token gen failed: " + err.Error()})
return
}
token := strings.TrimRight(base64.URLEncoding.EncodeToString(tokenBytes), "=")
cfgPort := LoadConfig().SendToMobile.Port
send(map[string]any{"type": "status", "message": "Starting server..."})
listener, err := net.Listen("tcp", "0.0.0.0:"+strconv.Itoa(cfgPort))
if err != nil {
send(map[string]any{"type": "error", "message": "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()
send(map[string]any{"type": "error", "message": "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
}
send(map[string]any{"type": "done", "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(``)
}
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
}