530 lines
17 KiB
Go
530 lines
17 KiB
Go
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 := `
|
||
<header class="tool-header">
|
||
<h2 class="tool-title">Send To Mobile</h2>
|
||
<p class="tool-description">Pick HTML files, then start sharing. Scan the QR with a phone on the same WiFi.</p>
|
||
</header>
|
||
|
||
<section class="tool-panel">
|
||
<div class="panel-header"><h3 class="panel-title">Inputs</h3></div>
|
||
<div class="panel-body">
|
||
<div class="control-group">
|
||
<div id="dropZone" class="drop-zone">
|
||
<div class="file-row">
|
||
<button id="pickFolder" class="secondary">Select Folder</button>
|
||
<button id="pick" class="secondary">Select File(s)</button>
|
||
<button id="clear" class="secondary">Clear</button>
|
||
</div>
|
||
<div class="drop-zone-text">or drop HTML files or folders here</div>
|
||
</div>
|
||
<div id="fileName" class="file-name" style="margin-top:8px;">(no files selected)</div>
|
||
<div id="fileList" class="selected-list"></div>
|
||
</div>
|
||
<div class="action-row">
|
||
<button id="start">Start</button>
|
||
<button id="stop" class="secondary" disabled>Stop</button>
|
||
</div>
|
||
<div id="status" class="status-panel"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<div id="ifaceWrap" class="control-group" style="display:none;margin-bottom:14px;">
|
||
<div class="control-label">Network Interfaces</div>
|
||
<div id="ifaceList"></div>
|
||
</div>
|
||
|
||
<div id="shareInfo" class="result-card" style="display:none;">
|
||
<div id="qr" style="background:#fff;padding:12px;display:inline-block;border-radius:4px;"></div>
|
||
<div class="muted" style="margin-top:8px;font-size:12px;">
|
||
First run on Windows may show a firewall prompt - allow access for Private networks.
|
||
</div>
|
||
</div>
|
||
|
||
<script src="/assets/qrcode.min.js"></script>
|
||
<script>
|
||
const PAGE_SIZE = 5;
|
||
const pickFolderBtn = document.getElementById('pickFolder');
|
||
const pickBtn = document.getElementById('pick');
|
||
const clearBtn = document.getElementById('clear');
|
||
const startBtn = document.getElementById('start');
|
||
const stopBtn = document.getElementById('stop');
|
||
const fileNameEl = document.getElementById('fileName');
|
||
const fileListEl = document.getElementById('fileList');
|
||
const statusEl = document.getElementById('status');
|
||
const shareInfo = document.getElementById('shareInfo');
|
||
const ifaceWrap = document.getElementById('ifaceWrap');
|
||
const ifaceList = document.getElementById('ifaceList');
|
||
const qrEl = document.getElementById('qr');
|
||
|
||
let currentUrls = [];
|
||
let selectedIdx = 0;
|
||
let selectedFiles = [];
|
||
let selectedPaths = [];
|
||
let selectedPage = 0;
|
||
|
||
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
||
function renderSelection() {
|
||
const oldPager = document.getElementById('selectedPager');
|
||
if (oldPager) oldPager.remove();
|
||
if (!selectedFiles.length) {
|
||
fileNameEl.textContent = '(no files selected)';
|
||
fileListEl.innerHTML = '';
|
||
return;
|
||
}
|
||
fileNameEl.textContent = selectedFiles.length + ' file' + (selectedFiles.length === 1 ? '' : 's');
|
||
const maxPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
|
||
selectedPage = Math.min(selectedPage, maxPage);
|
||
const start = selectedPage * PAGE_SIZE;
|
||
const visible = selectedFiles.slice(start, start + PAGE_SIZE);
|
||
fileListEl.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table">' +
|
||
'<thead><tr><th>Filename</th><th style="width:44px;"></th></tr></thead><tbody>' +
|
||
visible.map((file, offset) => {
|
||
const i = start + offset;
|
||
return '<tr><td class="mono wrap">' + escapeHtml(file.name) + '</td><td><button class="remove-selected danger" data-index="' + i + '" title="Remove">×</button></td></tr>';
|
||
}).join('') + '</tbody></table></div>';
|
||
fileListEl.querySelectorAll('.remove-selected').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const index = Number(btn.dataset.index);
|
||
selectedFiles.splice(index, 1);
|
||
selectedPaths.splice(index, 1);
|
||
renderSelection();
|
||
});
|
||
});
|
||
if (selectedFiles.length > PAGE_SIZE) {
|
||
const pager = document.createElement('div');
|
||
pager.id = 'selectedPager';
|
||
pager.className = 'pager';
|
||
pager.innerHTML = '<button class="secondary" id="prevPage"' + (selectedPage === 0 ? ' disabled' : '') + '>Previous</button>' +
|
||
'<span class="file-name">Page ' + (selectedPage + 1) + ' of ' + (maxPage + 1) + '</span>' +
|
||
'<button class="secondary" id="nextPage"' + (selectedPage === maxPage ? ' disabled' : '') + '>Next</button>';
|
||
fileListEl.appendChild(pager);
|
||
pager.querySelector('#prevPage').addEventListener('click', () => { selectedPage--; renderSelection(); });
|
||
pager.querySelector('#nextPage').addEventListener('click', () => { selectedPage++; renderSelection(); });
|
||
}
|
||
}
|
||
function addFiles(files) {
|
||
const existing = new Set(selectedFiles.map(f => f.path));
|
||
selectedFiles = selectedFiles.concat((files || []).filter(f => !existing.has(f.path)));
|
||
selectedPaths = selectedFiles.map(f => f.path);
|
||
selectedPage = Math.max(0, Math.ceil(selectedFiles.length / PAGE_SIZE) - 1);
|
||
renderSelection();
|
||
}
|
||
renderSelection();
|
||
setupDropZone('dropZone', statusEl, (j) => {
|
||
addFiles(j.files || []);
|
||
if (j.skipped) statusEl.textContent = 'Skipped ' + j.skipped + ' non-HTML or unavailable item(s).';
|
||
});
|
||
|
||
pickFolderBtn.addEventListener('click', async () => {
|
||
const r = await fetch('/api/mobile/pickFolder', { method:'POST' });
|
||
const j = await r.json();
|
||
addFiles(j.files || []);
|
||
});
|
||
pickBtn.addEventListener('click', async () => {
|
||
const r = await fetch('/api/mobile/pick', { method:'POST' });
|
||
const j = await r.json();
|
||
addFiles(j.files || []);
|
||
});
|
||
clearBtn.addEventListener('click', () => {
|
||
selectedFiles = [];
|
||
selectedPaths = [];
|
||
selectedPage = 0;
|
||
statusEl.textContent = '';
|
||
statusEl.classList.remove('is-busy');
|
||
renderSelection();
|
||
});
|
||
|
||
startBtn.addEventListener('click', async () => {
|
||
statusEl.innerHTML = '';
|
||
if (!selectedPaths.length) {
|
||
statusEl.innerHTML = '<span class="err">Pick at least one HTML file first.</span>';
|
||
return;
|
||
}
|
||
startBtn.disabled = true;
|
||
statusEl.textContent = 'Starting server...';
|
||
statusEl.classList.add('is-busy');
|
||
const r = await fetch('/api/mobile/start', {
|
||
method:'POST', headers:{'Content-Type':'application/json'},
|
||
body: JSON.stringify({ paths: selectedPaths }),
|
||
});
|
||
const j = await r.json();
|
||
if (j.error) {
|
||
statusEl.classList.remove('is-busy');
|
||
statusEl.innerHTML = '<span class="err">' + j.error + '</span>';
|
||
startBtn.disabled = false;
|
||
return;
|
||
}
|
||
currentUrls = j.urls;
|
||
statusEl.classList.remove('is-busy');
|
||
selectedIdx = 0;
|
||
statusEl.innerHTML = '<span class="ok">Sharing: ' + j.filename + '</span>';
|
||
shareInfo.style.display = '';
|
||
renderIfaces(j.urls);
|
||
renderQr(j.urls[0].url);
|
||
stopBtn.disabled = false;
|
||
});
|
||
|
||
stopBtn.addEventListener('click', async () => {
|
||
await fetch('/api/mobile/stop', { method:'POST' });
|
||
statusEl.classList.remove('is-busy');
|
||
statusEl.textContent = 'Stopped.';
|
||
shareInfo.style.display = 'none';
|
||
currentUrls = [];
|
||
startBtn.disabled = false;
|
||
stopBtn.disabled = true;
|
||
});
|
||
|
||
function renderQr(url) {
|
||
qrEl.innerHTML = '';
|
||
try {
|
||
const qr = qrcode(0, 'M');
|
||
qr.addData(url);
|
||
qr.make();
|
||
qrEl.innerHTML = qr.createSvgTag({ cellSize: 5, margin: 2, scalable: true });
|
||
const svg = qrEl.querySelector('svg');
|
||
if (svg) { svg.setAttribute('width','240'); svg.setAttribute('height','240'); }
|
||
} catch (e) {
|
||
qrEl.textContent = 'QR render failed: ' + e.message;
|
||
}
|
||
}
|
||
|
||
function renderIfaces(urls) {
|
||
ifaceList.innerHTML = '';
|
||
if (urls.length <= 1) { ifaceWrap.style.display = 'none'; return; }
|
||
ifaceWrap.style.display = '';
|
||
ifaceList.innerHTML = '<div class="results-panel" style="margin-top:0;"><table class="data-table"><thead><tr><th style="width:70px;">Use</th><th style="width:140px;">IP</th><th>Interface</th></tr></thead><tbody></tbody></table></div>';
|
||
const tbody = ifaceList.querySelector('tbody');
|
||
urls.forEach((u, i) => {
|
||
const id = 'iface_' + i;
|
||
const row = document.createElement('tr');
|
||
row.innerHTML = '<td style="text-align:center;"><input type="radio" name="iface" id="' + id + '"' + (i === selectedIdx ? ' checked' : '') + ' /></td>' +
|
||
'<td class="mono wrap">' + u.ip + '</td><td class="wrap">' + u.iface + '</td>';
|
||
row.querySelector('input').addEventListener('change', () => {
|
||
selectedIdx = i;
|
||
renderQr(urls[i].url);
|
||
});
|
||
tbody.appendChild(row);
|
||
});
|
||
}
|
||
</script>
|
||
`
|
||
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(`<div class="item"><div class="file">` + escName + `</div>
|
||
<a class="btn view" href="view/` + strconv.Itoa(i) + `">View in browser</a>
|
||
<a class="btn dl" href="file/` + strconv.Itoa(i) + `" download="` + escName + `">Download .html</a></div>`)
|
||
}
|
||
return `<!DOCTYPE html>
|
||
<html><head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>Send To Mobile</title>
|
||
<style>
|
||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
margin: 0; padding: 24px; background: #111; color: #eee; }
|
||
h1 { font-size: 18px; font-weight: 500; margin: 0 0 4px; opacity: 0.85; }
|
||
.item { margin-bottom: 26px; }
|
||
.file { font-size: 13px; opacity: 0.6; margin-bottom: 10px; word-break: break-all; }
|
||
a.btn { display: block; padding: 18px; margin-bottom: 14px; border-radius: 10px;
|
||
font-size: 17px; font-weight: 500; text-align: center; text-decoration: none; }
|
||
a.view { background: #2d7dff; color: white; }
|
||
a.dl { background: #2a2a2a; color: #eee; border: 1px solid #3a3a3a; }
|
||
</style></head>
|
||
<body>
|
||
<h1>HPL Toolbox</h1>
|
||
` + items.String() + `
|
||
</body></html>`
|
||
}
|
||
|
||
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
|
||
}
|