Files
vsix-hpl-toolbox/standalone/mobile.go

530 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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
}