233 lines
32 KiB
C++
233 lines
32 KiB
C++
#include "branding_config_server.hpp"
|
|
#include "branding_bundle.hpp"
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <atomic>
|
|
#include <cctype>
|
|
#include <cstdio>
|
|
#include <cstdint>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <ctime>
|
|
#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 kPort = 8767;
|
|
constexpr std::size_t kMaxRequest = 8 * 1024 * 1024 + 16 * 1024;
|
|
std::string portalAppName = "Switchnote Studio";
|
|
std::string portalNoteSingular = "Switchnote";
|
|
std::string portalNotePlural = "Switchnotes";
|
|
struct State {
|
|
Thread thread{};
|
|
std::atomic<bool> running{false};
|
|
std::atomic<bool> connected{false};
|
|
std::atomic<bool> pending{false};
|
|
std::atomic<std::uint64_t> lastSeenSeconds{0};
|
|
std::atomic<int> activeClient{-1};
|
|
int listenFd = -1;
|
|
std::string bindAddress, dataDirectory, assetsRoot, code, token;
|
|
std::uint32_t pairedAddress = 0;
|
|
unsigned int failedCodeAttempts = 0;
|
|
};
|
|
|
|
void logEvent(const State* st, const std::string& message) {
|
|
std::ofstream log(st->dataDirectory + "/branding-server.log", std::ios::app);
|
|
if (log) log << static_cast<long long>(std::time(nullptr)) << " " << message << '\n';
|
|
}
|
|
|
|
bool equalToken(const std::string& a, const std::string& b) {
|
|
unsigned int diff = static_cast<unsigned int>(a.size() ^ b.size());
|
|
for (std::size_t i = 0; i < std::max(a.size(), b.size()); ++i)
|
|
diff |= static_cast<unsigned int>((i < a.size() ? a[i] : 0) ^ (i < b.size() ? b[i] : 0));
|
|
return diff == 0;
|
|
}
|
|
bool sendAll(int fd, const char* data, std::size_t size) {
|
|
std::size_t sent = 0;
|
|
while (sent < size) { const ssize_t n = ::send(fd, data + sent, size - sent, 0); if (n <= 0) return false; sent += static_cast<std::size_t>(n); }
|
|
return true;
|
|
}
|
|
void reply(int fd, int status, const char* reason, const std::string& type, const std::string& body) {
|
|
std::ostringstream h; h << "HTTP/1.1 " << status << ' ' << reason << "\r\nContent-Type: " << type
|
|
<< "\r\nContent-Length: " << body.size() << "\r\nConnection: close\r\nCache-Control: no-store\r\n"
|
|
<< "X-Content-Type-Options: nosniff\r\nReferrer-Policy: no-referrer\r\nX-Frame-Options: DENY\r\n"
|
|
<< "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\r\n";
|
|
const std::string headers = h.str(); sendAll(fd, headers.data(), headers.size()); if (!body.empty()) sendAll(fd, body.data(), body.size());
|
|
}
|
|
void textReply(int fd, int status, const char* reason, const std::string& body) { reply(fd, status, reason, "text/plain; charset=utf-8", body); }
|
|
std::string htmlEscape(const std::string& value) {
|
|
std::string out;
|
|
out.reserve(value.size());
|
|
for (char c : value) {
|
|
switch (c) {
|
|
case '&': out += "&"; break;
|
|
case '<': out += "<"; break;
|
|
case '>': out += ">"; break;
|
|
case '\"': out += """; break;
|
|
case '\'': out += "'"; break;
|
|
default: out += c; break;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
std::string page() {
|
|
std::string html = R"HTML(<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Branding Config Portal</title><style>
|
|
@font-face{font-family:Seurat;src:url('/assets/font')}*{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: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)}h1{font-size:clamp(24px,4vw,34px);margin:0 0 10px}p{line-height:1.5;color:#6c7280;margin-top:0}label{display:block;font-weight:600;margin:18px 0 7px}input{width:100%;font:inherit;padding:13px 14px;border:2px solid #d8dce5;border-radius:12px;background:#fff;color:#252831}input:focus{outline:3px solid #ffb77e;border-color:#ff751e}.button{margin-top:22px;padding:13px 22px;border:3px solid #fff;border-radius:28px;background:#ffd3a6;color:#ee6818;font:inherit;box-shadow:0 4px 0 #cfd2d8,0 0 0 2px #e5e7ec;cursor:pointer;transition:transform .12s}.button:hover{transform:translateY(-2px)}.button:active{transform:translateY(2px)}.button:disabled{opacity:.5}.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{padding:13px 16px;border-radius:13px;background:#effbdd;color:#4e8d2c;font-size:18px}[hidden]{display:none!important}.footer{margin:18px 4px;color:#697183;font-size:13px}@media(max-width:620px){.shell{padding:14px}.card{padding:20px}}
|
|
</style></head><body><main class="shell"><section class="card"><h1>Branding Config Portal</h1><div id="pair"><p>Enter the code shown on your Nintendo Switch.</p><label for="code">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="connected" class="connected" hidden>Connected to Nintendo Switch</div><form id="form" hidden><p>Set the app name and the words used for notes. Upload a transparent PNG or JPEG for the title screen logo. Restart the app after saving for changes to apply.</p><label for="app">App name</label><input id="app" maxlength="40" required value="{{APP_NAME}}"><label for="singular">Singular name</label><input id="singular" maxlength="24" required value="{{NOTE_NAME}}"><label for="plural">Plural name</label><input id="plural" maxlength="24" required value="{{NOTES_NAME}}"><label for="logo">Title screen logo (PNG or JPEG, up to 8 MB)</label><input id="logo" type="file" accept="image/png,image/jpeg"><button class="button" id="save">Save branding</button><div id="status" class="status">Ready.</div></form></section><div class="footer">Ver 0.1.51</div></main><script>
|
|
'use strict';
|
|
const $=id=>document.getElementById(id);
|
|
let token='',busy=false;
|
|
function msg(v,k=''){const e=token?$('status'):$('pairStatus');e.className='status'+(k?' '+k:'');e.textContent=v}
|
|
async function pollConnection(){if(!token)return;try{const r=await fetch('/api/status',{headers:{Authorization:'Bearer '+token},cache:'no-store'});if(!r.ok)throw Error();const state=await r.json();$('connected').textContent=state.connected?'Connected to Nintendo Switch':'Disconnected from Nintendo Switch';$('connected').className=state.connected?'connected':'connected status error'}catch{$('connected').textContent='Disconnected from Nintendo Switch';$('connected').className='connected status error'}}
|
|
async function connect(code){if(busy||token)return;busy=true;msg('Connecting…');try{const r=await fetch('/api/connect',{method:'POST',headers:{Authorization:'Bearer '+code}}),v=(await r.text()).trim();if(!r.ok)throw Error(v||'Could not connect.');if(!/^[a-f0-9]{64}$/.test(v))throw Error('Invalid Switch response.');token=v;$('pair').hidden=true;$('connected').hidden=false;$('form').hidden=false;const current=await fetch('/api/config',{headers:{Authorization:'Bearer '+token}});if(current.ok){const saved=await current.json();$('app').value=saved.app;$('singular').value=saved.singular;$('plural').value=saved.plural}pollConnection()}catch(e){msg(e.message,'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)connect($('code').value)});
|
|
$('form').addEventListener('submit',async e=>{e.preventDefault();if(!token||busy)return;busy=true;$('save').disabled=true;try{const f=$('logo').files[0];if(f){if(f.size>8*1024*1024)throw Error('Logo must be 8 MB or smaller.');if(!['image/png','image/jpeg'].includes(f.type))throw Error('Choose a PNG or JPEG image.');const upload=await fetch('/api/logo',{method:'PUT',headers:{Authorization:'Bearer '+token,'Content-Type':f.type},body:f});if(!upload.ok)throw Error(await upload.text()||'Logo upload failed.')}const data=new URLSearchParams({app:$('app').value,singular:$('singular').value,plural:$('plural').value});const save=await fetch('/api/config',{method:'POST',headers:{Authorization:'Bearer '+token,'Content-Type':'application/x-www-form-urlencoded'},body:data});if(!save.ok)throw Error(await save.text()||'Could not save branding.');msg('Branding saved on Nintendo Switch.','ok')}catch(err){msg(err.message||'Save failed.','error')}finally{busy=false;$('save').disabled=false}});
|
|
setInterval(pollConnection,2000);
|
|
</script></body></html>)HTML";
|
|
auto replace = [&](const std::string& marker, const std::string& value) {
|
|
const std::size_t at = html.find(marker);
|
|
if (at != std::string::npos) html.replace(at, marker.size(), htmlEscape(value));
|
|
};
|
|
replace("{{APP_NAME}}", portalAppName);
|
|
replace("{{NOTE_NAME}}", portalNoteSingular);
|
|
replace("{{NOTES_NAME}}", portalNotePlural);
|
|
const std::string loaderInput = "<label for=\"loading\">Loading animation (GIF or WebP)</label><input id=\"loading\" type=\"file\" accept=\"image/gif,image/webp\"><small>Animated images are reduced to eight small frames for the Switch.</small>";
|
|
const std::size_t formEnd = html.find("</form>");
|
|
if (formEnd != std::string::npos) html.insert(formEnd, loaderInput);
|
|
const std::string bundleControls = "<div style=\"margin-top:24px;padding-top:18px;border-top:2px solid #e2e6f2\"><strong>Branding file</strong><p>Save or load your branding and title logo as a .config file.</p><button class=\"button\" type=\"button\" id=\"exportBundle\">Export .config</button> <button class=\"button\" type=\"button\" id=\"chooseBundle\">Import .config</button><input id=\"bundleFile\" type=\"file\" accept=\".config,application/zip\" hidden></div>";
|
|
const std::size_t bundleFormEnd = html.find("</form>");
|
|
if (bundleFormEnd != std::string::npos) html.insert(bundleFormEnd, bundleControls);
|
|
const std::string uploadScript = R"JS(
|
|
async function uploadLoading(file){if(!file)return;if(!['image/gif','image/webp'].includes(file.type))throw Error('Choose an animated GIF or WebP.');if(file.size>16*1024*1024)throw Error('Loading animation must be 16 MB or smaller.');const url=URL.createObjectURL(file),im=new Image();try{await new Promise((resolve,reject)=>{im.onload=resolve;im.onerror=()=>reject(Error('Could not decode that animation.'));im.src=url});const c=document.createElement('canvas');c.width=64;c.height=64;const g=c.getContext('2d');for(let i=0;i<8;i++){g.clearRect(0,0,64,64);const s=Math.min(64/im.naturalWidth,64/im.naturalHeight),w=im.naturalWidth*s,h=im.naturalHeight*s;g.drawImage(im,(64-w)/2,(64-h)/2,w,h);const png=await new Promise(resolve=>c.toBlob(resolve,'image/png'));if(!png)throw Error('Could not convert the animation.');const r=await fetch('/api/loading/'+i,{method:'PUT',headers:{Authorization:'Bearer '+token,'Content-Type':'image/png'},body:png});if(!r.ok)throw Error(await r.text()||'Could not upload the loading animation.');await new Promise(resolve=>setTimeout(resolve,125))}}finally{URL.revokeObjectURL(url)}}
|
|
)JS";
|
|
const std::string bundleScript = R"JS(
|
|
async function exportBrandingBundle(){if(!token||busy)return;busy=true;$('exportBundle').disabled=true;try{msg('Preparing branding file…');const r=await fetch('/api/bundle',{headers:{Authorization:'Bearer '+token},cache:'no-store'});if(!r.ok)throw Error(await r.text()||'Could not export branding.');const blob=await r.blob(),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='branding.config';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);msg('Branding exported as branding.config.','ok')}catch(e){msg(e.message||'Could not export branding.','error')}finally{busy=false;$('exportBundle').disabled=false}}
|
|
async function importBrandingBundle(file){if(!file||!token||busy)return;busy=true;$('chooseBundle').disabled=true;try{if(file.size>8*1024*1024+16384)throw Error('Branding file is too large.');msg('Importing branding…');const r=await fetch('/api/bundle',{method:'POST',headers:{Authorization:'Bearer '+token,'Content-Type':'application/octet-stream'},body:file});if(!r.ok)throw Error(await r.text()||'Could not import branding.');const current=await fetch('/api/config',{headers:{Authorization:'Bearer '+token},cache:'no-store'});if(current.ok){const saved=await current.json();$('app').value=saved.app;$('singular').value=saved.singular;$('plural').value=saved.plural}$('logo').value='';msg('Branding imported. Restart the app for changes to apply.','ok')}catch(e){msg(e.message||'Could not import branding.','error')}finally{busy=false;$('chooseBundle').disabled=false;$('bundleFile').value=''}}
|
|
$('exportBundle').addEventListener('click',exportBrandingBundle);$('chooseBundle').addEventListener('click',()=>{if(!busy)$('bundleFile').click()});$('bundleFile').addEventListener('change',()=>importBrandingBundle($('bundleFile').files[0]));
|
|
)JS";
|
|
const std::string submitHook = "$('form').addEventListener('submit',async e=>{e.preventDefault();if(!token||busy)return;busy=true;$('save').disabled=true;try{const f=$('logo').files[0];";
|
|
const std::string hookedSubmit = "$('form').addEventListener('submit',async e=>{e.preventDefault();if(!token||busy)return;busy=true;$('save').disabled=true;try{const loadingFile=$('loading').files[0];if(loadingFile)await uploadLoading(loadingFile);const f=$('logo').files[0];";
|
|
const std::size_t submitAt = html.find(submitHook);
|
|
if (submitAt != std::string::npos) html.replace(submitAt, submitHook.size(), hookedSubmit);
|
|
const std::size_t scriptAt = html.find("setInterval(pollConnection,2000);");
|
|
if (scriptAt != std::string::npos) html.insert(scriptAt, uploadScript);
|
|
const std::size_t bundleScriptAt = html.find("setInterval(pollConnection,2000);");
|
|
if (bundleScriptAt != std::string::npos) html.insert(bundleScriptAt, bundleScript);
|
|
return html;
|
|
}
|
|
bool readRequest(int fd, std::string& request, std::size_t& bodyAt, std::size_t& bodySize) {
|
|
char buffer[8192]; std::size_t headerEnd = std::string::npos;
|
|
while (headerEnd == std::string::npos) { ssize_t n=::recv(fd,buffer,sizeof(buffer),0); if(n<=0)return false; request.append(buffer,static_cast<std::size_t>(n)); if(request.size()>16384)return false; headerEnd=request.find("\r\n\r\n"); }
|
|
bodyAt=headerEnd+4; std::string headers=request.substr(0,headerEnd); std::size_t pos=headers.find("Content-Length:");
|
|
bodySize=0;if(pos!=std::string::npos){pos+=15;while(pos<headers.size()&&headers[pos]==' ')++pos;bodySize=static_cast<std::size_t>(std::strtoul(headers.c_str()+pos,nullptr,10));}
|
|
if(bodySize>kMaxRequest)return false;
|
|
while(request.size()-bodyAt<bodySize){std::size_t n=std::min(sizeof(buffer),bodySize-(request.size()-bodyAt));ssize_t got=::recv(fd,buffer,n,0);if(got<=0)return false;request.append(buffer,static_cast<std::size_t>(got));}
|
|
return true;
|
|
}
|
|
std::string decode(const std::string& s) { std::string o; for(std::size_t i=0;i<s.size();++i){if(s[i]=='+')o+=' ';else if(s[i]=='%'&&i+2<s.size()){char h[3]={s[i+1],s[i+2],0};o+=static_cast<char>(std::strtoul(h,nullptr,16));i+=2;}else o+=s[i];}return o; }
|
|
std::string jsonEscape(const std::string& s){std::string o="\"";for(unsigned char c:s){if(c=='\\'||c=='\"'){o+='\\';o+=static_cast<char>(c);}else if(c<32){constexpr char h[]="0123456789abcdef";o+="\\u00";o+=h[c>>4];o+=h[c&15];}else o+=static_cast<char>(c);}o+='\"';return o;}
|
|
std::map<std::string,std::string> fields(const std::string& b){std::map<std::string,std::string> m;std::size_t p=0;while(p<=b.size()){auto e=b.find('&',p);auto part=b.substr(p,e==std::string::npos?e:e-p);auto q=part.find('=');if(q!=std::string::npos)m[decode(part.substr(0,q))]=decode(part.substr(q+1));if(e==std::string::npos)break;p=e+1;}return m;}
|
|
bool cleanValue(std::string& s, std::size_t max) { if(s.empty()||s.size()>max)return false;for(unsigned char c:s)if(c<32||c==127)return false;return true; }
|
|
void handle(State* st,int fd){std::string req;std::size_t bodyAt=0,bodySize=0;if(!readRequest(fd,req,bodyAt,bodySize)){textReply(fd,400,"Bad Request","Invalid request.");return;}auto lineEnd=req.find("\r\n");if(lineEnd==std::string::npos)return;std::istringstream first(req.substr(0,lineEnd));std::string method,path,version;first>>method>>path>>version;std::string headers=req.substr(lineEnd+2,bodyAt-lineEnd-4);auto h=headers.find("Authorization: Bearer ");std::string auth=h==std::string::npos?"":headers.substr(h+22);auto trimEnd=auth.find("\r\n");if(trimEnd!=std::string::npos)auth.resize(trimEnd);auto peer=static_cast<std::uint32_t>(st->pairedAddress);sockaddr_in addr{};socklen_t addrLen=sizeof(addr);if(getpeername(fd,reinterpret_cast<sockaddr*>(&addr),&addrLen)==0)peer=ntohl(addr.sin_addr.s_addr);
|
|
if(method=="GET"&&path=="/"){reply(fd,200,"OK","text/html; charset=utf-8",page());return;}if(method=="GET"&&path=="/favicon.ico"){reply(fd,204,"No Content","image/x-icon","");return;}if(method=="GET"&&path=="/api/health"){textReply(fd,200,"OK","Branding portal build: save-diagnostics-1");return;}if(method=="GET"&&path=="/assets/font"){std::ifstream f(st->assetsRoot+"/fonts/FOT-Seurat.otf",std::ios::binary|std::ios::ate);if(!f){textReply(fd,404,"Not Found","Font unavailable.");return;}auto n=f.tellg();if(n<=0||n>4*1024*1024){textReply(fd,404,"Not Found","Font unavailable.");return;}std::string data(static_cast<std::size_t>(n),'\0');f.seekg(0);f.read(data.data(),n);reply(fd,200,"OK","font/otf",data);return;}
|
|
if(method=="POST"&&path=="/api/connect"){if(st->failedCodeAttempts>=8){textReply(fd,429,"Too Many Requests","Close and reopen the portal on Switch to try again.");return;}std::string code=auth;code.erase(std::remove(code.begin(),code.end(),'"'),code.end());if(!equalToken(code,st->code)){++st->failedCodeAttempts;logEvent(st,"pairing rejected");textReply(fd,403,"Forbidden","Incorrect code.");return;}st->pairedAddress=peer;std::array<std::uint8_t,32> random{};if(R_FAILED(csrngGetRandomBytes(random.data(),random.size()))){textReply(fd,500,"Internal Server Error","Could not create session.");return;}constexpr char hex[]="0123456789abcdef";st->token.clear();for(auto b:random){st->token+=hex[b>>4];st->token+=hex[b&15];}st->lastSeenSeconds.store(static_cast<std::uint64_t>(std::time(nullptr)));st->connected.store(true);logEvent(st,"paired PC session");textReply(fd,200,"OK",st->token);return;}
|
|
if(!st->connected.load()||peer!=st->pairedAddress||!equalToken(auth,st->token)){textReply(fd,403,"Forbidden","Connect using the code on the Switch first.");return;}st->lastSeenSeconds.store(static_cast<std::uint64_t>(std::time(nullptr)));
|
|
if(method=="GET"&&path=="/api/status"){textReply(fd,200,"OK","{\"connected\":true}");return;}
|
|
if(method=="GET"&&path=="/api/config"){std::ifstream in(st->dataDirectory+"/branding.ini",std::ios::binary);std::string app=portalAppName,one=portalNoteSingular,many=portalNotePlural;if(in){std::string v;if(std::getline(in,v)&&!v.empty())app=v;if(std::getline(in,v)&&!v.empty())one=v;if(std::getline(in,v)&&!v.empty())many=v;}textReply(fd,200,"OK","{\"app\":"+jsonEscape(app)+",\"singular\":"+jsonEscape(one)+",\"plural\":"+jsonEscape(many)+"}");return;}
|
|
if(method=="GET"&&path=="/api/bundle"){
|
|
branding::Bundle bundle;std::ifstream config(st->dataDirectory+"/branding.ini",std::ios::binary);
|
|
if(config){std::string value;if(std::getline(config,value)&&!value.empty())bundle.app=value;else bundle.app=portalAppName;if(std::getline(config,value)&&!value.empty())bundle.singular=value;else bundle.singular=portalNoteSingular;if(std::getline(config,value)&&!value.empty())bundle.plural=value;else bundle.plural=portalNotePlural;}
|
|
else{bundle.app=portalAppName;bundle.singular=portalNoteSingular;bundle.plural=portalNotePlural;}
|
|
std::string logoPath=st->dataDirectory+"/branding-logo.png";std::ifstream logo(logoPath,std::ios::binary|std::ios::ate);
|
|
if(!logo){logoPath=st->assetsRoot+"/art/switchnotedefault.png";logo.open(logoPath,std::ios::binary|std::ios::ate);}
|
|
if(!logo){logEvent(st,"branding export failed: no logo available");textReply(fd,500,"Internal Server Error","No title logo is available to export.");return;}
|
|
const std::streamoff logoSize=logo.tellg();if(logoSize<=0||logoSize>8*1024*1024){textReply(fd,500,"Internal Server Error","The title logo has an invalid size.");return;}
|
|
bundle.logo.resize(static_cast<std::size_t>(logoSize));logo.seekg(0);logo.read(reinterpret_cast<char*>(bundle.logo.data()),logoSize);if(!logo){textReply(fd,500,"Internal Server Error","Could not read the title logo.");return;}logo.close();
|
|
const char* image=reinterpret_cast<const char*>(bundle.logo.data());const bool png=bundle.logo.size()>=8&&std::memcmp(image,"\x89PNG\r\n\x1a\n",8)==0;const bool jpg=bundle.logo.size()>=3&&static_cast<unsigned char>(image[0])==0xff&&static_cast<unsigned char>(image[1])==0xd8&&static_cast<unsigned char>(image[2])==0xff;if(!png&&!jpg){textReply(fd,415,"Unsupported Media Type","The current logo is not a supported PNG or JPEG.");return;}
|
|
const std::string temp=st->dataDirectory+"/branding-export.pending.config";std::string error;if(!branding::writeConfig(temp,bundle,error)){logEvent(st,"branding export failed: "+error);textReply(fd,500,"Internal Server Error",error);return;}
|
|
std::ifstream archive(temp,std::ios::binary|std::ios::ate);const std::streamoff size=archive?static_cast<std::streamoff>(archive.tellg()):-1;if(size<22||size>9*1024*1024){archive.close();std::remove(temp.c_str());textReply(fd,500,"Internal Server Error","Could not prepare branding.config.");return;}std::string bytes(static_cast<std::size_t>(size),'\0');archive.seekg(0);archive.read(bytes.data(),static_cast<std::streamsize>(size));archive.close();std::remove(temp.c_str());if(!archive){textReply(fd,500,"Internal Server Error","Could not read exported branding.config.");return;}logEvent(st,"branding config exported: "+std::to_string(bytes.size())+" bytes");reply(fd,200,"OK","application/octet-stream",bytes);return;
|
|
}
|
|
if(method=="POST"&&path=="/api/bundle"){
|
|
if(bodySize<22||bodySize>kMaxRequest){textReply(fd,400,"Bad Request","Branding file size is invalid.");return;}
|
|
const std::string temp=st->dataDirectory+"/branding-import.pending.config";{std::ofstream file(temp,std::ios::binary|std::ios::trunc);if(!file){textReply(fd,500,"Internal Server Error","Could not open a temporary branding file.");return;}file.write(req.data()+bodyAt,static_cast<std::streamsize>(bodySize));file.flush();if(!file){textReply(fd,500,"Internal Server Error","Could not store the branding file.");return;}}
|
|
branding::Bundle bundle;std::string error;const bool parsed=branding::readConfig(temp,bundle,error);std::remove(temp.c_str());if(!parsed){logEvent(st,"branding import rejected: "+error);textReply(fd,400,"Bad Request",error);return;}
|
|
auto valid=[](const std::string& value){return std::all_of(value.begin(),value.end(),[](unsigned char c){return std::isalnum(c)||c==' '||c=='-'||c=='_'||c=='.'||c>=128;});};if(!cleanValue(bundle.app,40)||!cleanValue(bundle.singular,24)||!cleanValue(bundle.plural,24)||!valid(bundle.app)||!valid(bundle.singular)||!valid(bundle.plural)){textReply(fd,400,"Bad Request","Branding names contain unsupported characters or exceed their limits.");return;}
|
|
const char* image=reinterpret_cast<const char*>(bundle.logo.data());const bool png=bundle.logo.size()>=8&&std::memcmp(image,"\x89PNG\r\n\x1a\n",8)==0;const bool jpg=bundle.logo.size()>=3&&static_cast<unsigned char>(image[0])==0xff&&static_cast<unsigned char>(image[1])==0xd8&&static_cast<unsigned char>(image[2])==0xff;if(!png&&!jpg){textReply(fd,415,"Unsupported Media Type","Branding config must contain a PNG or JPEG logo.");return;}
|
|
const std::string logoPath=st->dataDirectory+"/branding-logo.pending";{std::ofstream logo(logoPath,std::ios::binary|std::ios::trunc);if(!logo){textReply(fd,500,"Internal Server Error","Could not stage the title logo.");return;}logo.write(reinterpret_cast<const char*>(bundle.logo.data()),static_cast<std::streamsize>(bundle.logo.size()));logo.flush();if(!logo){textReply(fd,500,"Internal Server Error","Could not write the title logo.");return;}}
|
|
const std::string configPath=st->dataDirectory+"/branding.ini";{std::ofstream config(configPath,std::ios::binary|std::ios::trunc);if(!config){std::remove(logoPath.c_str());textReply(fd,500,"Internal Server Error","Could not open branding settings on the SD card.");return;}config<<bundle.app<<'\n'<<bundle.singular<<'\n'<<bundle.plural<<'\n';config.flush();if(!config){std::remove(logoPath.c_str());textReply(fd,500,"Internal Server Error","Could not write branding settings to the SD card.");return;}}
|
|
logEvent(st,"branding config imported: "+std::to_string(bodySize)+" bytes");st->pending.store(true,std::memory_order_release);textReply(fd,200,"OK","Imported.");return;
|
|
}
|
|
if(method=="PUT"&&path=="/api/logo"){if(bodySize<8||bodySize>8*1024*1024){logEvent(st,"logo upload rejected: invalid size");textReply(fd,400,"Bad Request","Logo must be 8 MB or smaller.");return;}const char* b=req.data()+bodyAt;bool png=bodySize>=8&&std::memcmp(b,"\x89PNG\r\n\x1a\n",8)==0;bool jpg=bodySize>=3&&static_cast<unsigned char>(b[0])==0xff&&static_cast<unsigned char>(b[1])==0xd8&&static_cast<unsigned char>(b[2])==0xff;if(!png&&!jpg){logEvent(st,"logo upload rejected: invalid image signature");textReply(fd,415,"Unsupported Media Type","Choose a valid PNG or JPEG logo.");return;}std::string tmp=st->dataDirectory+"/branding-logo.pending";std::ofstream out(tmp,std::ios::binary|std::ios::trunc);if(!out){logEvent(st,"logo upload failed: pending file open failed");textReply(fd,500,"Internal Server Error","Could not open logo file on the SD card.");return;}out.write(b,static_cast<std::streamsize>(bodySize));out.flush();out.close();if(!out){logEvent(st,"logo upload failed: pending file write failed");textReply(fd,500,"Internal Server Error","Could not store logo on the SD card.");return;}logEvent(st,"logo uploaded: "+std::to_string(bodySize)+" bytes");textReply(fd,200,"OK","Logo received.");return;}
|
|
if(method=="PUT"&&path.size()==14&&path.rfind("/api/loading/",0)==0&&path.back()>='0'&&path.back()<='7'){if(bodySize<8||bodySize>128*1024){textReply(fd,400,"Bad Request","Loading frame is too large.");return;}const char*b=req.data()+bodyAt;if(std::memcmp(b,"\x89PNG\r\n\x1a\n",8)!=0){textReply(fd,415,"Unsupported Media Type","Loading frames must be PNG images.");return;}const std::string file=st->dataDirectory+"/branding-loading"+path.substr(path.size()-1)+".pending";std::ofstream out(file,std::ios::binary|std::ios::trunc);if(!out){textReply(fd,500,"Internal Server Error","Could not save loading frame.");return;}out.write(b,static_cast<std::streamsize>(bodySize));out.flush();if(!out){textReply(fd,500,"Internal Server Error","Could not write loading frame.");return;}logEvent(st,"loading frame uploaded: "+path.substr(path.size()-1));textReply(fd,200,"OK","Frame received.");return;}
|
|
if(method=="POST"&&path=="/api/config"){auto v=fields(req.substr(bodyAt,bodySize));std::string app=v["app"],one=v["singular"],many=v["plural"];if(!cleanValue(app,40)||!cleanValue(one,24)||!cleanValue(many,24)){textReply(fd,400,"Bad Request","Enter names within the listed limits.");return;}auto valid=[](const std::string&s){return std::all_of(s.begin(),s.end(),[](unsigned char c){return std::isalnum(c)||c==' '||c=='-'||c=='_'||c=='.'||c>=128;});};if(!valid(app)||!valid(one)||!valid(many)){textReply(fd,400,"Bad Request","Names may use letters, numbers, spaces, hyphens, underscores and periods.");return;}
|
|
// libnx's std::rename over FAT/SD can fail when replacing an existing
|
|
// file, even after the old destination has been removed. Write the small
|
|
// config directly instead of reporting a false save failure.
|
|
const std::string dst=st->dataDirectory+"/branding.ini";
|
|
std::ofstream out(dst,std::ios::binary|std::ios::trunc);
|
|
if(!out){logEvent(st,"branding save failed: destination open failed");textReply(fd,500,"Internal Server Error","Could not open branding file on the SD card.");return;}
|
|
out<<app<<'\n'<<one<<'\n'<<many<<'\n';out.flush();
|
|
if(!out){out.close();logEvent(st,"branding save failed: write failed");textReply(fd,500,"Internal Server Error","Could not write branding file to the SD card.");return;}
|
|
out.close();if(!out){logEvent(st,"branding save failed: close/flush failed");textReply(fd,500,"Internal Server Error","Could not finish writing branding file to the SD card.");return;}
|
|
logEvent(st,"branding settings saved");st->pending.store(true,std::memory_order_release);textReply(fd,200,"OK","Saved.");return;}
|
|
textReply(fd,404,"Not Found","Not found.");
|
|
}
|
|
void serverMain(void* arg){State* st=static_cast<State*>(arg);while(st->running.load()){fd_set set;FD_ZERO(&set);FD_SET(st->listenFd,&set);timeval wait{0,200000};if(select(st->listenFd+1,&set,nullptr,nullptr,&wait)<=0)continue;sockaddr_in peer{};socklen_t size=sizeof(peer);int fd=accept(st->listenFd,reinterpret_cast<sockaddr*>(&peer),&size);if(fd<0)continue;st->activeClient.store(fd);timeval timeout{5,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));handle(st,fd);st->activeClient.store(-1);::close(fd);}if(st->listenFd>=0){::close(st->listenFd);st->listenFd=-1;}st->running.store(false);}
|
|
#endif
|
|
}
|
|
|
|
BrandingConfigServer::BrandingConfigServer() = default;
|
|
BrandingConfigServer::~BrandingConfigServer(){stop();}
|
|
bool BrandingConfigServer::start(const std::string& ip,const std::string& data,const std::string& assets,const std::string& code,std::string& error,const std::string& appName,const std::string& noteSingular,const std::string& notePlural){
|
|
#ifdef __SWITCH__
|
|
portalAppName = appName;
|
|
portalNoteSingular = noteSingular;
|
|
portalNotePlural = notePlural;
|
|
stop();State* s=new(std::nothrow) State();if(!s){error="Not enough memory to start the portal.";return false;}s->bindAddress=ip;s->dataDirectory=data;s->assetsRoot=assets;s->code=code;s->listenFd=::socket(AF_INET,SOCK_STREAM,0);if(s->listenFd<0){error="Could not create the local web server.";delete s;return false;}int yes=1;setsockopt(s->listenFd,SOL_SOCKET,SO_REUSEADDR,&yes,sizeof(yes));sockaddr_in a{};a.sin_family=AF_INET;a.sin_port=htons(kPort);if(inet_pton(AF_INET,ip.c_str(),&a.sin_addr)!=1||bind(s->listenFd,reinterpret_cast<sockaddr*>(&a),sizeof(a))<0||listen(s->listenFd,4)<0){error="Could not start the portal on port 8767.";::close(s->listenFd);delete s;return false;}s->running.store(true);if(R_FAILED(threadCreate(&s->thread,serverMain,s,nullptr,0x10000,0x3b,-2))){error="Could not create the portal service thread.";s->running.store(false);::close(s->listenFd);delete s;return false;}if(R_FAILED(threadStart(&s->thread))){error="Could not start the portal service.";s->running.store(false);::close(s->listenFd);threadClose(&s->thread);delete s;return false;}impl_=s;return true;
|
|
#else
|
|
(void)ip;(void)data;(void)assets;(void)code;(void)appName;(void)noteSingular;(void)notePlural;error="The branding portal is available in the Switch homebrew app.";return false;
|
|
#endif
|
|
}
|
|
void BrandingConfigServer::stop(){
|
|
#ifdef __SWITCH__
|
|
State*s=static_cast<State*>(impl_);if(!s)return;s->running.store(false);int c=s->activeClient.load();if(c>=0)::shutdown(c,SHUT_RDWR);if(s->listenFd>=0)::shutdown(s->listenFd,SHUT_RDWR);threadWaitForExit(&s->thread);threadClose(&s->thread);delete s;impl_=nullptr;
|
|
#endif
|
|
}
|
|
bool BrandingConfigServer::isRunning()const{
|
|
#ifdef __SWITCH__
|
|
auto*s=static_cast<State*>(impl_);return s&&s->running.load();
|
|
#else
|
|
return false;
|
|
#endif
|
|
}
|
|
bool BrandingConfigServer::isConnected()const{
|
|
#ifdef __SWITCH__
|
|
auto*s=static_cast<State*>(impl_);if(!s||!s->connected.load())return false;const auto now=static_cast<std::uint64_t>(std::time(nullptr));const auto last=s->lastSeenSeconds.load();return now>=last&&now-last<=6;
|
|
#else
|
|
return false;
|
|
#endif
|
|
}
|
|
bool BrandingConfigServer::takeConfigUpdate(){
|
|
#ifdef __SWITCH__
|
|
auto*s=static_cast<State*>(impl_);return s&&s->pending.exchange(false,std::memory_order_acq_rel);
|
|
#else
|
|
return false;
|
|
#endif
|
|
}
|
