Add files via upload
This commit is contained in:
35 files changed
+14482
No files matched your search
@@ -0,0 +1,701 @@
|
||||
#include "audio_upload_server.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <new>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#ifdef __SWITCH__
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/time.h>
|
||||
#include <switch.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
#ifdef __SWITCH__
|
||||
constexpr std::uint16_t kUploadPort = 8766;
|
||||
constexpr std::size_t kMaxHeaderBytes = 16 * 1024;
|
||||
constexpr std::size_t kMaxBgmUploadBytes = 32 * 1024 * 1024;
|
||||
constexpr std::size_t kMaxSfxUploadBytes = 3 * 1024 * 1024;
|
||||
|
||||
struct ServerState {
|
||||
Thread thread{};
|
||||
std::atomic<bool> running{false};
|
||||
std::atomic<bool> connected{false};
|
||||
std::atomic<int> pendingTrack{-1};
|
||||
std::atomic<int> uploadOutcome{0}; // 0 idle, 1 processing, 2 imported, 3 rejected
|
||||
std::atomic<int> activeClient{-1};
|
||||
bool photoTransfer = false;
|
||||
int listenFd = -1;
|
||||
std::string bindAddress;
|
||||
std::string uploadDirectory;
|
||||
std::string assetsRoot;
|
||||
std::string noteSingular = "Switchnote";
|
||||
std::string sessionCode;
|
||||
std::string sessionToken;
|
||||
std::uint32_t pairedAddress = 0;
|
||||
unsigned int failedCodeAttempts = 0;
|
||||
};
|
||||
|
||||
constexpr unsigned int kMaxCodeAttempts = 8;
|
||||
|
||||
bool validPairCode(const std::string& code) {
|
||||
if (code.size() != 7 || code[3] != '-') return false;
|
||||
for (std::size_t i = 0; i < code.size(); ++i)
|
||||
if (i != 3 && (code[i] < '0' || code[i] > '9')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string hexToken(const std::array<std::uint8_t, 32>& bytes) {
|
||||
constexpr char digits[] = "0123456789abcdef";
|
||||
std::string token;
|
||||
token.reserve(bytes.size() * 2);
|
||||
for (const std::uint8_t byte : bytes) {
|
||||
token.push_back(digits[byte >> 4]);
|
||||
token.push_back(digits[byte & 15]);
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
std::string trim(std::string value) {
|
||||
while (!value.empty() && (value.back() == '\r' || value.back() == '\n' ||
|
||||
value.back() == ' ' || value.back() == '\t')) value.pop_back();
|
||||
std::size_t first = 0;
|
||||
while (first < value.size() && (value[first] == ' ' || value[first] == '\t')) ++first;
|
||||
return value.substr(first);
|
||||
}
|
||||
|
||||
std::string lower(std::string value) {
|
||||
for (char& c : value) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
|
||||
return value;
|
||||
}
|
||||
|
||||
bool constantTimeEqual(const std::string& a, const std::string& b) {
|
||||
const std::size_t length = std::max(a.size(), b.size());
|
||||
unsigned int difference = static_cast<unsigned int>(a.size() ^ b.size());
|
||||
for (std::size_t i = 0; i < length; ++i) {
|
||||
const unsigned char av = i < a.size() ? static_cast<unsigned char>(a[i]) : 0;
|
||||
const unsigned char bv = i < b.size() ? static_cast<unsigned char>(b[i]) : 0;
|
||||
difference |= static_cast<unsigned int>(av ^ bv);
|
||||
}
|
||||
return difference == 0;
|
||||
}
|
||||
|
||||
bool sendAll(int fd, const void* bytes, std::size_t length) {
|
||||
const auto* data = static_cast<const std::uint8_t*>(bytes);
|
||||
std::size_t sent = 0;
|
||||
while (sent < length) {
|
||||
const ssize_t count = ::send(fd, data + sent, length - sent, 0);
|
||||
if (count <= 0) return false;
|
||||
sent += static_cast<std::size_t>(count);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void respond(int fd, int status, const char* reason, const std::string& type,
|
||||
const void* body, std::size_t length, bool html = false) {
|
||||
std::ostringstream headers;
|
||||
headers << "HTTP/1.1 " << status << ' ' << reason << "\r\n"
|
||||
<< "Content-Type: " << type << "\r\n"
|
||||
<< "Content-Length: " << length << "\r\n"
|
||||
<< "Connection: close\r\n"
|
||||
<< "Cache-Control: no-store\r\n"
|
||||
<< "X-Content-Type-Options: nosniff\r\n"
|
||||
<< "Referrer-Policy: no-referrer\r\n"
|
||||
<< "X-Frame-Options: DENY\r\n";
|
||||
if (html) headers << "Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'self' blob:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'self'\r\n";
|
||||
headers << "\r\n";
|
||||
const std::string header = headers.str();
|
||||
sendAll(fd, header.data(), header.size());
|
||||
if (length) sendAll(fd, body, length);
|
||||
}
|
||||
|
||||
void respondText(int fd, int status, const char* reason, const char* body) {
|
||||
respond(fd, status, reason, "text/plain; charset=utf-8", body, std::strlen(body));
|
||||
}
|
||||
|
||||
std::string htmlEscape(const std::string& value) {
|
||||
std::string escaped;
|
||||
escaped.reserve(value.size());
|
||||
for (const char c : value) {
|
||||
switch (c) {
|
||||
case '&': escaped += "&"; break;
|
||||
case '<': escaped += "<"; break;
|
||||
case '>': escaped += ">"; break;
|
||||
case '\"': escaped += """; break;
|
||||
case '\'': escaped += "'"; break;
|
||||
default: escaped.push_back(c); break;
|
||||
}
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
std::string jsEscape(const std::string& value) {
|
||||
std::string escaped;
|
||||
escaped.reserve(value.size());
|
||||
for (const char c : value) {
|
||||
if (c == '\\' || c == '\'') escaped.push_back('\\');
|
||||
if (c == '\n' || c == '\r') continue;
|
||||
escaped.push_back(c);
|
||||
}
|
||||
return escaped;
|
||||
}
|
||||
|
||||
std::string brandedPage(std::string page, const std::string& noteSingular) {
|
||||
const std::string name = noteSingular.empty() ? "Switchnote" : noteSingular;
|
||||
auto replaceAll = [&](const std::string& from, const std::string& to) {
|
||||
std::size_t at = 0;
|
||||
while ((at = page.find(from, at)) != std::string::npos) {
|
||||
page.replace(at, from.size(), to);
|
||||
at += to.size();
|
||||
}
|
||||
};
|
||||
replaceAll("' </style></head>", "</style></head>");
|
||||
replaceAll("'Adding audio to the {{NOTE}}…'", "'Adding audio to the {{NOTE_JS}}…'");
|
||||
replaceAll("'Audio added to the {{NOTE}}.'", "'Audio added to the {{NOTE_JS}}.'");
|
||||
replaceAll("Photo added to the Switchnote.", "Photo added to the {{NOTE_JS}}.");
|
||||
replaceAll("{{NOTE}}", htmlEscape(name));
|
||||
replaceAll("{{NOTE_JS}}", jsEscape(name));
|
||||
return page;
|
||||
}
|
||||
|
||||
std::string uploadPage(const std::string& noteSingular) {
|
||||
return brandedPage(R"HTML(<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Audio upload</title>
|
||||
<style>
|
||||
@font-face{font-family:Seurat;src:url('/assets/font') format('opentype')}*{box-sizing:border-box}body{margin:0;min-height:100vh;color:#252831;font-family:Seurat,'Trebuchet MS',sans-serif;background-color:#fff;background-image:linear-gradient(#d9e2ef 1px,transparent 1px),linear-gradient(90deg,#d9e2ef 1px,transparent 1px);background-size:24px 24px}.shell{max-width:920px;margin:5vh auto;padding:22px}.card{background:#fff;border:2px solid #e2e6f2;border-radius:18px;box-shadow:0 5px 0 #d5d8df,0 8px 20px #18243b22;padding:clamp(20px,4vw,34px)}p{line-height:1.5;color:#6c7280;margin-top:0}label{display:block;font-weight:700;margin:18px 0 7px}input,select{width:100%;font:inherit;padding:13px 14px;border:2px solid #d8dce5;border-radius:12px;background:#fff;color:#252831}input:focus,select:focus{outline:3px solid #ffb77e;border-color:#ff751e}.row{display:grid;grid-template-columns:1fr 1fr;gap:16px}.button{margin-top:22px;width:100%;padding:14px 18px;border:3px solid #fff;border-radius:28px;background:#ffd3a6;color:#ee6818;font:inherit;font-weight:700;box-shadow:0 4px 0 #cfd2d8,0 0 0 2px #e5e7ec;cursor:pointer;transition:transform .12s,box-shadow .12s}.button:hover{transform:translateY(-2px)}.button:active{transform:translateY(2px);box-shadow:0 1px 0 #cfd2d8}.button:disabled{opacity:.5;cursor:not-allowed}.status{min-height:28px;margin:15px 0 0;padding:10px 12px;border-radius:10px;background:#f1f3f8;color:#555d6b}.status.ok{background:#effbdd;color:#4e8d2c}.status.error{background:#fff0ed;color:#ba342d}.connected{margin:0 0 20px;padding:15px 18px;border-radius:13px;background:#effbdd;color:#4e8d2c;font-size:20px}.footer{margin:20px 4px;color:#697183;font-size:13px}[hidden]{display:none!important}@media(max-width:620px){.shell{padding:14px}.row{grid-template-columns:1fr}.card{padding:20px}}
|
||||
</style></head><body><main class="shell">
|
||||
<section class="card"><div id="pair"><p>Enter the code shown in Sound on your Nintendo Switch.</p>
|
||||
<label for="code">Connection code</label><input id="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" maxlength="7" placeholder="000-000"><div id="pairStatus" class="status" role="status">Enter all six digits to connect.</div></div>
|
||||
<div id="connected" class="connected" role="status" hidden>Connected to Nintendo Switch</div>
|
||||
<div id="uploadPanel" hidden><p>Choose a track and an audio file. BGM is trimmed to 60 seconds; each sound effect is trimmed to 2 seconds.</p>
|
||||
<div class="row"><div><label for="track">Track</label><select id="track"><option value="0">BGM · Music</option><option value="1">SE1 · Sound effect 1</option><option value="2">SE2 · Sound effect 2</option><option value="3">SE3 · Sound effect 3</option></select></div><div><label for="file">Audio file</label><input id="file" type="file" accept="audio/*,.wav,.mp3,.ogg,.m4a,.flac"></div></div>
|
||||
<button class="button" id="send">Upload to Switch</button><div id="status" class="status" role="status">Ready to upload.</div></div></section>
|
||||
<div class="footer">Ver 0.1.51</div></main>
|
||||
<script>
|
||||
'use strict';
|
||||
const $ = id => document.getElementById(id);
|
||||
let sessionToken = '', connecting = false;
|
||||
function message(value, kind = '') {
|
||||
const target = sessionToken ? $('status') : $('pairStatus');
|
||||
target.className = 'status' + (kind ? ' ' + kind : ''); target.textContent = value;
|
||||
}
|
||||
|
||||
async function connect(code) {
|
||||
if (connecting || sessionToken) return;
|
||||
connecting = true; message('Connecting…');
|
||||
try {
|
||||
const response = await fetch('/api/connect', {method: 'POST', headers: {'Authorization': 'Bearer ' + code}});
|
||||
const reply = (await response.text()).trim();
|
||||
if (!response.ok) throw new Error(reply || 'Could not connect to the Switch.');
|
||||
if (!/^[a-f0-9]{64}$/.test(reply)) throw new Error('The Switch sent an invalid connection response.');
|
||||
if ($('code').value !== code) return;
|
||||
sessionToken = reply;
|
||||
$('pair').hidden = true; $('connected').hidden = false; $('uploadPanel').hidden = false;
|
||||
} catch (error) { message(error.message || 'Could not connect to the Switch.', 'error'); }
|
||||
finally { connecting = false; }
|
||||
}
|
||||
$('code').addEventListener('input', () => {
|
||||
const digits = $('code').value.replace(/\D/g, '').slice(0, 6);
|
||||
$('code').value = digits.length > 3 ? digits.slice(0, 3) + '-' + digits.slice(3) : digits;
|
||||
if (digits.length === 6) connect($('code').value);
|
||||
});
|
||||
async function wavFromFile(file, maxSeconds) {
|
||||
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContextClass) throw new Error('This browser cannot convert audio. Try a WAV file in a newer browser.');
|
||||
const context = new AudioContextClass({sampleRate: 48000});
|
||||
try {
|
||||
const audio = await context.decodeAudioData(await file.arrayBuffer());
|
||||
if (!audio.length) throw new Error('This audio file is empty.');
|
||||
const frames = Math.min(audio.length, Math.floor(maxSeconds * audio.sampleRate));
|
||||
const data = new ArrayBuffer(44 + frames * 4), view = new DataView(data);
|
||||
const write = (at, text) => { for (let i = 0; i < text.length; i++) view.setUint8(at + i, text.charCodeAt(i)); };
|
||||
write(0, 'RIFF'); view.setUint32(4, data.byteLength - 8, true); write(8, 'WAVE');
|
||||
write(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 2, true); view.setUint32(24, audio.sampleRate, true);
|
||||
view.setUint32(28, audio.sampleRate * 4, true); view.setUint16(32, 4, true);
|
||||
view.setUint16(34, 16, true); write(36, 'data'); view.setUint32(40, frames * 4, true);
|
||||
const left = audio.getChannelData(0), right = audio.getChannelData(Math.min(1, audio.numberOfChannels - 1));
|
||||
for (let i = 0; i < frames; i++) {
|
||||
view.setInt16(44 + i * 4, Math.round(Math.max(-1, Math.min(1, left[i])) * 32767), true);
|
||||
view.setInt16(46 + i * 4, Math.round(Math.max(-1, Math.min(1, right[i])) * 32767), true);
|
||||
}
|
||||
return {blob: new Blob([data], {type: 'audio/wav'}), trimmed: frames < audio.length};
|
||||
} finally { await context.close(); }
|
||||
}
|
||||
$('send').addEventListener('click', async () => {
|
||||
const file = $('file').files[0], track = $('track').value;
|
||||
if (!sessionToken) { message('Connect to the Switch first.', 'error'); return; }
|
||||
if (!file) { message('Choose an audio file first.', 'error'); return; }
|
||||
if (file.size > 128 * 1024 * 1024) { message('This source file is too large to prepare in the browser.', 'error'); return; }
|
||||
$('send').disabled = true;
|
||||
try {
|
||||
message('Preparing ' + file.name + '…');
|
||||
const prepared = await wavFromFile(file, track === '0' ? 60 : 2), wav = prepared.blob;
|
||||
if (wav.size > (track === '0' ? 32 : 3) * 1024 * 1024)
|
||||
throw new Error('The converted audio is too large for this track.');
|
||||
message('Sending ' + file.name + '…');
|
||||
const headers = {'Authorization': 'Bearer ' + sessionToken};
|
||||
const response = await fetch('/api/upload', {method: 'POST', headers: {...headers, 'Content-Type': 'audio/wav', 'X-Audio-Track': track}, body: wav});
|
||||
const reply = await response.text();
|
||||
if (!response.ok) throw new Error(reply || 'Upload failed.');
|
||||
message('Adding audio to the {{NOTE}}…');
|
||||
let outcome = 'processing';
|
||||
for (let i = 0; i < 60 && outcome === 'processing'; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
const result = await fetch('/api/status', {headers});
|
||||
if (!result.ok) throw new Error('The Sound menu was closed before import finished.');
|
||||
outcome = (await result.text()).trim();
|
||||
}
|
||||
if (outcome !== 'imported') throw new Error(outcome === 'processing' ? 'The Switch is still processing this audio. Check the Sound menu.' : 'The Switch could not add this audio. Check its duration.');
|
||||
message(prepared.trimmed ? 'Audio added and trimmed to ' + (track === '0' ? '60' : '2') + ' seconds.' : 'Audio added to the {{NOTE}}.', 'ok'); $('file').value = '';
|
||||
} catch (error) { message(error.message || 'Upload failed. Keep this page open and try again.', 'error'); }
|
||||
finally { $('send').disabled = false; }
|
||||
});
|
||||
</script></body></html>)HTML", noteSingular);
|
||||
}
|
||||
|
||||
std::string photoPage(const std::string& noteSingular) {
|
||||
return brandedPage(R"HTML(<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Photo import</title><style>
|
||||
@font-face{font-family:Seurat;src:url('/assets/font') format('opentype')}*{box-sizing:border-box}body{margin:0;min-height:100vh;color:#252831;font-family:Seurat,'Trebuchet MS',sans-serif;background:#fff;background-image:linear-gradient(#d9e2ef 1px,transparent 1px),linear-gradient(90deg,#d9e2ef 1px,transparent 1px);background-size:24px 24px}.shell{max-width:1000px;margin:3vh auto;padding:18px}.card{background:#fff;border:2px solid #e2e6f2;border-radius:18px;box-shadow:0 5px 0 #d5d8df,0 8px 20px #18243b22;padding:clamp(18px,3vw,30px)}h1{margin:0 0 8px;font-size:30px}p{line-height:1.45;color:#6c7280;margin-top:0}.connected{padding:12px 15px;border-radius:13px;background:#effbdd;color:#4e8d2c;font-size:18px}label{display:block;margin:13px 0 6px}input,select{width:100%;padding:10px 12px;border:2px solid #d8dce5;border-radius:10px;background:white;color:#252831;font:inherit}.grid{display:grid;grid-template-columns:minmax(220px,1fr) minmax(300px,1.25fr);gap:20px}.opts{display:grid;grid-template-columns:1fr 1fr;gap:10px}.preview{width:100%;aspect-ratio:5/3;background-color:#e8ecf4;background-image:linear-gradient(45deg,#cad0dc 25%,transparent 25%),linear-gradient(-45deg,#cad0dc 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#cad0dc 75%),linear-gradient(-45deg,transparent 75%,#cad0dc 75%);background-size:20px 20px;background-position:0 0,0 10px,10px -10px,-10px 0;border:2px solid #d8dce5;border-radius:12px;image-rendering:pixelated}.button{margin-top:16px;width:100%;padding:13px;border:3px solid white;border-radius:28px;background:#ffd3a6;color:#ee6818;font:inherit;box-shadow:0 4px 0 #cfd2d8,0 0 0 2px #e5e7ec;cursor:pointer}.button:disabled{opacity:.5}.status{margin-top:12px;padding:10px;border-radius:9px;background:#f1f3f8;color:#555d6b}.status.error{background:#fff0ed;color:#ba342d}[hidden]{display:none!important}@media(max-width:680px){.grid{grid-template-columns:1fr}.opts{grid-template-columns:1fr 1fr}}
|
||||
' </style></head><body><main class="shell"><section class="card"><h1>Photo Import</h1><div id="pair"><p>Connect to your {{NOTE}} over local Wi-Fi using the code shown on screen.</p><label>Connection code</label><input id="code" inputmode="numeric" maxlength="7" placeholder="000-000"><div id="pairStatus" class="status">Enter all six digits to connect.</div></div><div id="panel" hidden><div class="connected">Connected to Nintendo Switch</div><p>Choose an image and adjust its canvas placement before sending it to the current {{NOTE}}.</p><div class="grid"><div><label>Image</label><input id="file" type="file" accept="image/png,image/jpeg,image/webp"><div class="opts"><div><label>Color mode</label><select id="mode"><option value="color">Full color</option><option value="ink">Single ink</option></select></div><div><label>Dithering</label><select id="dither"><option value="none">None</option><option value="ordered">Ordered</option></select></div><div><label>Ink color</label><input id="ink" type="color" value="#171717"></div><div><label>Layer</label><select id="layer"><option value="0">A · Back</option><option value="1">B</option><option value="2">C</option><option value="3">D · Front</option></select></div><div><label>Size</label><input id="size" type="range" min="25" max="200" value="100"><small id="sizeLabel">100%</small></div><div><label>Position X</label><input id="px" type="range" min="-300" max="300" value="0"></div><div><label>Position Y</label><input id="py" type="range" min="-180" max="180" value="0"></div></div></div><div><canvas id="preview" class="preview" width="600" height="360"></canvas><button class="button" id="send" disabled>Upload to {{NOTE}}</button><div id="status" class="status">Choose an image to preview.</div></div></div></section></main><script>
|
||||
'use strict';const $=id=>document.getElementById(id);let token='',busy=false,img=null;const canvas=$('preview'),ctx=canvas.getContext('2d',{willReadFrequently:true});function say(v,e=false){$('status').textContent=v;$('status').className='status'+(e?' error':'')}async function pair(code){if(busy||token)return;busy=true;$('pairStatus').textContent='Connecting…';try{const r=await fetch('/api/connect',{method:'POST',headers:{Authorization:'Bearer '+code}}),t=(await r.text()).trim();if(!r.ok)throw Error(t||'Could not connect.');if(!/^[a-f0-9]{64}$/.test(t))throw Error('Invalid Switch response.');token=t;$('pair').hidden=true;$('panel').hidden=false}catch(e){$('pairStatus').textContent=e.message;$('pairStatus').className='status error'}finally{busy=false}}$('code').addEventListener('input',()=>{const d=$('code').value.replace(/\D/g,'').slice(0,6);$('code').value=d.length>3?d.slice(0,3)+'-'+d.slice(3):d;if(d.length===6)pair($('code').value)});function render(){if(!img)return;ctx.clearRect(0,0,600,360);const s=Number($('size').value)/100,w=img.width*s,h=img.height*s,x=(600-w)/2+Number($('px').value),y=(360-h)/2+Number($('py').value);ctx.drawImage(img,x,y,w,h);const im=ctx.getImageData(0,0,600,360),p=im.data,ink=$('ink').value.match(/[a-f\d]{2}/gi).map(v=>parseInt(v,16)),mono=$('mode').value==='ink',ordered=$('dither').value==='ordered',bayer=[0,8,2,10,12,4,14,6,3,11,1,9,15,7,13,5];for(let yy=0;yy<360;yy++)for(let xx=0;xx<600;xx++){const i=(yy*600+xx)*4;if(p[i+3]<24){p[i+3]=0;continue}if(mono){let l=(p[i]*299+p[i+1]*587+p[i+2]*114)/1000;if(ordered)l+=(bayer[(yy&3)*4+(xx&3)]-7.5)*12;const v=l<128?0:255;p[i]=v?255:ink[0];p[i+1]=v?255:ink[1];p[i+2]=v?255:ink[2]}else if(ordered){const t=(bayer[(yy&3)*4+(xx&3)]-7.5)*3;p[i]=Math.max(0,Math.min(255,p[i]+t));p[i+1]=Math.max(0,Math.min(255,p[i+1]+t));p[i+2]=Math.max(0,Math.min(255,p[i+2]+t))}}ctx.putImageData(im,0,0);$('sizeLabel').textContent=$('size').value+'%';$('send').disabled=!token}$('file').addEventListener('change',()=>{const f=$('file').files[0];if(!f)return;if(f.size>16*1024*1024){say('Choose an image under 16 MB.',true);return}img=new Image();img.onload=()=>{render();say('Preview ready.');};img.onerror=()=>say('Could not read that image.',true);img.src=URL.createObjectURL(f)});['mode','dither','ink','size','px','py'].forEach(id=>$(id).addEventListener('input',render));$('send').addEventListener('click',async()=>{if(!token||!img||busy)return;busy=true;$('send').disabled=true;try{render();say('Preparing image…');const data=ctx.getImageData(0,0,600,360).data;const r=await fetch('/api/photo',{method:'POST',headers:{Authorization:'Bearer '+token,'Content-Type':'application/octet-stream','X-Photo-Layer':$('layer').value,'X-Photo-Mode':$('mode').value,'X-Photo-Dither':$('dither').value,'X-Photo-Size':$('size').value,'X-Photo-X':$('px').value,'X-Photo-Y':$('py').value},body:data});const t=await r.text();if(!r.ok)throw Error(t||'Upload failed.');say('Image sent. Importing to the selected layer…');let o='processing';for(let i=0;i<80&&o==='processing';i++){await new Promise(a=>setTimeout(a,250));const q=await fetch('/api/status',{headers:{Authorization:'Bearer '+token}});if(!q.ok)throw Error('Photo menu closed before import finished.');o=(await q.text()).trim()}if(o!=='imported')throw Error(o==='rejected'?'Could not import this image.':'Switch is still processing the image.');say('Photo added to the Switchnote.');}catch(e){say(e.message||'Upload failed.',true)}finally{busy=false;$('send').disabled=false}});
|
||||
</script></body></html>)HTML", noteSingular);
|
||||
}
|
||||
|
||||
bool readAsset(const std::string& path, std::vector<char>& result, const std::string& root) {
|
||||
std::string relative;
|
||||
if (path == "/assets/font") relative = "/fonts/FOT-Seurat.otf";
|
||||
else return false;
|
||||
std::ifstream file(root + relative, std::ios::binary | std::ios::ate);
|
||||
if (!file) return false;
|
||||
const std::streamoff length = file.tellg();
|
||||
if (length <= 0 || length > 4 * 1024 * 1024) return false;
|
||||
result.resize(static_cast<std::size_t>(length));
|
||||
file.seekg(0, std::ios::beg);
|
||||
return static_cast<bool>(file.read(result.data(), length));
|
||||
}
|
||||
|
||||
bool receiveHeaders(int fd, std::string& request, std::size_t& bodyStart) {
|
||||
char buffer[4096];
|
||||
while (true) {
|
||||
const std::size_t split = request.find("\r\n\r\n");
|
||||
if (split != std::string::npos) { bodyStart = split + 4; return true; }
|
||||
if (request.size() >= kMaxHeaderBytes) return false;
|
||||
const ssize_t count = ::recv(fd, buffer, sizeof(buffer), 0);
|
||||
if (count <= 0) return false;
|
||||
request.append(buffer, static_cast<std::size_t>(count));
|
||||
if (request.size() > kMaxHeaderBytes + 4096) return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool storeUpload(ServerState* state, int fd, int track, const std::string& request,
|
||||
std::size_t bodyStart, std::size_t bodySize) {
|
||||
const std::string base = state->uploadDirectory + "/audio-transfer-" + std::to_string(track);
|
||||
const std::string temporary = base + ".part";
|
||||
const std::string destination = base + ".wav";
|
||||
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
|
||||
if (!output) {
|
||||
respondText(fd, 500, "Internal Server Error", "Could not save the upload.");
|
||||
return false;
|
||||
}
|
||||
const std::size_t inBuffer = std::min(bodySize, request.size() - bodyStart);
|
||||
char wavHeader[12]{};
|
||||
std::size_t headerCount = 0;
|
||||
auto append = [&](const char* bytes, std::size_t count) {
|
||||
const std::size_t prefix = std::min(count, sizeof(wavHeader) - headerCount);
|
||||
if (prefix) { std::memcpy(wavHeader + headerCount, bytes, prefix); headerCount += prefix; }
|
||||
output.write(bytes, static_cast<std::streamsize>(count));
|
||||
};
|
||||
append(request.data() + bodyStart, inBuffer);
|
||||
std::size_t received = inBuffer;
|
||||
char buffer[8192];
|
||||
while (output && received < bodySize && state->running.load(std::memory_order_acquire)) {
|
||||
const std::size_t needed = std::min(sizeof(buffer), bodySize - received);
|
||||
const ssize_t count = ::recv(fd, buffer, needed, 0);
|
||||
if (count <= 0) break;
|
||||
append(buffer, static_cast<std::size_t>(count));
|
||||
received += static_cast<std::size_t>(count);
|
||||
}
|
||||
if (!output || received != bodySize) {
|
||||
output.close();
|
||||
std::remove(temporary.c_str());
|
||||
respondText(fd, 400, "Bad Request", "Upload was interrupted.");
|
||||
return false;
|
||||
}
|
||||
if (std::memcmp(wavHeader, "RIFF", 4) != 0 || std::memcmp(wavHeader + 8, "WAVE", 4) != 0) {
|
||||
output.close();
|
||||
std::remove(temporary.c_str());
|
||||
respondText(fd, 415, "Unsupported Media Type", "This file is not a valid WAV container.");
|
||||
return false;
|
||||
}
|
||||
output.flush();
|
||||
output.close();
|
||||
// This path is reserved for the transient PC transfer. A previous session
|
||||
// can leave it behind if the app was closed during import.
|
||||
std::remove(destination.c_str());
|
||||
if (!output || std::rename(temporary.c_str(), destination.c_str()) != 0) {
|
||||
std::remove(temporary.c_str());
|
||||
respondText(fd, 500, "Internal Server Error", "Could not finalize the upload.");
|
||||
return false;
|
||||
}
|
||||
state->uploadOutcome.store(1, std::memory_order_release);
|
||||
state->pendingTrack.store(track, std::memory_order_release);
|
||||
const char* success = "WAV received by Switch. Importing now.";
|
||||
respond(fd, 200, "OK", "text/plain; charset=utf-8", success, std::strlen(success));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool storePhotoUpload(ServerState* state, int fd, const std::string& request,
|
||||
std::size_t bodyStart, std::size_t bodySize,
|
||||
const std::map<std::string, std::string>& headers) {
|
||||
auto number = [&](const char* key, int low, int high, int& value) {
|
||||
const auto found = headers.find(key);
|
||||
if (found == headers.end() || found->second.empty()) return false;
|
||||
char* end = nullptr;
|
||||
const long parsed = std::strtol(found->second.c_str(), &end, 10);
|
||||
if (!end || *end || parsed < low || parsed > high) return false;
|
||||
value = static_cast<int>(parsed);
|
||||
return true;
|
||||
};
|
||||
int layer = 0, size = 0, offsetX = 0, offsetY = 0;
|
||||
if (headers.find("x-photo-mode") == headers.end() || headers.find("x-photo-dither") == headers.end() ||
|
||||
!number("x-photo-layer", 0, 3, layer) || !number("x-photo-size", 25, 200, size) ||
|
||||
!number("x-photo-x", -300, 300, offsetX) || !number("x-photo-y", -180, 180, offsetY) ||
|
||||
(headers.at("x-photo-mode") != "color" && headers.at("x-photo-mode") != "ink") ||
|
||||
(headers.at("x-photo-dither") != "none" && headers.at("x-photo-dither") != "ordered")) {
|
||||
respondText(fd, 400, "Bad Request", "Photo options are invalid.");
|
||||
return false;
|
||||
}
|
||||
constexpr std::size_t expectedBytes = 600U * 360U * 4U;
|
||||
if (bodySize != expectedBytes || request.size() < bodyStart || request.size() - bodyStart > bodySize) {
|
||||
respondText(fd, 400, "Bad Request", "Processed photo has an invalid canvas size.");
|
||||
return false;
|
||||
}
|
||||
const std::string path = state->uploadDirectory + "/photo-transfer.rgba";
|
||||
const std::string configPath = state->uploadDirectory + "/photo-transfer.cfg";
|
||||
std::ofstream output(path, std::ios::binary | std::ios::trunc);
|
||||
if (!output) { respondText(fd, 500, "Internal Server Error", "Could not open the photo transfer file."); return false; }
|
||||
const std::size_t buffered = std::min(bodySize, request.size() - bodyStart);
|
||||
output.write(request.data() + bodyStart, static_cast<std::streamsize>(buffered));
|
||||
std::size_t received = buffered;
|
||||
char buffer[8192];
|
||||
while (output && received < bodySize && state->running.load(std::memory_order_acquire)) {
|
||||
const ssize_t count = ::recv(fd, buffer, std::min(sizeof(buffer), bodySize - received), 0);
|
||||
if (count <= 0) break;
|
||||
output.write(buffer, count);
|
||||
received += static_cast<std::size_t>(count);
|
||||
}
|
||||
output.flush();
|
||||
output.close();
|
||||
if (!output || received != bodySize) { std::remove(path.c_str()); respondText(fd, 400, "Bad Request", "Photo upload was interrupted."); return false; }
|
||||
std::ofstream config(configPath, std::ios::binary | std::ios::trunc);
|
||||
config << layer << '\n' << headers.at("x-photo-mode") << '\n' << headers.at("x-photo-dither")
|
||||
<< '\n' << size << '\n' << offsetX << '\n' << offsetY << '\n';
|
||||
config.flush();
|
||||
if (!config) { config.close(); std::remove(path.c_str()); respondText(fd, 500, "Internal Server Error", "Could not save photo options."); return false; }
|
||||
state->uploadOutcome.store(1, std::memory_order_release);
|
||||
state->pendingTrack.store(4, std::memory_order_release);
|
||||
const char* success = "Photo received by Switch. Importing now.";
|
||||
respond(fd, 200, "OK", "text/plain; charset=utf-8", success, std::strlen(success));
|
||||
return true;
|
||||
}
|
||||
|
||||
void serveClient(ServerState* state, int fd, std::uint32_t clientAddress) {
|
||||
timeval timeout{};
|
||||
timeout.tv_sec = 4;
|
||||
::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
|
||||
::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
|
||||
std::string request;
|
||||
std::size_t bodyStart = 0;
|
||||
if (!receiveHeaders(fd, request, bodyStart)) {
|
||||
respondText(fd, 400, "Bad Request", "Invalid HTTP request.");
|
||||
return;
|
||||
}
|
||||
const std::size_t lineEnd = request.find("\r\n");
|
||||
if (lineEnd == std::string::npos) return;
|
||||
std::istringstream firstLine(request.substr(0, lineEnd));
|
||||
std::string method, path, version;
|
||||
firstLine >> method >> path >> version;
|
||||
if (version != "HTTP/1.1" && version != "HTTP/1.0") {
|
||||
respondText(fd, 400, "Bad Request", "Invalid HTTP version.");
|
||||
return;
|
||||
}
|
||||
std::map<std::string, std::string> headers;
|
||||
std::size_t cursor = lineEnd + 2;
|
||||
while (cursor < bodyStart - 2) {
|
||||
const std::size_t end = request.find("\r\n", cursor);
|
||||
if (end == std::string::npos || end == cursor) break;
|
||||
const std::size_t colon = request.find(':', cursor);
|
||||
if (colon == std::string::npos || colon >= end) {
|
||||
respondText(fd, 400, "Bad Request", "Malformed request header.");
|
||||
return;
|
||||
}
|
||||
headers[lower(trim(request.substr(cursor, colon - cursor)))] = trim(request.substr(colon + 1, end - colon - 1));
|
||||
cursor = end + 2;
|
||||
}
|
||||
|
||||
if (method == "GET" && path == "/") {
|
||||
const std::string page = state->photoTransfer ? photoPage(state->noteSingular) : uploadPage(state->noteSingular);
|
||||
respond(fd, 200, "OK", "text/html; charset=utf-8", page.data(), page.size(), true);
|
||||
return;
|
||||
}
|
||||
if (method == "GET" && path == "/assets/font") {
|
||||
std::vector<char> asset;
|
||||
if (!readAsset(path, asset, state->assetsRoot)) {
|
||||
respondText(fd, 404, "Not Found", "Asset not found.");
|
||||
return;
|
||||
}
|
||||
respond(fd, 200, "OK", "font/otf", asset.data(), asset.size());
|
||||
return;
|
||||
}
|
||||
const bool pairing = method == "POST" && path == "/api/connect";
|
||||
const bool photoUpload = state->photoTransfer && method == "POST" && path == "/api/photo";
|
||||
if (!pairing && !photoUpload && !((method == "POST" && path == "/api/upload") || (method == "GET" && path == "/api/status"))) {
|
||||
respondText(fd, 404, "Not Found", "Not found.");
|
||||
return;
|
||||
}
|
||||
const std::string auth = headers["authorization"];
|
||||
if (pairing) {
|
||||
if (state->connected.load(std::memory_order_acquire) && clientAddress != state->pairedAddress) {
|
||||
respondText(fd, 409, "Conflict", "Another PC is already connected. Reopen Sound to pair again.");
|
||||
return;
|
||||
}
|
||||
if (state->failedCodeAttempts >= kMaxCodeAttempts) {
|
||||
respondText(fd, 429, "Too Many Requests", "Too many incorrect codes. Reopen Sound for a new code.");
|
||||
return;
|
||||
}
|
||||
if (!constantTimeEqual(auth, "Bearer " + state->sessionCode)) {
|
||||
++state->failedCodeAttempts;
|
||||
respondText(fd, 403, "Forbidden", "Connection code is incorrect.");
|
||||
return;
|
||||
}
|
||||
state->pairedAddress = clientAddress;
|
||||
state->connected.store(true, std::memory_order_release);
|
||||
respond(fd, 200, "OK", "text/plain; charset=utf-8",
|
||||
state->sessionToken.data(), state->sessionToken.size());
|
||||
return;
|
||||
}
|
||||
if (!state->connected.load(std::memory_order_acquire) || clientAddress != state->pairedAddress ||
|
||||
!constantTimeEqual(auth, "Bearer " + state->sessionToken)) {
|
||||
respondText(fd, 403, "Forbidden", "Connect to the Switch first.");
|
||||
return;
|
||||
}
|
||||
if (method == "GET") {
|
||||
const int outcome = state->uploadOutcome.load(std::memory_order_acquire);
|
||||
respondText(fd, 200, "OK", outcome == 2 ? "imported" : outcome == 3 ? "rejected" : "processing");
|
||||
return;
|
||||
}
|
||||
if (state->pendingTrack.load(std::memory_order_acquire) >= 0) {
|
||||
respondText(fd, 409, "Conflict", "The previous upload is still being added on the Switch.");
|
||||
return;
|
||||
}
|
||||
if (headers.find("transfer-encoding") != headers.end()) {
|
||||
respondText(fd, 400, "Bad Request", "Chunked upload is not supported.");
|
||||
return;
|
||||
}
|
||||
char* end = nullptr;
|
||||
const std::string lengthHeader = headers["content-length"];
|
||||
const unsigned long long bodyLength = std::strtoull(lengthHeader.c_str(), &end, 10);
|
||||
if (photoUpload) {
|
||||
constexpr unsigned long long photoBytes = 600ULL * 360ULL * 4ULL;
|
||||
if (lengthHeader.empty() || !end || *end != '\0' || bodyLength != photoBytes ||
|
||||
lower(headers["content-type"]) != "application/octet-stream") {
|
||||
respondText(fd, 413, "Payload Too Large", "Send a processed 600 by 360 RGBA photo.");
|
||||
return;
|
||||
}
|
||||
storePhotoUpload(state, fd, request, bodyStart, static_cast<std::size_t>(bodyLength), headers);
|
||||
return;
|
||||
}
|
||||
if (lengthHeader.empty() || !end || *end != '\0' || bodyLength < 12 || bodyLength > kMaxBgmUploadBytes) {
|
||||
respondText(fd, 413, "Payload Too Large", "Choose a WAV under 32 MiB.");
|
||||
return;
|
||||
}
|
||||
const std::string trackHeader = headers["x-audio-track"];
|
||||
if (trackHeader.size() != 1 || trackHeader[0] < '0' || trackHeader[0] > '3') {
|
||||
respondText(fd, 400, "Bad Request", "Choose BGM or one of SE1-SE3.");
|
||||
return;
|
||||
}
|
||||
const int track = trackHeader[0] - '0';
|
||||
if (track > 0 && bodyLength > kMaxSfxUploadBytes) {
|
||||
respondText(fd, 413, "Payload Too Large", "A sound-effect WAV must be under 3 MiB.");
|
||||
return;
|
||||
}
|
||||
const std::string contentType = lower(headers["content-type"]);
|
||||
if (contentType != "audio/wav" && contentType != "audio/x-wav" && contentType != "application/octet-stream") {
|
||||
respondText(fd, 415, "Unsupported Media Type", "Select a WAV audio file.");
|
||||
return;
|
||||
}
|
||||
storeUpload(state, fd, track, request, bodyStart, static_cast<std::size_t>(bodyLength));
|
||||
}
|
||||
|
||||
void serverThread(void* argument) {
|
||||
auto* state = static_cast<ServerState*>(argument);
|
||||
while (state->running.load(std::memory_order_acquire)) {
|
||||
fd_set readers;
|
||||
FD_ZERO(&readers);
|
||||
FD_SET(state->listenFd, &readers);
|
||||
timeval wait{};
|
||||
wait.tv_sec = 0;
|
||||
wait.tv_usec = 100000;
|
||||
const int ready = ::select(state->listenFd + 1, &readers, nullptr, nullptr, &wait);
|
||||
if (ready <= 0) continue;
|
||||
if (!state->running.load(std::memory_order_acquire)) break;
|
||||
sockaddr_in peer{};
|
||||
socklen_t peerSize = sizeof(peer);
|
||||
const int client = ::accept(state->listenFd, reinterpret_cast<sockaddr*>(&peer), &peerSize);
|
||||
if (client < 0) continue;
|
||||
state->activeClient.store(client, std::memory_order_release);
|
||||
serveClient(state, client, peer.sin_addr.s_addr);
|
||||
::shutdown(client, SHUT_RDWR);
|
||||
::close(client);
|
||||
state->activeClient.store(-1, std::memory_order_release);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
AudioUploadServer::AudioUploadServer() = default;
|
||||
|
||||
AudioUploadServer::~AudioUploadServer() { stop(); }
|
||||
|
||||
bool AudioUploadServer::start(const std::string& bindAddress, const std::string& uploadDirectory,
|
||||
const std::string& assetsRoot, const std::string& sessionCode,
|
||||
std::string& error, bool photoTransfer,
|
||||
const std::string& noteSingular) {
|
||||
stop();
|
||||
#ifdef __SWITCH__
|
||||
if (!validPairCode(sessionCode) || uploadDirectory.empty()) {
|
||||
error = "Invalid upload server configuration.";
|
||||
return false;
|
||||
}
|
||||
auto* state = new (std::nothrow) ServerState{};
|
||||
if (!state) { error = "Not enough memory to open the sound server."; return false; }
|
||||
state->bindAddress = bindAddress;
|
||||
state->uploadDirectory = uploadDirectory;
|
||||
state->assetsRoot = assetsRoot;
|
||||
state->noteSingular = noteSingular;
|
||||
state->sessionCode = sessionCode;
|
||||
state->photoTransfer = photoTransfer;
|
||||
std::array<std::uint8_t, 32> tokenBytes{};
|
||||
if (R_FAILED(csrngGetRandomBytes(tokenBytes.data(), tokenBytes.size()))) {
|
||||
delete state;
|
||||
error = "Could not create a secure upload session.";
|
||||
return false;
|
||||
}
|
||||
state->sessionToken = hexToken(tokenBytes);
|
||||
state->listenFd = ::socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (state->listenFd < 0) { delete state; error = "Could not create the upload socket."; return false; }
|
||||
int reuse = 1;
|
||||
::setsockopt(state->listenFd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
|
||||
sockaddr_in address{};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_port = htons(kUploadPort);
|
||||
if (::inet_pton(AF_INET, bindAddress.c_str(), &address.sin_addr) != 1 ||
|
||||
::bind(state->listenFd, reinterpret_cast<sockaddr*>(&address), sizeof(address)) != 0 ||
|
||||
::listen(state->listenFd, 2) != 0) {
|
||||
::close(state->listenFd);
|
||||
delete state;
|
||||
error = "Could not listen on " + bindAddress + ":" + std::to_string(kUploadPort) + ".";
|
||||
return false;
|
||||
}
|
||||
state->running.store(true, std::memory_order_release);
|
||||
const Result created = threadCreate(&state->thread, serverThread, state, nullptr, 0x10000, 0x3B, -2);
|
||||
if (R_FAILED(created)) {
|
||||
state->running.store(false, std::memory_order_release);
|
||||
::close(state->listenFd);
|
||||
delete state;
|
||||
error = "Could not start the upload service thread.";
|
||||
return false;
|
||||
}
|
||||
if (R_FAILED(threadStart(&state->thread))) {
|
||||
state->running.store(false, std::memory_order_release);
|
||||
::close(state->listenFd);
|
||||
threadClose(&state->thread);
|
||||
delete state;
|
||||
error = "Could not start the upload service thread.";
|
||||
return false;
|
||||
}
|
||||
impl_ = state;
|
||||
error.clear();
|
||||
return true;
|
||||
#else
|
||||
(void)bindAddress; (void)uploadDirectory; (void)assetsRoot; (void)sessionCode; (void)photoTransfer; (void)noteSingular;
|
||||
error = "The Switch upload server is available in the homebrew app.";
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void AudioUploadServer::stop() {
|
||||
#ifdef __SWITCH__
|
||||
auto* state = static_cast<ServerState*>(impl_);
|
||||
if (!state) return;
|
||||
state->running.store(false, std::memory_order_release);
|
||||
const int client = state->activeClient.load(std::memory_order_acquire);
|
||||
if (client >= 0) ::shutdown(client, SHUT_RDWR);
|
||||
threadWaitForExit(&state->thread);
|
||||
if (state->listenFd >= 0) ::close(state->listenFd);
|
||||
threadClose(&state->thread);
|
||||
delete state;
|
||||
impl_ = nullptr;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool AudioUploadServer::isRunning() const {
|
||||
#ifdef __SWITCH__
|
||||
const auto* state = static_cast<const ServerState*>(impl_);
|
||||
return state && state->running.load(std::memory_order_acquire);
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool AudioUploadServer::isConnected() const {
|
||||
#ifdef __SWITCH__
|
||||
const auto* state = static_cast<const ServerState*>(impl_);
|
||||
return state && state->connected.load(std::memory_order_acquire);
|
||||
#else
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool AudioUploadServer::takeCompletedUpload(int& track) {
|
||||
#ifdef __SWITCH__
|
||||
auto* state = static_cast<ServerState*>(impl_);
|
||||
if (!state) return false;
|
||||
track = state->pendingTrack.load(std::memory_order_acquire);
|
||||
return track >= 0 && track <= 4;
|
||||
#else
|
||||
(void)track;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void AudioUploadServer::finishCompletedUpload(bool success) {
|
||||
#ifdef __SWITCH__
|
||||
auto* state = static_cast<ServerState*>(impl_);
|
||||
if (state) {
|
||||
state->uploadOutcome.store(success ? 2 : 3, std::memory_order_release);
|
||||
state->pendingTrack.store(-1, std::memory_order_release);
|
||||
}
|
||||
#else
|
||||
(void)success;
|
||||
#endif
|
||||
}
|
||||
Reference in new issue
Block a user
