#include "audio_upload_server.hpp" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #ifdef __SWITCH__ #include #include #include #include #include #include #include #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 running{false}; std::atomic connected{false}; std::atomic pendingTrack{-1}; std::atomic uploadOutcome{0}; // 0 idle, 1 processing, 2 imported, 3 rejected std::atomic 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& 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(std::tolower(static_cast(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(a.size() ^ b.size()); for (std::size_t i = 0; i < length; ++i) { const unsigned char av = i < a.size() ? static_cast(a[i]) : 0; const unsigned char bv = i < b.size() ? static_cast(b[i]) : 0; difference |= static_cast(av ^ bv); } return difference == 0; } bool sendAll(int fd, const void* bytes, std::size_t length) { const auto* data = static_cast(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(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("' ", ""); 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( Audio upload

Enter the code shown in Sound on your Nintendo Switch.

Enter all six digits to connect.
)HTML", noteSingular); } std::string photoPage(const std::string& noteSingular) { return brandedPage(R"HTML(Photo import

Photo Import

Connect to your {{NOTE}} over local Wi-Fi using the code shown on screen.

Enter all six digits to connect.
)HTML", noteSingular); } bool readAsset(const std::string& path, std::vector& 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(length)); file.seekg(0, std::ios::beg); return static_cast(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(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(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(count)); received += static_cast(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& 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(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(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(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 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 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(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(bodyLength)); } void serverThread(void* argument) { auto* state = static_cast(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(&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 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(&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(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(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(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(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(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 }