NAM A2 Sync
This commit is contained in:
@@ -247,8 +247,30 @@ std::string HtmlHelper::Rfc5987EncodeFileName(const std::string &name)
|
||||
|
||||
#define MAX_FILE_NAME_LENGHT 96
|
||||
|
||||
const std::string SF_SPECIALS = " <>@;:\"\'/[]?=+";
|
||||
const std::string SF_SPECIALS = "<>@;:\"\'/[]?=";
|
||||
const std::string ISF_SPECIALS = "\\:";
|
||||
|
||||
|
||||
bool HtmlHelper::IsSafeFileName(const std::filesystem::path &path)
|
||||
{
|
||||
for (const auto&segment: path)
|
||||
{
|
||||
if (segment.string() == "..")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for (char c: segment.string())
|
||||
{
|
||||
unsigned char uc = (unsigned char)c;
|
||||
if (uc < 0x20 || uc >= 0x80 || ISF_SPECIALS.find(c) != std::string::npos)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
std::string HtmlHelper::SafeFileName(const std::string &name)
|
||||
{
|
||||
std::stringstream s;
|
||||
@@ -456,8 +478,30 @@ std::string HtmlHelper::httpErrorString(int errorCode)
|
||||
auto ff = httpErrorStrings.find(errorCode);
|
||||
if (ff != httpErrorStrings.end())
|
||||
{
|
||||
return SS(errorCode << " " << ff->second);
|
||||
return SS(errorCode << ": " << ff->second);
|
||||
}
|
||||
return SS(errorCode << " Unknown error");
|
||||
return SS(errorCode << ": Unknown error");
|
||||
|
||||
}
|
||||
|
||||
std::string HtmlFormBuilder::build()
|
||||
{
|
||||
std::ostringstream ss;
|
||||
bool firstTime = true;
|
||||
for (const auto &entry: entries_)
|
||||
{
|
||||
if (!firstTime)
|
||||
{
|
||||
ss << "&";
|
||||
}
|
||||
ss << entry.key();
|
||||
if (entry.value().has_value())
|
||||
{
|
||||
|
||||
ss << "=" << HtmlHelper::encode_url_segment(entry.value().value(),true);
|
||||
}
|
||||
firstTime = false;
|
||||
|
||||
}
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
@@ -24,47 +24,75 @@
|
||||
#include <chrono>
|
||||
#include <filesystem>
|
||||
|
||||
namespace pipedal {
|
||||
namespace pipedal
|
||||
{
|
||||
|
||||
class HtmlHelper {
|
||||
|
||||
public:
|
||||
|
||||
static std::string timeToHttpDate();
|
||||
static std::string timeToHttpDate(time_t time);
|
||||
static std::string timeToHttpDate(std::filesystem::file_time_type time);
|
||||
|
||||
|
||||
static std::string encode_url_segment(const char*pStart, const char*pEnd, bool isQuerySegment = false);
|
||||
static std::string encode_url_segment(const std::string &segment, bool isQuerySegment = false) {
|
||||
return encode_url_segment(segment.c_str(), segment.c_str() + segment.length(), isQuerySegment);
|
||||
}
|
||||
static void encode_url_segment(std::ostream&os, const char*pStart, const char *pEnd, bool isQuerySegment = false);
|
||||
static void encode_url_segment(std::ostream&os, const std::string&segment, bool isQuerySegment = false)
|
||||
class HtmlHelper
|
||||
{
|
||||
const char*p = segment.c_str();
|
||||
encode_url_segment(os,p, p + segment.length(),isQuerySegment);
|
||||
}
|
||||
|
||||
public:
|
||||
static std::string timeToHttpDate();
|
||||
static std::string timeToHttpDate(time_t time);
|
||||
static std::string timeToHttpDate(std::filesystem::file_time_type time);
|
||||
|
||||
static std::string decode_url_segment(const char*pStart, const char*pEnd, bool isQuerySegment = false);
|
||||
static std::string encode_url_segment(const char *pStart, const char *pEnd, bool isQuerySegment = false);
|
||||
static std::string encode_url_segment(const std::string &segment, bool isQuerySegment = false)
|
||||
{
|
||||
return encode_url_segment(segment.c_str(), segment.c_str() + segment.length(), isQuerySegment);
|
||||
}
|
||||
static void encode_url_segment(std::ostream &os, const char *pStart, const char *pEnd, bool isQuerySegment = false);
|
||||
static void encode_url_segment(std::ostream &os, const std::string &segment, bool isQuerySegment = false)
|
||||
{
|
||||
const char *p = segment.c_str();
|
||||
encode_url_segment(os, p, p + segment.length(), isQuerySegment);
|
||||
}
|
||||
|
||||
static std::string decode_url_segment(const char*text, bool isQuerySegment = false);
|
||||
static std::string decode_url_segment(const char *pStart, const char *pEnd, bool isQuerySegment = false);
|
||||
|
||||
static void utf32_to_utf8_stream(std::ostream &s, uint32_t uc);
|
||||
static std::string decode_url_segment(const char *text, bool isQuerySegment = false);
|
||||
|
||||
static std::string Rfc5987EncodeFileName(const std::string&name);
|
||||
static void utf32_to_utf8_stream(std::ostream &s, uint32_t uc);
|
||||
|
||||
static std::string SafeFileName(const std::string &name);
|
||||
static std::string HtmlEncode(const std::string& text);
|
||||
static std::string Rfc5987EncodeFileName(const std::string &name);
|
||||
|
||||
static uint64_t crc64(uint8_t *data, size_t length,uint64_t crc = 0);
|
||||
static uint64_t crc64(const std::string&value, uint64_t crc = 0);
|
||||
static std::string SafeFileName(const std::string &name);
|
||||
static bool IsSafeFileName(const std::filesystem::path &path);
|
||||
static std::string HtmlEncode(const std::string &text);
|
||||
|
||||
static std::string generateEtag(const std::filesystem::path &path);
|
||||
static uint64_t crc64(uint8_t *data, size_t length, uint64_t crc = 0);
|
||||
static uint64_t crc64(const std::string &value, uint64_t crc = 0);
|
||||
|
||||
static std::string httpErrorString(int errorCode);
|
||||
static std::string generateEtag(const std::filesystem::path &path);
|
||||
|
||||
};
|
||||
static std::string httpErrorString(int errorCode);
|
||||
};
|
||||
|
||||
class HtmlFormBuilder
|
||||
{
|
||||
|
||||
public:
|
||||
class FormEntry
|
||||
{
|
||||
public:
|
||||
FormEntry() = default;
|
||||
FormEntry(const std::string &key) : key_(key) {}
|
||||
FormEntry(const std::string &key, const std::string &value) : key_(key), value_(value) {}
|
||||
|
||||
const std::string&key() const { return key_; }
|
||||
const std::optional<std::string>&value() const { return value_; }
|
||||
private:
|
||||
std::string key_;
|
||||
std::optional<std::string> value_;
|
||||
};
|
||||
HtmlFormBuilder() = default;
|
||||
HtmlFormBuilder(const std::vector<FormEntry> &&entries) : entries_(std::move(entries)) {}
|
||||
void Add(const std::string &key) { entries_.push_back(FormEntry(key)); }
|
||||
void Add(const std::string &key, const std::string &value) { entries_.push_back(FormEntry(key, value)); }
|
||||
|
||||
std::string build();
|
||||
|
||||
private:
|
||||
std::vector<FormEntry> entries_;
|
||||
};
|
||||
|
||||
} // namespace.
|
||||
+442
-221
@@ -41,16 +41,15 @@ static const std::filesystem::path WEB_TEMP_DIR{"/var/pipedal/web_temp"};
|
||||
|
||||
static bool enableLogging = true;
|
||||
|
||||
|
||||
namespace pipedal
|
||||
{
|
||||
|
||||
static void logHeaders(const std::vector<std::string>&headers)
|
||||
static void logHeaders(const std::vector<std::string> &headers)
|
||||
{
|
||||
if (enableLogging)
|
||||
{
|
||||
std::ofstream os {"/tmp/PipedalCurl.log"};
|
||||
for (const auto&header: headers)
|
||||
std::ofstream os{"/tmp/PipedalCurl.log"};
|
||||
for (const auto &header : headers)
|
||||
{
|
||||
os << header << "\n";
|
||||
}
|
||||
@@ -62,92 +61,178 @@ namespace pipedal
|
||||
// https://curl.se/libcurl/c/libcurl-errors.html
|
||||
switch (exitCode)
|
||||
{
|
||||
case 0: return "OK"; // CURLE_OK - All fine
|
||||
case 1: return "Unsupported protocol.";
|
||||
case 2: return "Failed to initialize.";
|
||||
case 3: return "URL malformed.";
|
||||
case 4: return "Feature not built-in.";
|
||||
case 5: return "Couldn't resolve proxy.";
|
||||
case 6: return "Couldn't resolve host.";
|
||||
case 7: return "Failed to connect to host.";
|
||||
case 8: return "Weird server reply.";
|
||||
case 9: return "Access denied to remote resource.";
|
||||
case 10: return "FTP accept failed.";
|
||||
case 11: return "FTP weird PASS reply.";
|
||||
case 12: return "FTP accept timeout.";
|
||||
case 13: return "FTP weird PASV reply.";
|
||||
case 14: return "FTP weird 227 format.";
|
||||
case 15: return "FTP can't get host.";
|
||||
case 16: return "HTTP/2 error.";
|
||||
case 17: return "FTP couldn't set type.";
|
||||
case 18: return "Partial file transfer.";
|
||||
case 19: return "FTP couldn't retrieve file.";
|
||||
case 21: return "FTP quote error.";
|
||||
case 22: return "HTTP page not retrieved.";
|
||||
case 23: return "Write error.";
|
||||
case 25: return "Upload failed.";
|
||||
case 26: return "Read error.";
|
||||
case 27: return "Out of memory.";
|
||||
case 28: return "Operation timeout.";
|
||||
case 30: return "FTP PORT failed.";
|
||||
case 31: return "FTP couldn't use REST.";
|
||||
case 33: return "Range error.";
|
||||
case 35: return "SSL connect error.";
|
||||
case 36: return "Bad download resume.";
|
||||
case 37: return "FILE couldn't read file.";
|
||||
case 38: return "LDAP cannot bind.";
|
||||
case 39: return "LDAP search failed.";
|
||||
case 42: return "Aborted by callback.";
|
||||
case 43: return "Bad function argument.";
|
||||
case 45: return "Interface failed.";
|
||||
case 47: return "Too many redirects.";
|
||||
case 48: return "Unknown option.";
|
||||
case 49: return "Setopt option syntax error.";
|
||||
case 52: return "Got nothing from server.";
|
||||
case 53: return "SSL engine not found.";
|
||||
case 54: return "SSL engine set failed.";
|
||||
case 55: return "Failed sending network data.";
|
||||
case 56: return "Failure receiving network data.";
|
||||
case 58: return "Problem with local client certificate.";
|
||||
case 59: return "Couldn't use specified SSL cipher.";
|
||||
case 60: return "Peer SSL certificate or SSH fingerprint verification failed.";
|
||||
case 61: return "Unrecognized transfer encoding.";
|
||||
case 63: return "Maximum file size exceeded.";
|
||||
case 64: return "Requested FTP SSL level failed.";
|
||||
case 65: return "Send failed to rewind.";
|
||||
case 66: return "SSL engine initialization failed.";
|
||||
case 67: return "Login denied.";
|
||||
case 68: return "TFTP file not found.";
|
||||
case 69: return "TFTP permission problem.";
|
||||
case 70: return "Remote disk full.";
|
||||
case 71: return "TFTP illegal operation.";
|
||||
case 72: return "TFTP unknown transfer ID.";
|
||||
case 73: return "Remote file already exists.";
|
||||
case 74: return "TFTP no such user.";
|
||||
case 77: return "Problem reading SSL CA cert.";
|
||||
case 78: return "Remote file not found.";
|
||||
case 79: return "SSH error.";
|
||||
case 80: return "SSL shutdown failed.";
|
||||
case 82: return "Failed to load CRL file.";
|
||||
case 83: return "SSL issuer check failed.";
|
||||
case 84: return "FTP PRET command failed.";
|
||||
case 85: return "RTSP CSeq mismatch.";
|
||||
case 86: return "RTSP session ID mismatch.";
|
||||
case 87: return "Unable to parse FTP file list.";
|
||||
case 88: return "Chunk callback error.";
|
||||
case 89: return "No connection available.";
|
||||
case 90: return "SSL pinned public key mismatch.";
|
||||
case 91: return "SSL invalid certificate status.";
|
||||
case 92: return "HTTP/2 stream error.";
|
||||
case 93: return "Recursive API call.";
|
||||
case 94: return "Authentication error.";
|
||||
case 95: return "HTTP/3 error.";
|
||||
case 96: return "QUIC connection error.";
|
||||
case 97: return "Proxy handshake error.";
|
||||
case 98: return "SSL client certificate required.";
|
||||
case 99: return "Unrecoverable poll error.";
|
||||
case 100: return "Value or data field too large.";
|
||||
case 101: return "ECH failed.";
|
||||
case 0:
|
||||
return "OK"; // CURLE_OK - All fine
|
||||
case 1:
|
||||
return "Unsupported protocol.";
|
||||
case 2:
|
||||
return "Failed to initialize.";
|
||||
case 3:
|
||||
return "URL malformed.";
|
||||
case 4:
|
||||
return "Feature not built-in.";
|
||||
case 5:
|
||||
return "Couldn't resolve proxy.";
|
||||
case 6:
|
||||
return "Couldn't resolve host.";
|
||||
case 7:
|
||||
return "Failed to connect to host.";
|
||||
case 8:
|
||||
return "Weird server reply.";
|
||||
case 9:
|
||||
return "Access denied to remote resource.";
|
||||
case 10:
|
||||
return "FTP accept failed.";
|
||||
case 11:
|
||||
return "FTP weird PASS reply.";
|
||||
case 12:
|
||||
return "FTP accept timeout.";
|
||||
case 13:
|
||||
return "FTP weird PASV reply.";
|
||||
case 14:
|
||||
return "FTP weird 227 format.";
|
||||
case 15:
|
||||
return "FTP can't get host.";
|
||||
case 16:
|
||||
return "HTTP/2 error.";
|
||||
case 17:
|
||||
return "FTP couldn't set type.";
|
||||
case 18:
|
||||
return "Partial file transfer.";
|
||||
case 19:
|
||||
return "FTP couldn't retrieve file.";
|
||||
case 21:
|
||||
return "FTP quote error.";
|
||||
case 22:
|
||||
return "HTTP page not retrieved.";
|
||||
case 23:
|
||||
return "Write error.";
|
||||
case 25:
|
||||
return "Upload failed.";
|
||||
case 26:
|
||||
return "Read error.";
|
||||
case 27:
|
||||
return "Out of memory.";
|
||||
case 28:
|
||||
return "Operation timeout.";
|
||||
case 30:
|
||||
return "FTP PORT failed.";
|
||||
case 31:
|
||||
return "FTP couldn't use REST.";
|
||||
case 33:
|
||||
return "Range error.";
|
||||
case 35:
|
||||
return "SSL connect error.";
|
||||
case 36:
|
||||
return "Bad download resume.";
|
||||
case 37:
|
||||
return "FILE couldn't read file.";
|
||||
case 38:
|
||||
return "LDAP cannot bind.";
|
||||
case 39:
|
||||
return "LDAP search failed.";
|
||||
case 42:
|
||||
return "Aborted by callback.";
|
||||
case 43:
|
||||
return "Bad function argument.";
|
||||
case 45:
|
||||
return "Interface failed.";
|
||||
case 47:
|
||||
return "Too many redirects.";
|
||||
case 48:
|
||||
return "Unknown option.";
|
||||
case 49:
|
||||
return "Setopt option syntax error.";
|
||||
case 52:
|
||||
return "Got nothing from server.";
|
||||
case 53:
|
||||
return "SSL engine not found.";
|
||||
case 54:
|
||||
return "SSL engine set failed.";
|
||||
case 55:
|
||||
return "Failed sending network data.";
|
||||
case 56:
|
||||
return "Failure receiving network data.";
|
||||
case 58:
|
||||
return "Problem with local client certificate.";
|
||||
case 59:
|
||||
return "Couldn't use specified SSL cipher.";
|
||||
case 60:
|
||||
return "Peer SSL certificate or SSH fingerprint verification failed.";
|
||||
case 61:
|
||||
return "Unrecognized transfer encoding.";
|
||||
case 63:
|
||||
return "Maximum file size exceeded.";
|
||||
case 64:
|
||||
return "Requested FTP SSL level failed.";
|
||||
case 65:
|
||||
return "Send failed to rewind.";
|
||||
case 66:
|
||||
return "SSL engine initialization failed.";
|
||||
case 67:
|
||||
return "Login denied.";
|
||||
case 68:
|
||||
return "TFTP file not found.";
|
||||
case 69:
|
||||
return "TFTP permission problem.";
|
||||
case 70:
|
||||
return "Remote disk full.";
|
||||
case 71:
|
||||
return "TFTP illegal operation.";
|
||||
case 72:
|
||||
return "TFTP unknown transfer ID.";
|
||||
case 73:
|
||||
return "Remote file already exists.";
|
||||
case 74:
|
||||
return "TFTP no such user.";
|
||||
case 77:
|
||||
return "Problem reading SSL CA cert.";
|
||||
case 78:
|
||||
return "Remote file not found.";
|
||||
case 79:
|
||||
return "SSH error.";
|
||||
case 80:
|
||||
return "SSL shutdown failed.";
|
||||
case 82:
|
||||
return "Failed to load CRL file.";
|
||||
case 83:
|
||||
return "SSL issuer check failed.";
|
||||
case 84:
|
||||
return "FTP PRET command failed.";
|
||||
case 85:
|
||||
return "RTSP CSeq mismatch.";
|
||||
case 86:
|
||||
return "RTSP session ID mismatch.";
|
||||
case 87:
|
||||
return "Unable to parse FTP file list.";
|
||||
case 88:
|
||||
return "Chunk callback error.";
|
||||
case 89:
|
||||
return "No connection available.";
|
||||
case 90:
|
||||
return "SSL pinned public key mismatch.";
|
||||
case 91:
|
||||
return "SSL invalid certificate status.";
|
||||
case 92:
|
||||
return "HTTP/2 stream error.";
|
||||
case 93:
|
||||
return "Recursive API call.";
|
||||
case 94:
|
||||
return "Authentication error.";
|
||||
case 95:
|
||||
return "HTTP/3 error.";
|
||||
case 96:
|
||||
return "QUIC connection error.";
|
||||
case 97:
|
||||
return "Proxy handshake error.";
|
||||
case 98:
|
||||
return "SSL client certificate required.";
|
||||
case 99:
|
||||
return "Unrecoverable poll error.";
|
||||
case 100:
|
||||
return "Value or data field too large.";
|
||||
case 101:
|
||||
return "ECH failed.";
|
||||
default:
|
||||
return SS("Failed with exit code " << exitCode << ".");
|
||||
}
|
||||
@@ -155,7 +240,8 @@ namespace pipedal
|
||||
|
||||
static void throwCurlExitCode(int exitCode)
|
||||
{
|
||||
if (exitCode == 0) return;
|
||||
if (exitCode == 0)
|
||||
return;
|
||||
std::string message = getCurlExitCodeString(exitCode);
|
||||
throw std::runtime_error(SS("Server download failed. " << message));
|
||||
}
|
||||
@@ -167,48 +253,57 @@ namespace pipedal
|
||||
throw std::runtime_error("Download failed. Invalid curl response: no headers.");
|
||||
}
|
||||
|
||||
const std::string&httpResponse = headers[0];
|
||||
// verify that the string starts with HTTP/, and then parse the numeric error code in the result, throwing a std::runtime_error if it doesn't parse.
|
||||
// e.g. "HTTP/2 200"
|
||||
if (!httpResponse.starts_with("HTTP/"))
|
||||
{
|
||||
throw std::runtime_error(SS("Download failed. Invalid curl response: " << httpResponse));
|
||||
}
|
||||
int returnCode = 0;
|
||||
|
||||
size_t codeStart = httpResponse.find(' ');
|
||||
if (codeStart == std::string::npos)
|
||||
for (const auto &httpResponse : headers)
|
||||
{
|
||||
throw std::runtime_error(SS("Download failed. Invalid curl response: " << httpResponse));
|
||||
}
|
||||
++codeStart;
|
||||
if (httpResponse.starts_with("HTTP/"))
|
||||
{
|
||||
size_t codeStart = httpResponse.find(' ');
|
||||
if (codeStart == std::string::npos)
|
||||
{
|
||||
throw std::runtime_error(SS("Download failed. Invalid curl response: " << httpResponse));
|
||||
}
|
||||
++codeStart;
|
||||
|
||||
size_t codeEnd = httpResponse.find(' ', codeStart);
|
||||
if (codeEnd == std::string::npos)
|
||||
{
|
||||
codeEnd = httpResponse.length();
|
||||
}
|
||||
size_t codeEnd = httpResponse.find(' ', codeStart);
|
||||
if (codeEnd == std::string::npos)
|
||||
{
|
||||
codeEnd = httpResponse.length();
|
||||
}
|
||||
|
||||
int statusCode = 0;
|
||||
try
|
||||
{
|
||||
statusCode = std::stoi(httpResponse.substr(codeStart, codeEnd - codeStart));
|
||||
int statusCode = 0;
|
||||
try
|
||||
{
|
||||
statusCode = std::stoi(httpResponse.substr(codeStart, codeEnd - codeStart));
|
||||
if (statusCode >= 200 && statusCode < 300) // a completion code.
|
||||
{
|
||||
return statusCode;
|
||||
}
|
||||
if (statusCode >= 400) // an error
|
||||
{
|
||||
return statusCode;
|
||||
}
|
||||
// otherwise it's a redirect of some kind, keep going.
|
||||
returnCode = statusCode; // return the last one if there's not 200 or 400+ result.
|
||||
}
|
||||
catch (const std::exception &)
|
||||
{
|
||||
// ignored. Weird. but ignored.
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (const std::exception &)
|
||||
{
|
||||
throw std::runtime_error(SS("Download failed. Invalid curl response: " << httpResponse));
|
||||
}
|
||||
|
||||
return statusCode;
|
||||
return returnCode;
|
||||
}
|
||||
|
||||
|
||||
int CurlGet(
|
||||
const std::string &url,
|
||||
std::vector<uint8_t> &output,
|
||||
std::vector<std::string> *headersOpt
|
||||
) {
|
||||
TemporaryFile tempFile { WEB_TEMP_DIR};
|
||||
int rc = CurlGet(url,tempFile.Path(), headersOpt);
|
||||
std::vector<std::string> *outputHeadersOpt,
|
||||
const std::vector<std::string> *inputHeadersOpt)
|
||||
{
|
||||
TemporaryFile tempFile{WEB_TEMP_DIR};
|
||||
int rc = CurlGet(url, tempFile.Path(), outputHeadersOpt, inputHeadersOpt);
|
||||
if (rc == 200)
|
||||
{
|
||||
std::ifstream inputStream(tempFile.Path(), std::ios::binary);
|
||||
@@ -226,16 +321,15 @@ namespace pipedal
|
||||
}
|
||||
}
|
||||
return rc;
|
||||
|
||||
}
|
||||
int CurlGet(
|
||||
const std::string&url,
|
||||
const std::string &url,
|
||||
std::vector<std::string> &output,
|
||||
std::vector<std::string>*headersOpt
|
||||
)
|
||||
std::vector<std::string> *outputHeadersOpt,
|
||||
const std::vector<std::string> *inputHeadersOpt)
|
||||
{
|
||||
TemporaryFile tempFile {WEB_TEMP_DIR};
|
||||
int rc = CurlGet(url,tempFile.Path(), headersOpt);
|
||||
TemporaryFile tempFile{WEB_TEMP_DIR};
|
||||
int rc = CurlGet(url, tempFile.Path(), outputHeadersOpt, inputHeadersOpt);
|
||||
|
||||
std::ifstream inputStream(tempFile.Path(), std::ios::binary);
|
||||
if (!inputStream)
|
||||
@@ -253,36 +347,48 @@ namespace pipedal
|
||||
output.push_back(line);
|
||||
}
|
||||
return rc;
|
||||
|
||||
}
|
||||
|
||||
|
||||
int CurlGet(
|
||||
const std::string &url,
|
||||
const std::filesystem::path&outputPath,
|
||||
std::vector<std::string> *headersOpt
|
||||
)
|
||||
const std::filesystem::path &outputPath,
|
||||
std::vector<std::string> *outputHeadersOpt,
|
||||
const std::vector<std::string> *inputHeadersOpt)
|
||||
{
|
||||
|
||||
TemporaryFile headersFile { WEB_TEMP_DIR};
|
||||
TemporaryFile headersFile{WEB_TEMP_DIR};
|
||||
|
||||
std::vector<std::string> defaultHeaders;
|
||||
if (headersOpt == nullptr)
|
||||
if (outputHeadersOpt == nullptr)
|
||||
{
|
||||
headersOpt = &defaultHeaders;
|
||||
outputHeadersOpt = &defaultHeaders;
|
||||
}
|
||||
|
||||
bool bResult = true;
|
||||
|
||||
std::string args = SS("-s -L -D " << ShellEscape(headersFile.Path().c_str()) << " " << ShellEscape(url) << " -o " << ShellEscape(outputPath.c_str()) ) ;
|
||||
std::stringstream ssArgs;
|
||||
|
||||
if (inputHeadersOpt)
|
||||
{
|
||||
for (const std::string &header : *inputHeadersOpt)
|
||||
{
|
||||
ssArgs << "-H " << ShellEscape(header) << " ";
|
||||
}
|
||||
}
|
||||
|
||||
ssArgs << "-s -L -D " << ShellEscape(headersFile.Path().c_str())
|
||||
<< " " << ShellEscape(url)
|
||||
<< " -o " << ShellEscape(outputPath.c_str());
|
||||
|
||||
std::string args = ssArgs.str();
|
||||
auto curlOutput = sysExecForOutput("/usr/bin/curl", args);
|
||||
|
||||
if (headersOpt != nullptr)
|
||||
if (outputHeadersOpt != nullptr)
|
||||
{
|
||||
std::ifstream headersStream(headersFile.Path());
|
||||
if (headersStream)
|
||||
{
|
||||
headersOpt->clear();
|
||||
outputHeadersOpt->clear();
|
||||
std::string line;
|
||||
while (std::getline(headersStream, line))
|
||||
{
|
||||
@@ -293,13 +399,13 @@ namespace pipedal
|
||||
}
|
||||
if (!line.empty())
|
||||
{
|
||||
headersOpt->push_back(line);
|
||||
outputHeadersOpt->push_back(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logHeaders(*headersOpt);
|
||||
logHeaders(*outputHeadersOpt);
|
||||
if (curlOutput.exitCode == 512)
|
||||
{
|
||||
throw std::runtime_error("Invalid curl arguments.");
|
||||
@@ -322,37 +428,36 @@ namespace pipedal
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
int errorCode = checkCurlHttpResponse(*headersOpt);
|
||||
|
||||
int errorCode = checkCurlHttpResponse(*outputHeadersOpt);
|
||||
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
|
||||
static int curlExec(
|
||||
const std::string&commandPath, // path to executable.
|
||||
const std::string &commandPath, // path to executable.
|
||||
const std::vector<std::string> &arguments, // commandline arguments.
|
||||
const std::function<bool (const std::string &string)> &onStdoutLine,
|
||||
std::string&stdErrOutput
|
||||
)
|
||||
const std::function<bool(const std::string &string)> &onStdoutLine,
|
||||
std::string &stdErrOutput)
|
||||
{
|
||||
// Unexpected errors throw std::runtime_error.
|
||||
|
||||
// create linux socket pairs for stdout and stderr.
|
||||
int stdoutPipe[2] = {-1,-1};
|
||||
int stderrPipe[2] = {-1,-1};
|
||||
// create linux socket pairs for stdout and stderr.
|
||||
int stdoutPipe[2] = {-1, -1};
|
||||
int stderrPipe[2] = {-1, -1};
|
||||
|
||||
Finally ffPipes {
|
||||
[&stdoutPipe, &stderrPipe]()
|
||||
Finally ffPipes{
|
||||
[&stdoutPipe, &stderrPipe]()
|
||||
{
|
||||
if (stdoutPipe[0] != -1) close(stdoutPipe[0]);
|
||||
if (stdoutPipe[1] != -1) close(stdoutPipe[1]);
|
||||
if (stderrPipe[0] != -1) close(stdoutPipe[0]);
|
||||
if (stderrPipe[1] != -1) close(stdoutPipe[1]);
|
||||
}
|
||||
};
|
||||
|
||||
if (stdoutPipe[0] != -1)
|
||||
close(stdoutPipe[0]);
|
||||
if (stdoutPipe[1] != -1)
|
||||
close(stdoutPipe[1]);
|
||||
if (stderrPipe[0] != -1)
|
||||
close(stdoutPipe[0]);
|
||||
if (stderrPipe[1] != -1)
|
||||
close(stdoutPipe[1]);
|
||||
}};
|
||||
|
||||
if (socketpair(AF_UNIX, SOCK_STREAM, 0, stdoutPipe) == -1)
|
||||
{
|
||||
throw std::runtime_error("Failed to create stdout socket pair");
|
||||
@@ -368,21 +473,20 @@ namespace pipedal
|
||||
{
|
||||
throw std::runtime_error("Failed to fork process");
|
||||
}
|
||||
|
||||
|
||||
if (pid == 0)
|
||||
{
|
||||
// Child process
|
||||
close(stdoutPipe[0]); // Close read end
|
||||
close(stderrPipe[0]); // Close read end
|
||||
|
||||
|
||||
// Redirect stdout and stderr
|
||||
dup2(stdoutPipe[1], STDOUT_FILENO);
|
||||
dup2(stderrPipe[1], STDERR_FILENO);
|
||||
|
||||
|
||||
close(stdoutPipe[1]);
|
||||
close(stderrPipe[1]);
|
||||
|
||||
|
||||
// stdin to /dev/null.
|
||||
int devNull = open("/dev/null", O_RDONLY);
|
||||
if (devNull == -1)
|
||||
@@ -398,49 +502,48 @@ namespace pipedal
|
||||
}
|
||||
close(devNull);
|
||||
|
||||
|
||||
// Build argv array
|
||||
std::vector<char*> argv;
|
||||
argv.push_back(const_cast<char*>(commandPath.c_str()));
|
||||
for (const auto& arg : arguments)
|
||||
std::vector<char *> argv;
|
||||
argv.push_back(const_cast<char *>(commandPath.c_str()));
|
||||
for (const auto &arg : arguments)
|
||||
{
|
||||
argv.push_back(const_cast<char*>(arg.c_str()));
|
||||
argv.push_back(const_cast<char *>(arg.c_str()));
|
||||
}
|
||||
argv.push_back(nullptr);
|
||||
|
||||
|
||||
execv(commandPath.c_str(), argv.data());
|
||||
|
||||
|
||||
// If execv returns, it failed
|
||||
std::cerr << "Failed to execute: " << commandPath << std::endl;
|
||||
exit(EXIT_FAILURE);
|
||||
}
|
||||
|
||||
|
||||
// Parent process
|
||||
close(stdoutPipe[1]); // Close write end
|
||||
stdoutPipe[1] = -1;
|
||||
close(stderrPipe[1]); // Close write end
|
||||
stderrPipe[1] = -1;
|
||||
|
||||
// Read from stderr and stdout.
|
||||
// stderr output gets appended to the stdErrOutput argument.
|
||||
// Read from stderr and stdout.
|
||||
// stderr output gets appended to the stdErrOutput argument.
|
||||
// stdin output gets read line by line, and onStdOutLine is called for each line of text that's read.
|
||||
|
||||
|
||||
// Set non-blocking mode for both pipes
|
||||
fcntl(stdoutPipe[0], F_SETFL, O_NONBLOCK);
|
||||
fcntl(stderrPipe[0], F_SETFL, O_NONBLOCK);
|
||||
|
||||
|
||||
std::string stdoutBuffer;
|
||||
std::string stderrBuffer;
|
||||
|
||||
|
||||
bool stdoutOpen = true;
|
||||
bool stderrOpen = true;
|
||||
|
||||
|
||||
while (stdoutOpen || stderrOpen)
|
||||
{
|
||||
fd_set readfds;
|
||||
FD_ZERO(&readfds);
|
||||
int maxfd = -1;
|
||||
|
||||
|
||||
if (stdoutOpen)
|
||||
{
|
||||
FD_SET(stdoutPipe[0], &readfds);
|
||||
@@ -451,13 +554,13 @@ namespace pipedal
|
||||
FD_SET(stderrPipe[0], &readfds);
|
||||
maxfd = std::max(maxfd, stderrPipe[0]);
|
||||
}
|
||||
|
||||
|
||||
struct timeval timeout;
|
||||
timeout.tv_sec = 1;
|
||||
timeout.tv_usec = 0;
|
||||
|
||||
|
||||
int ret = select(maxfd + 1, &readfds, nullptr, nullptr, &timeout);
|
||||
|
||||
|
||||
if (ret > 0)
|
||||
{
|
||||
// Read from stdout
|
||||
@@ -468,7 +571,7 @@ namespace pipedal
|
||||
if (n > 0)
|
||||
{
|
||||
stdoutBuffer.append(buffer, n);
|
||||
|
||||
|
||||
// Process complete lines
|
||||
size_t pos;
|
||||
while ((pos = stdoutBuffer.find('\n')) != std::string::npos)
|
||||
@@ -491,7 +594,7 @@ namespace pipedal
|
||||
{
|
||||
stdoutOpen = false;
|
||||
close(stdoutPipe[0]);
|
||||
|
||||
|
||||
// Process any remaining data
|
||||
if (!stdoutBuffer.empty())
|
||||
{
|
||||
@@ -503,7 +606,7 @@ namespace pipedal
|
||||
{
|
||||
if (!onStdoutLine(stdoutBuffer))
|
||||
{
|
||||
// abort the child process
|
||||
// abort the child process
|
||||
kill(pid, SIGINT); // Curl: SIGINT= graceful shutdown. SIGTERM=abrupt shutdown.
|
||||
// but wait for output conforming the cancellation
|
||||
// i.e. don't waitpid -- that will be done on cleanup, and don't abort the loop.
|
||||
@@ -512,7 +615,7 @@ namespace pipedal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Read from stderr
|
||||
if (stderrOpen && FD_ISSET(stderrPipe[0], &readfds))
|
||||
{
|
||||
@@ -531,14 +634,15 @@ namespace pipedal
|
||||
}
|
||||
}
|
||||
|
||||
// Return the procesess's exit code.
|
||||
// Return the procesess's exit code.
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
|
||||
|
||||
if (WIFEXITED(status))
|
||||
{
|
||||
auto exitCode = WEXITSTATUS(status);
|
||||
if (exitCode == EXIT_SUCCESS) {
|
||||
if (exitCode == EXIT_SUCCESS)
|
||||
{
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
throwCurlExitCode(exitCode);
|
||||
@@ -548,14 +652,13 @@ namespace pipedal
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
return -2;
|
||||
}
|
||||
int CurlGet(
|
||||
const std::vector<CurlDownloadRequest> &request,
|
||||
const std::vector<CurlDownloadRequest> &request,
|
||||
const std::function<void(size_t completed, size_t total)> &progressCallback,
|
||||
std::vector<std::string>*headersOpt
|
||||
)
|
||||
std::vector<std::string> *outputHeadersOpt)
|
||||
{
|
||||
if (request.empty())
|
||||
{
|
||||
@@ -565,10 +668,10 @@ namespace pipedal
|
||||
size_t currentDownload = 0;
|
||||
size_t totalDownloads = request.size();
|
||||
int lastStatusCode = 200;
|
||||
|
||||
|
||||
// Create temporary file for curl config
|
||||
TemporaryFile configFile{WEB_TEMP_DIR};
|
||||
|
||||
|
||||
// Write curl config file with URL and output pairs
|
||||
{
|
||||
std::ofstream configStream(configFile.Path());
|
||||
@@ -576,24 +679,24 @@ namespace pipedal
|
||||
{
|
||||
throw std::runtime_error("Failed to create curl config file");
|
||||
}
|
||||
|
||||
for (const auto& req : request)
|
||||
|
||||
for (const auto &req : request)
|
||||
{
|
||||
configStream << "url = \"" << req.url << "\"\n";
|
||||
configStream << "output = \"" << req.outputFile.string() << "\"\n";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Build curl arguments
|
||||
std::vector<std::string> curlArgs;
|
||||
curlArgs.push_back("-s"); // Silent mode
|
||||
curlArgs.push_back("-L"); // Follow redirects
|
||||
curlArgs.push_back("-s"); // Silent mode
|
||||
curlArgs.push_back("-L"); // Follow redirects
|
||||
// curlArgs.push_back("-w"); // Write format
|
||||
// curlArgs.push_back("URL: %{url_effective}\\n");
|
||||
// curlArgs.push_back("URL: %{url_effective}\\n");
|
||||
curlArgs.push_back("--parallel-max");
|
||||
curlArgs.push_back("1");
|
||||
curlArgs.push_back("-D"); // Dump headers to stdout
|
||||
curlArgs.push_back("-"); // Write headers to stdout
|
||||
curlArgs.push_back("-D"); // Dump headers to stdout
|
||||
curlArgs.push_back("-"); // Write headers to stdout
|
||||
curlArgs.push_back("--no-progress-meter"); // Suppress progress
|
||||
curlArgs.push_back("--retry-delay");
|
||||
curlArgs.push_back("2");
|
||||
@@ -602,16 +705,17 @@ namespace pipedal
|
||||
curlArgs.push_back("-K");
|
||||
curlArgs.push_back(configFile.Path().string());
|
||||
curlArgs.push_back("--fail-early"); // quit as soon as one transfer fails.
|
||||
|
||||
|
||||
std::string stderrOutput;
|
||||
|
||||
std::string savedHttpHeader;
|
||||
|
||||
|
||||
// Process stdout lines
|
||||
auto onStdoutLine = [&](const std::string& line) -> bool {
|
||||
if (headersOpt != nullptr)
|
||||
auto onStdoutLine = [&](const std::string &line) -> bool
|
||||
{
|
||||
if (outputHeadersOpt != nullptr)
|
||||
{
|
||||
headersOpt->push_back(line);
|
||||
outputHeadersOpt->push_back(line);
|
||||
}
|
||||
// Empty line indicates start of new download or end of headers
|
||||
if (line.empty())
|
||||
@@ -630,12 +734,12 @@ namespace pipedal
|
||||
{
|
||||
codeEnd = lastHeader.length();
|
||||
}
|
||||
|
||||
|
||||
std::string codeStr = lastHeader.substr(codeStart, codeEnd - codeStart);
|
||||
try
|
||||
{
|
||||
int statusCode = std::stoi(codeStr);
|
||||
|
||||
|
||||
// Handle different status codes
|
||||
if (statusCode == 200)
|
||||
{
|
||||
@@ -656,8 +760,8 @@ namespace pipedal
|
||||
{
|
||||
// Throttling - save position and expect exit
|
||||
lastStatusCode = 503;
|
||||
Lv2Log::warning("%s","curl: Received 503 response.");
|
||||
return false;
|
||||
Lv2Log::warning("%s", "curl: Received 503 response.");
|
||||
return false;
|
||||
}
|
||||
else if (statusCode >= 300 && statusCode < 400)
|
||||
{
|
||||
@@ -671,14 +775,15 @@ namespace pipedal
|
||||
return false; // Stop processing
|
||||
}
|
||||
}
|
||||
catch (const std::exception&)
|
||||
catch (const std::exception &)
|
||||
{
|
||||
// Failed to parse status code - continue
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (line.starts_with("HTTP/"))
|
||||
}
|
||||
else if (line.starts_with("HTTP/"))
|
||||
{
|
||||
// Delay processing until we get a blank line.
|
||||
savedHttpHeader = line;
|
||||
@@ -686,21 +791,137 @@ namespace pipedal
|
||||
// Other lines are headers - ignore unless we need to save them
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
// Execute curl
|
||||
int exitCode = curlExec("/usr/bin/curl", curlArgs, onStdoutLine, stderrOutput);
|
||||
|
||||
logHeaders(*headersOpt);
|
||||
logHeaders(*outputHeadersOpt);
|
||||
|
||||
if (exitCode != EXIT_SUCCESS && exitCode != -1)
|
||||
{
|
||||
throwCurlExitCode(exitCode);
|
||||
}
|
||||
|
||||
|
||||
// Return appropriate status code
|
||||
|
||||
|
||||
return lastStatusCode;
|
||||
}
|
||||
|
||||
}
|
||||
int CurlPostFile(
|
||||
const std::string &url,
|
||||
const std::filesystem::path &inputPath,
|
||||
const std::filesystem::path &outputPath,
|
||||
std::vector<std::string> *outputHeadersOpt,
|
||||
std::vector<std::string> *inputHeadersOpt)
|
||||
{
|
||||
TemporaryFile headersFile{WEB_TEMP_DIR};
|
||||
|
||||
std::vector<std::string> defaultHeaders;
|
||||
if (outputHeadersOpt == nullptr)
|
||||
{
|
||||
outputHeadersOpt = &defaultHeaders;
|
||||
}
|
||||
|
||||
bool bResult = true;
|
||||
|
||||
std::stringstream ssArgs;
|
||||
|
||||
if (inputHeadersOpt)
|
||||
{
|
||||
for (const std::string &header : *inputHeadersOpt)
|
||||
{
|
||||
ssArgs << "-H " << ShellEscape(header) << " ";
|
||||
}
|
||||
}
|
||||
ssArgs << "-s -L -X POST --data-binary "
|
||||
<< SS("@" << inputPath.c_str()).c_str()
|
||||
<< " -D " << ShellEscape(headersFile.Path().c_str());
|
||||
|
||||
std::string args = ssArgs.str();
|
||||
|
||||
|
||||
args += SS(" " << ShellEscape(url)
|
||||
<< " -o " << ShellEscape(outputPath.c_str()));
|
||||
|
||||
auto curlOutput = sysExecForOutput("/usr/bin/curl", args);
|
||||
|
||||
if (outputHeadersOpt != nullptr)
|
||||
{
|
||||
std::ifstream headersStream(headersFile.Path());
|
||||
if (headersStream)
|
||||
{
|
||||
outputHeadersOpt->clear();
|
||||
std::string line;
|
||||
while (std::getline(headersStream, line))
|
||||
{
|
||||
// Remove trailing carriage return if present
|
||||
if (!line.empty() && line.back() == '\r')
|
||||
{
|
||||
line.pop_back();
|
||||
}
|
||||
if (!line.empty())
|
||||
{
|
||||
outputHeadersOpt->push_back(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logHeaders(*outputHeadersOpt);
|
||||
if (curlOutput.exitCode == 512)
|
||||
{
|
||||
throw std::runtime_error("Invalid curl arguments.");
|
||||
}
|
||||
if (curlOutput.exitCode != EXIT_SUCCESS && curlOutput.exitCode != 22 * 256)
|
||||
{
|
||||
if (WIFEXITED(curlOutput.exitCode))
|
||||
{
|
||||
auto exitCode = WEXITSTATUS(curlOutput.exitCode);
|
||||
throwCurlExitCode(exitCode);
|
||||
return -99;
|
||||
}
|
||||
else if (WIFSIGNALED(curlOutput.exitCode))
|
||||
{
|
||||
throw std::runtime_error("No internet access.");
|
||||
}
|
||||
else
|
||||
{
|
||||
throw std::runtime_error(SS("Unable to exec curl. (exit status = " << curlOutput.exitCode << ")"));
|
||||
}
|
||||
}
|
||||
|
||||
int errorCode = checkCurlHttpResponse(*outputHeadersOpt);
|
||||
|
||||
return errorCode;
|
||||
}
|
||||
|
||||
int CurlPostStrings(
|
||||
const std::string &url,
|
||||
const std::string &body,
|
||||
std::string &outputBody,
|
||||
std::vector<std::string> *outputHeadersOpt,
|
||||
std::vector<std::string> *inputHeadersOpt)
|
||||
{
|
||||
TemporaryFile inputFile(WEB_TEMP_DIR);
|
||||
{
|
||||
std::ofstream f{inputFile.Path()};
|
||||
if (!f.is_open())
|
||||
{
|
||||
throw std::runtime_error(SS("Can't open file " << inputFile.Path()));
|
||||
}
|
||||
f << body;
|
||||
}
|
||||
TemporaryFile outputFile(WEB_TEMP_DIR);
|
||||
int result = CurlPostFile(url, inputFile.Path(), outputFile.Path(), outputHeadersOpt, inputHeadersOpt);
|
||||
|
||||
std::ifstream outputStream(outputFile.Path());
|
||||
if (outputStream)
|
||||
{
|
||||
outputBody.assign(
|
||||
std::istreambuf_iterator<char>(outputStream),
|
||||
std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
+25
-4
@@ -34,17 +34,21 @@ namespace pipedal {
|
||||
extern int CurlGet(
|
||||
const std::string &url,
|
||||
const std::filesystem::path&outputFile,
|
||||
std::vector<std::string> *headersOpt = nullptr
|
||||
std::vector<std::string> *outputHeadersOpt = nullptr,
|
||||
const std::vector<std::string> *inputHeadersOpt = nullptr
|
||||
);
|
||||
extern int CurlGet(
|
||||
const std::string &url,
|
||||
std::vector<uint8_t> &output,
|
||||
std::vector<std::string> *headersOpt = nullptr
|
||||
std::vector<std::string> *outputHeadersOpt = nullptr,
|
||||
const std::vector<std::string> *inputHeadersOpt = nullptr
|
||||
|
||||
);
|
||||
extern int CurlGet(
|
||||
const std::string&url,
|
||||
std::vector<std::string> &output,
|
||||
std::vector<std::string> *headersOpt = nullptr
|
||||
std::vector<std::string> *outputHeadersOpt = nullptr,
|
||||
const std::vector<std::string> *inputHeadersOpt = nullptr
|
||||
);
|
||||
|
||||
struct CurlDownloadRequest{
|
||||
@@ -54,7 +58,24 @@ namespace pipedal {
|
||||
extern int CurlGet(
|
||||
const std::vector<CurlDownloadRequest> &request,
|
||||
const std::function<void(size_t completed, size_t total)> &progressCallback,
|
||||
std::vector<std::string>*headersOpt
|
||||
std::vector<std::string>*outputHeadersOpt
|
||||
);
|
||||
|
||||
|
||||
extern int CurlPostFile(
|
||||
const std::string &url,
|
||||
const std::filesystem::path&inputFile,
|
||||
const std::filesystem::path&outputFile,
|
||||
std::vector<std::string> *outputHeadersOpt = nullptr,
|
||||
std::vector<std::string> *inputHeadersOpt = nullptr
|
||||
);
|
||||
extern int CurlPostStrings(
|
||||
const std::string &url,
|
||||
const std::string&body,
|
||||
std::string&responseBody,
|
||||
std::vector<std::string> *outputHeadersOpt = nullptr,
|
||||
std::vector<std::string> *inputHeadersOpt = nullptr
|
||||
);
|
||||
|
||||
|
||||
}
|
||||
+12
-7
@@ -238,16 +238,17 @@ void PiPedalModel::Init(const PiPedalConfiguration &configuration)
|
||||
}
|
||||
|
||||
int64_t PiPedalModel::DownloadModelsFromTone3000(
|
||||
int64_t clientId,
|
||||
Tone3000DownloadType downloadType,
|
||||
const std::string &uri,
|
||||
const Tone3000PkceParams&pckeParams,
|
||||
const std::string &downloadPath,
|
||||
const std::string &tone3000Url)
|
||||
Tone3000DownloadType downloadType
|
||||
)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lock(mutex);
|
||||
|
||||
// Validate the download path
|
||||
std::filesystem::path path{downloadPath};
|
||||
if (!IsInUploadsDirectory(downloadPath))
|
||||
if (!IsInUploadsDirectory(path))
|
||||
{
|
||||
throw PiPedalException("Invalid path: not in uploads directory.");
|
||||
}
|
||||
@@ -266,10 +267,12 @@ int64_t PiPedalModel::DownloadModelsFromTone3000(
|
||||
tone3000Downloader = Tone3000Downloader::Create();
|
||||
tone3000Downloader->SetListener(this);
|
||||
}
|
||||
return tone3000Downloader->RequestDownload(
|
||||
downloadType,
|
||||
return tone3000Downloader->RequestTone3000Download(
|
||||
uri,
|
||||
pckeParams,
|
||||
downloadPath,
|
||||
tone3000Url);
|
||||
downloadType
|
||||
);
|
||||
}
|
||||
|
||||
void PiPedalModel::CancelTone3000Download(
|
||||
@@ -3447,3 +3450,5 @@ std::string PiPedalModel::Tone3000ThumbnailDirectory()
|
||||
{
|
||||
return "/var/pipedal/tone3000_thumbnails";
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
#include "ChannelRouterSettings.hpp"
|
||||
#include <unordered_map>
|
||||
#include "Tone3000Downloader.hpp"
|
||||
#include "Uri.hpp"
|
||||
|
||||
namespace pipedal
|
||||
{
|
||||
@@ -310,10 +311,11 @@ namespace pipedal
|
||||
void PreviousPreset() { NextPreset(Direction::Decrease); }
|
||||
|
||||
int64_t DownloadModelsFromTone3000(
|
||||
int64_t clientId,
|
||||
Tone3000DownloadType downloadType,
|
||||
const std::string&responseuri,
|
||||
const Tone3000PkceParams& pkce,
|
||||
const std::string &downloadPath,
|
||||
const std::string &tone3000Url);
|
||||
Tone3000DownloadType downloadType
|
||||
);
|
||||
|
||||
void CancelTone3000Download(
|
||||
int64_t clientId,
|
||||
|
||||
+283
-291
File diff suppressed because it is too large
Load Diff
+46
-39
@@ -435,7 +435,6 @@ std::filesystem::path Storage::GetCurrentPresetPath() const
|
||||
return this->dataRoot / "currentPreset.json";
|
||||
}
|
||||
|
||||
|
||||
std::filesystem::path Storage::GetChannelSelectionFileName()
|
||||
{
|
||||
return this->dataRoot / "JackChannelSelection.json";
|
||||
@@ -2083,26 +2082,29 @@ static bool ensureNoDotDot(const std::filesystem::path &path)
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool isInfoFile(const std::string &fileName)
|
||||
bool isInfoFile(const fs::path &path)
|
||||
{
|
||||
if (strcasecmp(fileName.c_str(), "license.txt") == 0)
|
||||
auto extension = path.extension();
|
||||
if (extension == ".pdf")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (strcasecmp(fileName.c_str(), "license.md") == 0)
|
||||
auto filename = path.filename();
|
||||
if (filename == "LICENSE.txt" || filename == "README.txt")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (strcasecmp(fileName.c_str(), "readme.txt") == 0)
|
||||
if (filename == "LICENSE.md" || filename == "README.md")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (strcasecmp(fileName.c_str(), "readme.md") == 0)
|
||||
if (filename == "license.txt" || filename == "readme.txt")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool isInfoFile(const FileEntry &l)
|
||||
{
|
||||
return isInfoFile(l.displayName_);
|
||||
@@ -2333,10 +2335,8 @@ FileRequestResult Storage::GetModFileList2(const std::string &relativePath, cons
|
||||
{
|
||||
rootModDirectory = modDirectoryInfo;
|
||||
modDirectoryPath = uploadsDirectory / modDirectoryInfo->pipedalPath;
|
||||
result.breadcrumbs_.push_back({
|
||||
modDirectoryPath.string(),
|
||||
(modDirectoryInfo->displayName)}
|
||||
);
|
||||
result.breadcrumbs_.push_back({modDirectoryPath.string(),
|
||||
(modDirectoryInfo->displayName)});
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -2346,9 +2346,8 @@ FileRequestResult Storage::GetModFileList2(const std::string &relativePath, cons
|
||||
if (IsSubdirectory(fsRelativePath, uploadsDirectory / fileProperty.directory()))
|
||||
{
|
||||
modDirectoryPath = uploadsDirectory / fileProperty.directory();
|
||||
result.breadcrumbs_.push_back({
|
||||
modDirectoryPath.string(),
|
||||
SafeFilenameToString(fs::path(fileProperty.directory()).filename().string())});
|
||||
result.breadcrumbs_.push_back({modDirectoryPath.string(),
|
||||
SafeFilenameToString(fs::path(fileProperty.directory()).filename().string())});
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -2991,8 +2990,6 @@ const PluginPresetIndex &Storage::GetPluginPresetIndex()
|
||||
return pluginPresetIndex;
|
||||
}
|
||||
|
||||
|
||||
|
||||
std::filesystem::path Storage::FromAbstractPathString(const std::string &stringPath)
|
||||
{
|
||||
if (stringPath.empty())
|
||||
@@ -3054,15 +3051,18 @@ ChannelRouterSettings::ptr Storage::LoadChannelRouterSettings()
|
||||
// See UpgradeChannelRouterSettings(), which will upgrade from old settings
|
||||
// or create a default instance.
|
||||
return nullptr; // will be upgraded later.
|
||||
}
|
||||
try {
|
||||
}
|
||||
try
|
||||
{
|
||||
std::ifstream is(path);
|
||||
json_reader reader(is);
|
||||
ChannelRouterSettings::ptr result;
|
||||
reader.read(&result);
|
||||
this->channelSelection = ChannelSelection(*result);
|
||||
return result;
|
||||
} catch (const std::exception &e) {
|
||||
return result;
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
Lv2Log::error("Failed to load Channel Router settings: %s", e.what());
|
||||
return std::make_shared<ChannelRouterSettings>();
|
||||
}
|
||||
@@ -3071,55 +3071,62 @@ ChannelRouterSettings::ptr Storage::LoadChannelRouterSettings()
|
||||
static int64_t GetUpgradedPortIndex(const std::string &portName)
|
||||
{
|
||||
auto nPos = portName.find_last_of('_');
|
||||
if (nPos != std::string::npos)
|
||||
if (nPos != std::string::npos)
|
||||
{
|
||||
std::string channelStr = portName.substr(nPos + 1);
|
||||
try
|
||||
try
|
||||
{
|
||||
int64_t channelIndex = std::stoll(channelStr);
|
||||
return channelIndex;
|
||||
} catch (const std::exception &) {
|
||||
}
|
||||
catch (const std::exception &)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
void Storage::UpgradeChannelRouterSettings()
|
||||
}
|
||||
void Storage::UpgradeChannelRouterSettings()
|
||||
{
|
||||
if (channelRouterSettings == nullptr)
|
||||
if (channelRouterSettings == nullptr)
|
||||
{
|
||||
channelRouterSettings = std::make_shared<ChannelRouterSettings>();
|
||||
if (jackChannelSelection.isValid())
|
||||
if (jackChannelSelection.isValid())
|
||||
{
|
||||
channelRouterSettings->configured(true);
|
||||
channelRouterSettings->mainInputChannels().resize(2);
|
||||
const std::vector<std::string>& oldInputs = jackChannelSelection.GetInputAudioPorts();
|
||||
std::vector<int64_t>& newInputs = channelRouterSettings->mainInputChannels();
|
||||
const std::vector<std::string> &oldInputs = jackChannelSelection.GetInputAudioPorts();
|
||||
std::vector<int64_t> &newInputs = channelRouterSettings->mainInputChannels();
|
||||
|
||||
if (oldInputs.size() ==1)
|
||||
if (oldInputs.size() == 1)
|
||||
{
|
||||
int64_t leftIndex = GetUpgradedPortIndex(oldInputs[0]);
|
||||
newInputs.resize(2);
|
||||
newInputs[0] = leftIndex;
|
||||
newInputs[1] = leftIndex;
|
||||
} else if (oldInputs.size() == 2) {
|
||||
}
|
||||
else if (oldInputs.size() == 2)
|
||||
{
|
||||
newInputs.resize(2);
|
||||
for (size_t i = 0; i < std::min(oldInputs.size(), newInputs.size()); ++i)
|
||||
for (size_t i = 0; i < std::min(oldInputs.size(), newInputs.size()); ++i)
|
||||
{
|
||||
int64_t portIndex = GetUpgradedPortIndex(oldInputs[i]);
|
||||
newInputs[i] = portIndex;
|
||||
}
|
||||
}
|
||||
const std::vector<std::string>& oldOutputs = jackChannelSelection.GetOutputAudioPorts();
|
||||
const std::vector<std::string> &oldOutputs = jackChannelSelection.GetOutputAudioPorts();
|
||||
auto &newMainOutputs = channelRouterSettings->mainOutputChannels();
|
||||
if (oldOutputs.size() == 1) {
|
||||
if (oldOutputs.size() == 1)
|
||||
{
|
||||
int64_t leftIndex = GetUpgradedPortIndex(oldOutputs[0]);
|
||||
newMainOutputs.resize(2);
|
||||
newMainOutputs[0] = leftIndex;
|
||||
newMainOutputs[1] = leftIndex;
|
||||
} else if (oldOutputs.size() == 2) {
|
||||
}
|
||||
else if (oldOutputs.size() == 2)
|
||||
{
|
||||
newMainOutputs.resize(2);
|
||||
for (size_t i = 0; i < std::min(oldOutputs.size(), newMainOutputs.size()); ++i)
|
||||
for (size_t i = 0; i < std::min(oldOutputs.size(), newMainOutputs.size()); ++i)
|
||||
{
|
||||
int64_t portIndex = GetUpgradedPortIndex(oldOutputs[i]);
|
||||
newMainOutputs[i] = portIndex;
|
||||
@@ -3135,16 +3142,16 @@ void Storage::UpgradeChannelRouterSettings()
|
||||
newAuxOutputs[0] = -1;
|
||||
newAuxOutputs[1] = -1;
|
||||
SaveChannelRouterSettings(channelRouterSettings);
|
||||
}
|
||||
}
|
||||
channelSelection = ChannelSelection(*channelRouterSettings);
|
||||
}
|
||||
}
|
||||
|
||||
const ChannelSelection& Storage::GetChannelSelection() const {
|
||||
const ChannelSelection &Storage::GetChannelSelection() const
|
||||
{
|
||||
return channelSelection;
|
||||
}
|
||||
|
||||
|
||||
JSON_MAP_BEGIN(UserSettings)
|
||||
JSON_MAP_REFERENCE(UserSettings, governor)
|
||||
JSON_MAP_REFERENCE(UserSettings, showStatusMonitor)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Robin E. R. Davies
|
||||
* All rights reserved.
|
||||
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <queue>
|
||||
#include <condition_variable>
|
||||
#include <functional>
|
||||
|
||||
namespace pipedal
|
||||
{
|
||||
template <typename T>
|
||||
class ThreadedQueue
|
||||
{
|
||||
public:
|
||||
~ThreadedQueue() { Close(); }
|
||||
|
||||
void Put(std::shared_ptr<T> value)
|
||||
{
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
if (!closed)
|
||||
{
|
||||
queue.push(value);
|
||||
}
|
||||
}
|
||||
read_cv.notify_one();
|
||||
}
|
||||
std::shared_ptr<T> Take()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
{
|
||||
std::unique_lock lock(mutex);
|
||||
read_cv.wait(lock,
|
||||
[this]() {return !queue.empty() || closed;});
|
||||
if (!queue.empty())
|
||||
{
|
||||
auto result = queue.front();
|
||||
queue.pop();
|
||||
return result;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (closed)
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
void Close()
|
||||
{
|
||||
{
|
||||
std::lock_guard lock{mutex};
|
||||
closed = true;
|
||||
}
|
||||
read_cv.notify_all();
|
||||
}
|
||||
void CloseAndClear()
|
||||
{
|
||||
{
|
||||
std::lock_guard lock{mutex};
|
||||
closed = true;
|
||||
while (!queue.empty())
|
||||
{
|
||||
queue.pop();
|
||||
}
|
||||
}
|
||||
read_cv.notify_all();
|
||||
}
|
||||
void Erase(std::function<bool(std::shared_ptr<T> queueEntry)> predicate)
|
||||
{
|
||||
std::lock_guard lock{mutex};
|
||||
std::queue<std::shared_ptr<T>> newQueue;
|
||||
while (!queue.empty())
|
||||
{
|
||||
auto item = queue.front();
|
||||
queue.pop();
|
||||
if (!predicate(item))
|
||||
{
|
||||
newQueue.push(item);
|
||||
}
|
||||
}
|
||||
queue = newQueue;
|
||||
}
|
||||
|
||||
private:
|
||||
bool closed = 0;
|
||||
std::queue<std::shared_ptr<T>> queue;
|
||||
std::mutex mutex;
|
||||
std::condition_variable read_cv;
|
||||
};
|
||||
}
|
||||
+17
-760
@@ -35,8 +35,13 @@
|
||||
#include "PiPedalModel.hpp"
|
||||
|
||||
using namespace pipedal;
|
||||
using namespace pipedal::tone3000;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
Tone3000Download::Tone3000Download(const std::string &json)
|
||||
{
|
||||
std::istringstream ss(json);
|
||||
json_reader reader(ss);
|
||||
reader.read(this);
|
||||
}
|
||||
|
||||
JSON_MAP_BEGIN(Tone3000Model)
|
||||
JSON_MAP_REFERENCE(Tone3000Model, id)
|
||||
@@ -61,770 +66,22 @@ JSON_MAP_REFERENCE(Tone3000Download, title)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, description)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, created_at)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, updated_at)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, platform)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, gear)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, images)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, is_public)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, links)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, model_count)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, platform)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, models_count)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, favorites_count)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, downloads_count)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, license)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, sizes)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, a1_models_count)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, a2_models_count)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, irs_count)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, custom_models_count)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, user)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, url)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, user)
|
||||
JSON_MAP_REFERENCE(Tone3000Download, models)
|
||||
JSON_MAP_END()
|
||||
|
||||
JSON_MAP_BEGIN(Tone3000DownloadResult)
|
||||
JSON_MAP_REFERENCE(Tone3000DownloadResult, success)
|
||||
JSON_MAP_REFERENCE(Tone3000DownloadResult, errorMessage)
|
||||
JSON_MAP_END()
|
||||
|
||||
static const std::filesystem::path WEB_TEMP_DIR{"/var/pipedal/web_temp"};
|
||||
static const std::filesystem::path TONE3000_THUMBNAIL_PATH{"/var/pipedal/tone3000_thumbnails"};
|
||||
|
||||
static std::string getCurlErrorFromExitCode(int exitCode)
|
||||
{
|
||||
|
||||
switch (exitCode)
|
||||
{
|
||||
case 3 * 256: // malformed URL
|
||||
return "Invalid URL.";
|
||||
case 6 * 256: // could not resolve hostname
|
||||
return "Could not resolve hostname. Check that the PiPedal server is connected to the Internet.";
|
||||
case 7 * 256: // failed to connect to host
|
||||
return "Failed to connect to host.";
|
||||
case 22 * 256: // HTTP error
|
||||
return "HTTP error downloading file.";
|
||||
case 28 * 256: // timeout
|
||||
return "Download timeout.";
|
||||
case 63 * 256: // file too large
|
||||
return "File too large.";
|
||||
default:
|
||||
return SS("Failed to download file (exit code: " << (exitCode / 256) << ")");
|
||||
}
|
||||
}
|
||||
|
||||
static void cancellableSleep(std::chrono::steady_clock::duration duration, std::function<bool()> &isCancelled)
|
||||
{
|
||||
std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now();
|
||||
while (true)
|
||||
{
|
||||
if (std::chrono::steady_clock::now() - start >= duration)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if (isCancelled())
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
}
|
||||
|
||||
static std::string mdSanitize(const std::string &str, const std::string &illegalCharacters = "")
|
||||
{
|
||||
std::ostringstream os;
|
||||
for (char c : str)
|
||||
{
|
||||
if ((unsigned char)c < 0x20)
|
||||
continue;
|
||||
if (c == '<')
|
||||
{
|
||||
os << "<";
|
||||
continue;
|
||||
}
|
||||
auto npos = illegalCharacters.find_first_of(c);
|
||||
if (npos != std::string::npos)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
os << c;
|
||||
}
|
||||
return os.str();
|
||||
}
|
||||
|
||||
static std::string mdDate(const tone3000_time_point &date_)
|
||||
{
|
||||
std::ostringstream os;
|
||||
// C++ doesn't handle timezones. Just zap the timezone, and replace it with "Z"
|
||||
std::string date = date_;
|
||||
|
||||
// Remove timezone offset if present and replace with Z
|
||||
size_t plusPos = date.find('+');
|
||||
if (plusPos != std::string::npos)
|
||||
{
|
||||
date = date.substr(0, plusPos) + "Z";
|
||||
}
|
||||
// Parse ISO 8601 date string into tm
|
||||
std::tm tm = {};
|
||||
std::istringstream ss(date);
|
||||
ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
|
||||
if (ss.fail())
|
||||
{
|
||||
os << date;
|
||||
return os.str();
|
||||
}
|
||||
|
||||
os << std::put_time(&tm, "%x");
|
||||
return os.str();
|
||||
}
|
||||
|
||||
static std::string mdEnum(const std::string &value, const std::map<std::string, std::string> &enumValues)
|
||||
{
|
||||
auto ff = enumValues.find(value);
|
||||
if (ff == enumValues.end())
|
||||
{
|
||||
return value;
|
||||
}
|
||||
return ff->second;
|
||||
}
|
||||
|
||||
static std::string mdEnumList(
|
||||
const std::vector<std::string> &values,
|
||||
const std::map<std::string, std::string> &enumValues)
|
||||
{
|
||||
if (values.size() == 1)
|
||||
{
|
||||
return mdEnum(values[0], enumValues);
|
||||
}
|
||||
std::ostringstream os;
|
||||
os << '[';
|
||||
bool first = true;
|
||||
for (const auto &value : values)
|
||||
{
|
||||
if (first)
|
||||
{
|
||||
first = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
os << ", ";
|
||||
}
|
||||
os << mdEnum(value, enumValues);
|
||||
}
|
||||
os << ']';
|
||||
return os.str();
|
||||
}
|
||||
|
||||
static std::map<std::string, std::string> sizeEnumValues =
|
||||
{
|
||||
{"standard", "Standard"},
|
||||
{"lite", "Lite"},
|
||||
{"feather", "Feather"},
|
||||
{"nano", "Nano"},
|
||||
{"custom", "Custom"}};
|
||||
static std::string mdSizes(const std::vector<std::string> &sizes)
|
||||
{
|
||||
return mdEnumList(sizes, sizeEnumValues);
|
||||
}
|
||||
|
||||
static std::map<std::string, std::string> gearEnumValues =
|
||||
{
|
||||
{"amp", "Amp only"},
|
||||
{"full-rig", "Full rig"},
|
||||
{"pedal", "Pedal"},
|
||||
{"outboard", "Outboard"},
|
||||
{"ir", "I/R"}};
|
||||
|
||||
static std::string mdGear(const std::string &gear)
|
||||
{
|
||||
return mdEnum(gear, gearEnumValues);
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
enum class LicenseFlags
|
||||
{
|
||||
None = 0,
|
||||
Cc = 1,
|
||||
By = 2,
|
||||
CcBy = 3, // = Cc | By
|
||||
Sa = 4,
|
||||
Nc = 8,
|
||||
Nd = 16,
|
||||
Cc0 = 32,
|
||||
};
|
||||
|
||||
static bool operator&(LicenseFlags v1, LicenseFlags v2)
|
||||
{
|
||||
return (((int)v1) & ((int)v2)) != 0;
|
||||
}
|
||||
static LicenseFlags operator|(LicenseFlags v1, LicenseFlags v2)
|
||||
{
|
||||
return (LicenseFlags)(((int)(v1)) | ((int)(v2)));
|
||||
}
|
||||
|
||||
struct LicenseInfo
|
||||
{
|
||||
std::string key;
|
||||
std::string displayName;
|
||||
std::string url;
|
||||
LicenseFlags licenseFlags;
|
||||
};
|
||||
}
|
||||
|
||||
static std::vector<LicenseInfo> licenses{
|
||||
{.key = "t3k",
|
||||
.displayName = "T3K",
|
||||
.url = "licenses/t3k_license.html",
|
||||
.licenseFlags = LicenseFlags::None},
|
||||
{.key = "cc-by",
|
||||
.displayName = "CC BY 4.0",
|
||||
.url = "https://creativecommons.org/licenses/by/4.0/",
|
||||
.licenseFlags = LicenseFlags::CcBy},
|
||||
{.key = "cc-by-sa",
|
||||
.displayName = "CC BY-SA 4.0",
|
||||
.url = "https://creativecommons.org/licenses/by-sa/4.0/",
|
||||
.licenseFlags = LicenseFlags::CcBy | LicenseFlags::Sa},
|
||||
{.key = "cc-by-nc",
|
||||
.displayName = "CC BY-NC 4.0",
|
||||
.url = "https://creativecommons.org/licenses/by-nc/4.0/",
|
||||
.licenseFlags = LicenseFlags::CcBy | LicenseFlags::Nc},
|
||||
{.key = "cc-by-nc-sa",
|
||||
.displayName = "CC BY-NC-SA 4.0",
|
||||
.url = "https://creativecommons.org/licenses/by-nc-sa/4.0/",
|
||||
.licenseFlags = LicenseFlags::CcBy | LicenseFlags::Nc | LicenseFlags::Sa},
|
||||
{.key = "cc-by-nd",
|
||||
.displayName = "CC BY-ND 4.0",
|
||||
.url = "https://creativecommons.org/licenses/by-nd/4.0/",
|
||||
.licenseFlags = LicenseFlags::CcBy | LicenseFlags::Nd},
|
||||
{.key = "cc-by-nc-nd",
|
||||
.displayName = "CC BY-NC-ND 4.0",
|
||||
.url = "https://creativecommons.org/licenses/by-nc-nd/4.0/",
|
||||
.licenseFlags = LicenseFlags::CcBy | LicenseFlags::Nc | LicenseFlags::Nd},
|
||||
{
|
||||
.key = "cco",
|
||||
.displayName = "CC0",
|
||||
.url = "https://creativecommons.org/publicdomain/zero/1.0/",
|
||||
.licenseFlags = LicenseFlags::Cc | LicenseFlags::Cc0,
|
||||
|
||||
},
|
||||
};
|
||||
|
||||
static std::map<std::string, LicenseInfo> makeLicenseEnum()
|
||||
{
|
||||
std::map<std::string, LicenseInfo> result;
|
||||
for (const auto &license : licenses)
|
||||
{
|
||||
result[license.key] = license;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static std::map<std::string, LicenseInfo> licenseEnum = makeLicenseEnum();
|
||||
|
||||
static std::string mdLicenseIcon(LicenseFlags licenseFlag)
|
||||
{
|
||||
std::ostringstream os;
|
||||
std::string url;
|
||||
|
||||
switch (licenseFlag)
|
||||
{
|
||||
case LicenseFlags::Cc:
|
||||
url = "img/cc.svg";
|
||||
break;
|
||||
case LicenseFlags::By:
|
||||
url = "img/by.svg";
|
||||
break;
|
||||
case LicenseFlags::Sa:
|
||||
url = "img/sa.svg";
|
||||
break;
|
||||
case LicenseFlags::Nc:
|
||||
url = "img/nc.svg";
|
||||
break;
|
||||
case LicenseFlags::Nd:
|
||||
url = "img/nd.svg";
|
||||
break;
|
||||
case LicenseFlags::Cc0:
|
||||
url = "img/cc0.svg";
|
||||
break;
|
||||
|
||||
case LicenseFlags::None:
|
||||
throw std::runtime_error("Invalid argument.");
|
||||
}
|
||||
os << "<img id='cc_img' src='" << url << "'/>";
|
||||
return os.str();
|
||||
}
|
||||
|
||||
static void mdEscapeString(std::ostream &f, const std::string &str)
|
||||
{
|
||||
size_t ix = 0;
|
||||
while (ix < str.size())
|
||||
{
|
||||
char c = str[ix++];
|
||||
if (c == '\n')
|
||||
{
|
||||
if (ix < str.size() && str[ix] == '\n')
|
||||
{
|
||||
f << "\n\n";
|
||||
}
|
||||
else
|
||||
{
|
||||
f << " \n"; // a <br> in md.
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
f << c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static std::string mdHref(const std::string &url)
|
||||
{
|
||||
std::ostringstream os;
|
||||
os << '\'';
|
||||
for (char c : url)
|
||||
{
|
||||
if (c == '\'')
|
||||
{
|
||||
continue;
|
||||
}
|
||||
os << c;
|
||||
}
|
||||
os << '\'';
|
||||
return os.str();
|
||||
}
|
||||
static std::string mdLicense(const std::string &license)
|
||||
{
|
||||
|
||||
auto ff = licenseEnum.find(license);
|
||||
LicenseInfo licenseInfo;
|
||||
if (ff != licenseEnum.end())
|
||||
{
|
||||
licenseInfo = ff->second;
|
||||
}
|
||||
else
|
||||
{
|
||||
licenseInfo.displayName = license;
|
||||
}
|
||||
|
||||
std::ostringstream os;
|
||||
|
||||
if (licenseInfo.licenseFlags != LicenseFlags::None)
|
||||
{
|
||||
for (LicenseFlags licenseFlag : std::vector<LicenseFlags>{LicenseFlags::Cc, LicenseFlags::By, LicenseFlags::Cc0, LicenseFlags::Nc, LicenseFlags::Sa, LicenseFlags::Nd})
|
||||
{
|
||||
if (licenseInfo.licenseFlags & licenseFlag)
|
||||
{
|
||||
os << mdLicenseIcon(licenseFlag);
|
||||
}
|
||||
}
|
||||
os << " ";
|
||||
}
|
||||
|
||||
if (licenseInfo.url != "")
|
||||
{
|
||||
os << "<a href="
|
||||
<< mdHref(licenseInfo.url)
|
||||
<< " target='_blank' rel='noopener noreferrer' >" << mdSanitize(licenseInfo.displayName) << "</a>";
|
||||
}
|
||||
else
|
||||
{
|
||||
os << mdSanitize(licenseInfo.displayName);
|
||||
}
|
||||
return os.str();
|
||||
}
|
||||
// static std::string mdTestLicenses()
|
||||
// {
|
||||
// std::ostringstream os;
|
||||
// for (auto license: licenseEnum)
|
||||
// {
|
||||
// os << "<br/>" << mdLicense(license.first) << "\n";
|
||||
// }
|
||||
// return os.str();
|
||||
// }
|
||||
static std::map<std::string, std::string> platformEnumValues =
|
||||
{
|
||||
{"nam", "NAM"},
|
||||
{"ir", "I/R"},
|
||||
{"aida-x", "Aida X"},
|
||||
{"aa-snapshot", "aa-snapshot"},
|
||||
{"proteus", "Proteus"}};
|
||||
static std::string mdPlatform(const std::string &platform)
|
||||
{
|
||||
return mdEnum(platform, platformEnumValues);
|
||||
}
|
||||
static std::string mdUser(const tone3000::Tone3000User &user)
|
||||
{
|
||||
// clang-format off
|
||||
std::ostringstream os;
|
||||
os <<
|
||||
"<a target='_blank' rel='noopener noreferrer' href='"
|
||||
<< mdSanitize(user.url(),"'")
|
||||
<< "'>" << mdSanitize(user.username()) << "</a>";
|
||||
return os.str();
|
||||
// clang-format on
|
||||
}
|
||||
|
||||
static void mdLink(std::ostream &f, const std::string &label, const std::string &url)
|
||||
{
|
||||
f << "[" << mdSanitize(label, "]") << "](" << mdSanitize(url, ")") << ")";
|
||||
}
|
||||
|
||||
static void writeReadme(const fs::path &path, const std::string &thumbnailUrl, const Tone3000Download &download)
|
||||
{
|
||||
std::ofstream of{path};
|
||||
if (!of.is_open())
|
||||
{
|
||||
throw std::runtime_error(SS("Unable to create file " << path));
|
||||
}
|
||||
|
||||
// clang-format off
|
||||
of <<
|
||||
|
||||
"<div id='tone3000_float' >\n";
|
||||
if (!thumbnailUrl.empty())
|
||||
{
|
||||
|
||||
of << " <img id='tone3000_thumbnail' src='"
|
||||
<< thumbnailUrl << "' alt='' />\n";
|
||||
}
|
||||
of << " <div id='tone3000_float_content' >\n"
|
||||
<< " <h3 id='tone3000_title'>" << mdSanitize(download.title()) << "</h3>\n"
|
||||
<< " <div>\n"
|
||||
<< " <p id='tone3000_subtitle'>\n"
|
||||
<< mdDate(download.updated_at())
|
||||
<< " "
|
||||
<< mdUser(download.user())
|
||||
<< " <br/>\n"
|
||||
<< " ";
|
||||
if (download.sizes().has_value()) {
|
||||
of
|
||||
<< mdSizes(download.sizes().value()) << ", "
|
||||
;
|
||||
}
|
||||
if (download.platform() == "ir")
|
||||
{
|
||||
of
|
||||
<< (mdPlatform(download.platform()));
|
||||
} else {
|
||||
of
|
||||
<< mdGear(download.gear()) << ", "
|
||||
<< mdPlatform(download.platform());
|
||||
}
|
||||
of << "<br/>\n";
|
||||
if (download.license() != "") {
|
||||
of << " License: " << mdLicense(download.license()) << "\n";
|
||||
}
|
||||
of
|
||||
// << " " << mdTestLicenses()
|
||||
<< " </p>\n"
|
||||
<< " </div>\n"
|
||||
<< " </div>\n"
|
||||
<< "</div>\n";
|
||||
// clang-format on
|
||||
of << "\n\n";
|
||||
mdEscapeString(of, download.description());
|
||||
|
||||
if (download.links().size() > 0)
|
||||
{
|
||||
of << std::endl;
|
||||
of << "## Links" << std::endl
|
||||
<< std::endl;
|
||||
for (auto &link : download.links())
|
||||
{
|
||||
of << "- " << "[" << link << "](" << link << ")" << std::endl;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void CleanUpFailedDownload(const std::filesystem::path &folder, const std::vector<CurlDownloadRequest> &requests)
|
||||
{
|
||||
|
||||
std::error_code ec; // do NOT throw errors.
|
||||
|
||||
if (fs::exists(folder))
|
||||
{
|
||||
for (const auto &request : requests)
|
||||
{
|
||||
if (fs::exists(request.outputFile))
|
||||
{
|
||||
fs::remove(request.outputFile, ec); // ignoring errors.
|
||||
}
|
||||
}
|
||||
// If there's a readme file, remove that too.
|
||||
fs::path readmePath = folder / "README.md";
|
||||
if (fs::exists(readmePath))
|
||||
{
|
||||
fs::remove(readmePath, ec); // ignoring errors
|
||||
}
|
||||
|
||||
// if the folder directory is empty, remove the the folder too.
|
||||
try
|
||||
{
|
||||
if (fs::is_empty(folder))
|
||||
{
|
||||
fs::remove(folder, ec); // ignoring errors.
|
||||
}
|
||||
}
|
||||
catch (const std::exception &)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static std::string GetHeader(const std::string &headerName, const std::vector<std::string> &htmlHeaders)
|
||||
{
|
||||
std::string needle = headerName;
|
||||
std::transform(needle.begin(), needle.end(), needle.begin(), [](unsigned char c)
|
||||
{ return std::tolower(c); });
|
||||
|
||||
for (const auto &header : htmlHeaders)
|
||||
{
|
||||
auto pos = header.find(':');
|
||||
if (pos == std::string::npos)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
std::string name = header.substr(0, pos);
|
||||
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c)
|
||||
{ return std::tolower(c); });
|
||||
if (name != needle)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
std::string value = header.substr(pos + 1);
|
||||
auto first = value.find_first_not_of(" \t\r\n");
|
||||
if (first == std::string::npos)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
auto last = value.find_last_not_of(" \t\r\n");
|
||||
return value.substr(first, last - first + 1);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
static bool CancellableSleep(std::chrono::steady_clock::duration duration, std::function<bool()> &isCancelled)
|
||||
{
|
||||
using clock_t = std::chrono::steady_clock;
|
||||
|
||||
auto start = clock_t::now();
|
||||
while (true)
|
||||
{
|
||||
auto now = clock_t::now();
|
||||
auto elapsed = now - start;
|
||||
if (elapsed > duration)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (isCancelled())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
}
|
||||
}
|
||||
std::string pipedal::tone3000::DownloadTone3000Bundle(
|
||||
const std::filesystem::path &destinationFolder,
|
||||
const std::string &downloadUrl,
|
||||
int64_t handle,
|
||||
std::function<void(const Tone3000DownloadProgress &progress)> progressCallback,
|
||||
std::function<bool()> isCancelled)
|
||||
{
|
||||
TemporaryFile downloadTemporaryFile{WEB_TEMP_DIR};
|
||||
fs::path downloadPath = downloadTemporaryFile.Path();
|
||||
std::vector<std::string> headers;
|
||||
|
||||
std::string resultDirectory;
|
||||
Tone3000DownloadProgress progress;
|
||||
progress.handle(handle);
|
||||
progressCallback(progress);
|
||||
|
||||
int httpResult = CurlGet(downloadUrl, downloadPath, &headers);
|
||||
if (httpResult != 200)
|
||||
{
|
||||
throw std::runtime_error(SS("Download failed: HTTP " << HtmlHelper::httpErrorString(httpResult)));
|
||||
}
|
||||
|
||||
if (isCancelled())
|
||||
{
|
||||
|
||||
return resultDirectory;
|
||||
}
|
||||
|
||||
std::ifstream f{downloadPath};
|
||||
if (!f.is_open())
|
||||
{
|
||||
throw std::runtime_error(SS("Can't open file " << downloadPath));
|
||||
}
|
||||
json_reader reader(f);
|
||||
|
||||
Tone3000Download tone3000Download;
|
||||
reader.read(&tone3000Download);
|
||||
|
||||
if (isCancelled())
|
||||
{
|
||||
return resultDirectory;
|
||||
}
|
||||
progress.title(tone3000Download.title());
|
||||
progress.total(tone3000Download.models().size());
|
||||
progressCallback(progress);
|
||||
|
||||
if (tone3000Download.platform() != "nam" && tone3000Download.platform() != "ir")
|
||||
{
|
||||
throw std::runtime_error(SS("Unexpected file format. (platform=\"" << tone3000Download.platform() << "\")"));
|
||||
}
|
||||
fs::path bundlePath = destinationFolder / StringToSafeFilename(tone3000Download.title());
|
||||
resultDirectory = bundlePath;
|
||||
|
||||
fs::create_directories(bundlePath);
|
||||
|
||||
std::vector<CurlDownloadRequest> requests;
|
||||
|
||||
for (auto &model : tone3000Download.models())
|
||||
{
|
||||
fs::path modelPath;
|
||||
if (tone3000Download.platform() == "ir")
|
||||
{
|
||||
modelPath = bundlePath / SS(StringToSafeFilename(model.name()) << ".wav");
|
||||
}
|
||||
else
|
||||
{
|
||||
modelPath = bundlePath / SS(StringToSafeFilename(model.name()) << ".nam");
|
||||
}
|
||||
requests.push_back(CurlDownloadRequest{url : model.model_url(), outputFile : modelPath});
|
||||
}
|
||||
try
|
||||
{
|
||||
size_t downloadIndex = 0;
|
||||
while (downloadIndex < requests.size())
|
||||
{
|
||||
if (isCancelled())
|
||||
{
|
||||
throw std::runtime_error("Cancelled");
|
||||
}
|
||||
size_t thisTime = requests.size() - downloadIndex;
|
||||
thisTime = Tone3000Throttler::instance().ReserveDownloadSlots(thisTime);
|
||||
if (thisTime != 0)
|
||||
{
|
||||
std::vector<CurlDownloadRequest> requestsThisTime;
|
||||
for (size_t i = 0; i < thisTime; ++i)
|
||||
{
|
||||
requestsThisTime.push_back(requests[downloadIndex + i]);
|
||||
}
|
||||
|
||||
int result = CurlGet(
|
||||
requestsThisTime,
|
||||
[&](size_t completed, size_t total)
|
||||
{
|
||||
if (isCancelled())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
progress.progress(completed + downloadIndex);
|
||||
progress.total(requests.size());
|
||||
progressCallback(progress);
|
||||
return true;
|
||||
},
|
||||
&headers);
|
||||
|
||||
if (isCancelled())
|
||||
{
|
||||
throw std::runtime_error("Cancelled");
|
||||
}
|
||||
|
||||
if (result != 200)
|
||||
{
|
||||
throw std::runtime_error(SS("Server download failed. HTTP Error " << HtmlHelper::httpErrorString(result)));
|
||||
}
|
||||
|
||||
downloadIndex += thisTime;
|
||||
}
|
||||
if (thisTime <= 1)
|
||||
{
|
||||
CancellableSleep(std::chrono::milliseconds(1000), isCancelled);
|
||||
}
|
||||
}
|
||||
|
||||
fs::path readmePath = bundlePath / "README.md";
|
||||
|
||||
std::string thumbnailUrl;
|
||||
if (tone3000Download.images().has_value() && tone3000Download.images().value().size() != 0)
|
||||
{
|
||||
|
||||
TemporaryFile imageFile{TONE3000_THUMBNAIL_PATH};
|
||||
|
||||
std::string imageUrl = tone3000Download.images().value()[0];
|
||||
std::vector<std::string> imageHeaders;
|
||||
try
|
||||
{
|
||||
size_t reserveCount;
|
||||
while (true)
|
||||
{
|
||||
reserveCount = Tone3000Throttler::instance().ReserveDownloadSlots(1);
|
||||
if (reserveCount != 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
|
||||
if (isCancelled())
|
||||
{
|
||||
throw std::runtime_error("Cancelled");
|
||||
}
|
||||
}
|
||||
if (CurlGet(imageUrl, imageFile.Path(), &imageHeaders) == 200)
|
||||
{
|
||||
std::string mediaType = GetHeader("Content-Type", imageHeaders);
|
||||
std::string extension;
|
||||
if (mediaType == "image/jpeg")
|
||||
{
|
||||
extension = ".jpeg";
|
||||
}
|
||||
else if (mediaType == "image/webp")
|
||||
{
|
||||
extension = ".webp";
|
||||
}
|
||||
else if (mediaType == "image/png")
|
||||
{
|
||||
extension = ".png";
|
||||
}
|
||||
else
|
||||
{
|
||||
Lv2Log::warning("Tone3000 thumbnail has an unexpected media type of '%s'", mediaType.c_str());
|
||||
throw std::runtime_error("Invalid thumbnail media type.");
|
||||
}
|
||||
if (mediaType == "image/jpeg")
|
||||
{
|
||||
fs::path thumnbailTempPath = imageFile.Detach();
|
||||
fs::path finalPath = TONE3000_THUMBNAIL_PATH / SS(tone3000Download.id() << extension);
|
||||
if (fs::exists(finalPath))
|
||||
{
|
||||
fs::remove(finalPath);
|
||||
}
|
||||
fs::create_directories(finalPath.parent_path());
|
||||
fs::rename(thumnbailTempPath, finalPath);
|
||||
thumbnailUrl = SS("var/tone3000_thumbnail?id=" << tone3000Download.id());
|
||||
// thumbnailUrl = mdDataUrl("image/jpeg", finalPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (const std::exception &)
|
||||
{
|
||||
// ignore.
|
||||
}
|
||||
}
|
||||
writeReadme(readmePath, thumbnailUrl, tone3000Download);
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
CleanUpFailedDownload(bundlePath, requests);
|
||||
if (isCancelled())
|
||||
{
|
||||
return "";
|
||||
}
|
||||
throw;
|
||||
}
|
||||
|
||||
return resultDirectory;
|
||||
}
|
||||
|
||||
Tone3000DownloadResult::Tone3000DownloadResult()
|
||||
: success_(true)
|
||||
{
|
||||
}
|
||||
Tone3000DownloadResult::Tone3000DownloadResult(const std::exception &e)
|
||||
: success_(false), errorMessage_(e.what())
|
||||
{
|
||||
}
|
||||
JSON_MAP_END();
|
||||
|
||||
@@ -618,7 +618,7 @@ static void writeReadme(const fs::path &path, const Tone3000Download &download)
|
||||
}
|
||||
}
|
||||
|
||||
std::string pipedal::tone3000::DownloadTone3000Bundle(
|
||||
std::string pipedal::tone3000::DownloadTone3000File(
|
||||
const std::filesystem::path &destinationFolder,
|
||||
const std::string &downloadUrl,
|
||||
int64_t handle,
|
||||
|
||||
+89
-96
@@ -22,6 +22,7 @@
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -36,111 +37,103 @@
|
||||
namespace pipedal
|
||||
{
|
||||
|
||||
namespace tone3000
|
||||
class Tone3000FileDownloadRequest;
|
||||
|
||||
using tone3000_time_point = std::string; // Date in ISO 8601 format. (C++ doesn't handle timezones)
|
||||
class Tone3000Model
|
||||
{
|
||||
using tone3000_time_point = std::string; // Date in ISO 8601 format. (C++ doesn't handle timezones)
|
||||
class Tone3000Model
|
||||
{
|
||||
private:
|
||||
int64_t id_ = -1;
|
||||
std::string name_;
|
||||
std::string model_url_;
|
||||
tone3000_time_point created_at_;
|
||||
std::optional<std::string> size_;
|
||||
std::string user_id_;
|
||||
private:
|
||||
int64_t id_ = -1;
|
||||
std::string name_;
|
||||
std::string model_url_;
|
||||
tone3000_time_point created_at_;
|
||||
std::optional<std::string> size_;
|
||||
std::string user_id_;
|
||||
|
||||
public:
|
||||
JSON_GETTER_SETTER(id)
|
||||
JSON_GETTER_SETTER_REF(name)
|
||||
JSON_GETTER_SETTER_REF(model_url)
|
||||
JSON_GETTER_SETTER_REF(created_at)
|
||||
JSON_GETTER_SETTER_REF(size)
|
||||
JSON_GETTER_SETTER_REF(user_id)
|
||||
public:
|
||||
JSON_GETTER_SETTER(id)
|
||||
JSON_GETTER_SETTER_REF(name)
|
||||
JSON_GETTER_SETTER_REF(model_url)
|
||||
JSON_GETTER_SETTER_REF(created_at)
|
||||
JSON_GETTER_SETTER_REF(size)
|
||||
JSON_GETTER_SETTER_REF(user_id)
|
||||
|
||||
DECLARE_JSON_MAP(Tone3000Model);
|
||||
};
|
||||
DECLARE_JSON_MAP(Tone3000Model);
|
||||
};
|
||||
|
||||
class Tone3000User
|
||||
{
|
||||
private:
|
||||
std::string id_;
|
||||
std::optional<std::string> avatar_url_;
|
||||
std::string username_;
|
||||
std::string url_;
|
||||
class Tone3000User
|
||||
{
|
||||
private:
|
||||
std::string id_;
|
||||
std::optional<std::string> avatar_url_;
|
||||
std::string username_;
|
||||
std::string url_;
|
||||
|
||||
public:
|
||||
JSON_GETTER_SETTER_REF(id)
|
||||
JSON_GETTER_SETTER(username)
|
||||
JSON_GETTER_SETTER(url)
|
||||
public:
|
||||
JSON_GETTER_SETTER_REF(id)
|
||||
JSON_GETTER_SETTER(username)
|
||||
JSON_GETTER_SETTER(url)
|
||||
|
||||
DECLARE_JSON_MAP(Tone3000User);
|
||||
};
|
||||
DECLARE_JSON_MAP(Tone3000User);
|
||||
};
|
||||
|
||||
class Tone3000Download
|
||||
{
|
||||
private:
|
||||
int64_t id_ = -1;
|
||||
std::string user_id_;
|
||||
std::string title_;
|
||||
std::string platform_;
|
||||
std::string description_;
|
||||
tone3000_time_point created_at_;
|
||||
tone3000_time_point updated_at_;
|
||||
std::string gear_;
|
||||
std::optional<std::vector<std::string>> images_;
|
||||
bool is_public_ = true;
|
||||
std::vector<std::string> links_;
|
||||
int64_t model_count_ = 0;
|
||||
int64_t favorites_count_ = 0;
|
||||
std::string license_;
|
||||
std::optional<std::vector<std::string>> sizes_;
|
||||
Tone3000User user_;
|
||||
std::vector<Tone3000Model> models_;
|
||||
class Tone3000Download
|
||||
{
|
||||
private:
|
||||
int64_t id_ = -1;
|
||||
std::string user_id_;
|
||||
std::string title_;
|
||||
std::string description_;
|
||||
tone3000_time_point created_at_;
|
||||
tone3000_time_point updated_at_;
|
||||
std::string gear_;
|
||||
std::vector<std::string> images_;
|
||||
bool is_public_ = true;
|
||||
std::vector<std::string> links_;
|
||||
std::string platform_;
|
||||
int64_t models_count_ = 0;
|
||||
int64_t favorites_count_ = 0;
|
||||
int64_t downloads_count_ = 0;
|
||||
std::string license_;
|
||||
std::optional<std::vector<std::string>> sizes_;
|
||||
int64_t a1_models_count_ = 0;
|
||||
int64_t a2_models_count_ =0;
|
||||
int64_t irs_count_ =0;
|
||||
int64_t custom_models_count_ = 0;
|
||||
Tone3000User user_;
|
||||
std::string url_;
|
||||
|
||||
public:
|
||||
JSON_GETTER_SETTER(id)
|
||||
JSON_GETTER_SETTER_REF(user_id)
|
||||
JSON_GETTER_SETTER_REF(title)
|
||||
JSON_GETTER_SETTER_REF(platform)
|
||||
JSON_GETTER_SETTER_REF(description)
|
||||
JSON_GETTER_SETTER_REF(created_at)
|
||||
JSON_GETTER_SETTER_REF(updated_at)
|
||||
JSON_GETTER_SETTER_REF(gear)
|
||||
JSON_GETTER_SETTER_REF(images)
|
||||
JSON_GETTER_SETTER(is_public)
|
||||
JSON_GETTER_SETTER_REF(links)
|
||||
JSON_GETTER_SETTER(model_count)
|
||||
JSON_GETTER_SETTER(favorites_count)
|
||||
JSON_GETTER_SETTER_REF(license)
|
||||
JSON_GETTER_SETTER_REF(sizes)
|
||||
JSON_GETTER_SETTER_REF(user)
|
||||
JSON_GETTER_SETTER_REF(models)
|
||||
std::vector<Tone3000Model> models_;
|
||||
|
||||
DECLARE_JSON_MAP(Tone3000Download);
|
||||
};
|
||||
public:
|
||||
Tone3000Download() = default;
|
||||
Tone3000Download(const std::string&json);
|
||||
|
||||
class Tone3000DownloadResult {
|
||||
private:
|
||||
bool success_;
|
||||
std::string errorMessage_;
|
||||
public:
|
||||
JSON_GETTER_SETTER(success);
|
||||
JSON_GETTER_SETTER_REF(errorMessage);
|
||||
JSON_GETTER_SETTER(id)
|
||||
JSON_GETTER_SETTER_REF(user_id)
|
||||
JSON_GETTER_SETTER_REF(title)
|
||||
JSON_GETTER_SETTER_REF(description)
|
||||
JSON_GETTER_SETTER_REF(created_at)
|
||||
JSON_GETTER_SETTER_REF(updated_at)
|
||||
JSON_GETTER_SETTER_REF(gear)
|
||||
JSON_GETTER_SETTER_REF(images)
|
||||
JSON_GETTER_SETTER(is_public)
|
||||
JSON_GETTER_SETTER_REF(links)
|
||||
JSON_GETTER_SETTER_REF(platform)
|
||||
JSON_GETTER_SETTER(models_count)
|
||||
JSON_GETTER_SETTER(favorites_count)
|
||||
JSON_GETTER_SETTER(downloads_count)
|
||||
JSON_GETTER_SETTER_REF(license)
|
||||
JSON_GETTER_SETTER_REF(sizes)
|
||||
JSON_GETTER_SETTER_REF(a1_models_count)
|
||||
JSON_GETTER_SETTER_REF(a2_models_count)
|
||||
JSON_GETTER_SETTER_REF(irs_count)
|
||||
JSON_GETTER_SETTER_REF(custom_models_count)
|
||||
JSON_GETTER_SETTER_REF(user)
|
||||
JSON_GETTER_SETTER_REF(url)
|
||||
JSON_GETTER_SETTER_REF(models)
|
||||
|
||||
Tone3000DownloadResult();
|
||||
Tone3000DownloadResult(const std::exception &e);
|
||||
DECLARE_JSON_MAP(Tone3000Download);
|
||||
};
|
||||
|
||||
DECLARE_JSON_MAP(Tone3000DownloadResult);
|
||||
};
|
||||
|
||||
extern std::string DownloadTone3000Bundle
|
||||
(
|
||||
const std::filesystem::path&destinationFolder,
|
||||
const std::string &downloadUrl,
|
||||
int64_t downloadHandle,
|
||||
std::function<void(const Tone3000DownloadProgress&progress)> progressCallback,
|
||||
std::function<bool()> isCancelled
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
|
||||
namespace pipedal {
|
||||
enum class Tone3000DownloadType {
|
||||
Nam,
|
||||
CabIr
|
||||
Nam = 0,
|
||||
CabIr = 1
|
||||
};
|
||||
|
||||
Tone3000DownloadType StringToTone3000DownloadType(const std::string &value);
|
||||
|
||||
+1030
-103
File diff suppressed because it is too large
Load Diff
+110
-28
@@ -8,10 +8,10 @@
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
|
||||
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
@@ -21,64 +21,146 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <cstdint>
|
||||
#include "Tone3000DownloadProgress.hpp"
|
||||
#include "Tone3000DownloadType.hpp"
|
||||
#include "json.hpp"
|
||||
#include "Uri.hpp"
|
||||
|
||||
namespace pipedal {
|
||||
namespace pipedal
|
||||
{
|
||||
|
||||
class Tone3000Downloader {
|
||||
class Tone3000PkceParams {
|
||||
private:
|
||||
std::string publishableKey_;
|
||||
std::string redirectUrl_;
|
||||
std::string codeVerifier_;
|
||||
std::string codeChallenge_;
|
||||
std::string state_;
|
||||
public:
|
||||
JSON_GETTER_SETTER_REF(publishableKey);
|
||||
JSON_GETTER_SETTER_REF(redirectUrl);
|
||||
JSON_GETTER_SETTER_REF(codeVerifier);
|
||||
JSON_GETTER_SETTER_REF(codeChallenge);
|
||||
JSON_GETTER_SETTER_REF(state);
|
||||
|
||||
DECLARE_JSON_MAP(Tone3000PkceParams);
|
||||
};
|
||||
class Tone3000ModelInfo
|
||||
{
|
||||
private:
|
||||
std::string url_;
|
||||
std::string name_;
|
||||
|
||||
public:
|
||||
JSON_GETTER_SETTER_REF(url);
|
||||
JSON_GETTER_SETTER_REF(name);
|
||||
|
||||
DECLARE_JSON_MAP(Tone3000ModelInfo);
|
||||
};
|
||||
|
||||
// class Tone3000DownloadRequest
|
||||
// {
|
||||
// private:
|
||||
// int64_t downloadType_;
|
||||
// std::string downloadPath_;
|
||||
// std::string codeVerifier_;
|
||||
// std::string state_;
|
||||
// std::string codeChallenge_;
|
||||
|
||||
// std::string authToken_;
|
||||
// int64_t id_;
|
||||
// std::string title_;
|
||||
// std::string description_;
|
||||
// std::string imageUrl_;
|
||||
// std::vector<Tone3000ModelInfo> models_;
|
||||
|
||||
// std::string updated_at_;
|
||||
// std::string user_;
|
||||
// std::vector<std::string> sizes_;
|
||||
// std::string platform_;
|
||||
// std::string gear_;
|
||||
// std::string license_;
|
||||
// std::vector<std::string> links_;
|
||||
|
||||
// public:
|
||||
// Tone3000DownloadType downloadType() const { return (Tone3000DownloadType)downloadType_;}
|
||||
// void downloadType(Tone3000DownloadType value) { downloadType_= (int64_t)value;}
|
||||
// JSON_GETTER_SETTER_REF(downloadPath);
|
||||
// JSON_GETTER_SETTER(id);
|
||||
// JSON_GETTER_SETTER(codeVerifier);
|
||||
// JSON_GETTER_SETTER(state);
|
||||
// JSON_GETTER_SETTER(codeChallenge);
|
||||
// JSON_GETTER_SETTER(authToken);
|
||||
// JSON_GETTER_SETTER_REF(title);
|
||||
// JSON_GETTER_SETTER_REF(description)
|
||||
// JSON_GETTER_SETTER_REF(imageUrl);
|
||||
// JSON_GETTER_SETTER_REF(models);
|
||||
|
||||
// JSON_GETTER_SETTER_REF(updated_at);
|
||||
// JSON_GETTER_SETTER_REF(user);
|
||||
// JSON_GETTER_SETTER_REF(sizes);
|
||||
// JSON_GETTER_SETTER_REF(platform);
|
||||
// JSON_GETTER_SETTER_REF(gear);
|
||||
// JSON_GETTER_SETTER_REF(license);
|
||||
// JSON_GETTER_SETTER_REF(links);
|
||||
|
||||
|
||||
// DECLARE_JSON_MAP(Tone3000DownloadRequest);
|
||||
// };
|
||||
|
||||
class Tone3000Downloader
|
||||
{
|
||||
protected:
|
||||
Tone3000Downloader();
|
||||
|
||||
public:
|
||||
using handle_t = int64_t;
|
||||
|
||||
virtual ~Tone3000Downloader();
|
||||
|
||||
class Listener {
|
||||
class Listener
|
||||
{
|
||||
public:
|
||||
virtual void OnStartTone3000Download
|
||||
(
|
||||
virtual void OnStartTone3000Download(
|
||||
handle_t handle,
|
||||
const std::string &title
|
||||
) = 0;
|
||||
const std::string &title) = 0;
|
||||
virtual void OnTone3000Progress(
|
||||
const Tone3000DownloadProgress& downloadProgress
|
||||
) = 0;
|
||||
const Tone3000DownloadProgress &downloadProgress) = 0;
|
||||
virtual void OnTone3000DownloadComplete(
|
||||
handle_t handle,
|
||||
const std::string&resultPath
|
||||
) = 0;
|
||||
|
||||
const std::string &resultPath) = 0;
|
||||
|
||||
virtual void OnTone3000DownloadError(
|
||||
handle_t handle,
|
||||
const std::string &errorMessage
|
||||
) = 0;
|
||||
|
||||
virtual std::string Tone3000ThumbnailDirectory() = 0;
|
||||
const std::string &errorMessage) = 0;
|
||||
|
||||
virtual std::string Tone3000ThumbnailDirectory() = 0;
|
||||
};
|
||||
|
||||
|
||||
using self = Tone3000Downloader;
|
||||
static std::shared_ptr<self> Create();
|
||||
|
||||
virtual void SetListener(Listener*listener) = 0;
|
||||
|
||||
virtual handle_t RequestDownload(
|
||||
Tone3000DownloadType downloadType,
|
||||
const std::string &path,
|
||||
const std::string &url
|
||||
) = 0;
|
||||
virtual void SetListener(Listener *listener) = 0;
|
||||
|
||||
|
||||
virtual void CancelDownload(
|
||||
handle_t handle
|
||||
) = 0;
|
||||
handle_t handle) = 0;
|
||||
|
||||
virtual handle_t RequestTone3000Download(
|
||||
const uri &uri,
|
||||
const Tone3000PkceParams &pkceParams,
|
||||
const std::string &downloadPath,
|
||||
Tone3000DownloadType downloadType)
|
||||
= 0;
|
||||
|
||||
virtual void Close() = 0;
|
||||
virtual Tone3000DownloadProgress GetDownloadStatus() = 0;
|
||||
|
||||
|
||||
};
|
||||
}
|
||||
+153
-34
@@ -8,10 +8,10 @@
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
|
||||
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
|
||||
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
@@ -21,7 +21,7 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <cstdint>
|
||||
@@ -31,55 +31,173 @@
|
||||
#include <condition_variable>
|
||||
#include <vector>
|
||||
#include <functional>
|
||||
#include "ThreadedQueue.hpp"
|
||||
#include "Tone3000Download.hpp"
|
||||
|
||||
namespace pipedal {
|
||||
namespace pipedal
|
||||
{
|
||||
|
||||
struct CachedPkceParams
|
||||
{
|
||||
std::chrono::steady_clock::time_point time;
|
||||
Tone3000PkceParams pkceParams;
|
||||
};
|
||||
|
||||
class Tone3000AuthResponse
|
||||
{
|
||||
private:
|
||||
std::string access_token_;
|
||||
int64_t expires_in_ = -1;
|
||||
std::string refresh_token_;
|
||||
std::string token_type_;
|
||||
std::optional<std::string> tone_id_;
|
||||
std::optional<std::string> model_id_;
|
||||
bool canceled_ = false;
|
||||
|
||||
using clock_t = std::chrono::steady_clock;
|
||||
clock_t::time_point expires_at_;
|
||||
|
||||
class Tone3000DownloaderImpl: public Tone3000Downloader {
|
||||
public:
|
||||
virtual ~Tone3000DownloaderImpl();
|
||||
|
||||
virtual void SetListener(Listener*listener) override;
|
||||
Tone3000AuthResponse(const std::string &responseBody);
|
||||
JSON_GETTER_REF(access_token);
|
||||
JSON_GETTER(expires_in);
|
||||
JSON_GETTER_REF(refresh_token);
|
||||
JSON_GETTER_REF(token_type);
|
||||
JSON_GETTER_REF(expires_at);
|
||||
JSON_GETTER_REF(tone_id);
|
||||
JSON_GETTER_REF(model_id);
|
||||
JSON_GETTER_SETTER(canceled);
|
||||
|
||||
virtual handle_t RequestDownload(
|
||||
Tone3000DownloadType downloadType,
|
||||
const std::string &path,
|
||||
const std::string &url
|
||||
) override;
|
||||
DECLARE_JSON_MAP(Tone3000AuthResponse);
|
||||
};
|
||||
class Tone3000AccessTokens
|
||||
{
|
||||
private:
|
||||
std::string access_token_;
|
||||
int64_t expires_in_ = -1;
|
||||
std::string refresh_token_;
|
||||
std::string token_type_;
|
||||
|
||||
using clock_t = std::chrono::steady_clock;
|
||||
clock_t::time_point expires_at_;
|
||||
|
||||
public:
|
||||
Tone3000AccessTokens(const Tone3000AuthResponse &authResponse);
|
||||
JSON_GETTER_REF(access_token);
|
||||
JSON_GETTER(expires_in);
|
||||
JSON_GETTER_REF(refresh_token);
|
||||
JSON_GETTER_REF(token_type);
|
||||
JSON_GETTER_REF(expires_at);
|
||||
};
|
||||
|
||||
class Tone3000DownloaderImpl : public Tone3000Downloader
|
||||
{
|
||||
public:
|
||||
Tone3000DownloaderImpl();
|
||||
|
||||
virtual ~Tone3000DownloaderImpl();
|
||||
|
||||
virtual void SetListener(Listener *listener) override;
|
||||
|
||||
virtual void CancelDownload(
|
||||
handle_t handle
|
||||
) override;
|
||||
handle_t handle) override;
|
||||
|
||||
virtual void Close() override;
|
||||
virtual Tone3000DownloadProgress GetDownloadStatus() override;
|
||||
|
||||
virtual handle_t RequestTone3000Download(
|
||||
const uri &uri,
|
||||
const Tone3000PkceParams &pkceParams,
|
||||
const std::string &downloadPath,
|
||||
Tone3000DownloadType downloadType) override;
|
||||
|
||||
private:
|
||||
struct DownloadRequest
|
||||
{
|
||||
handle_t handle = -1;
|
||||
std::string requestUri;
|
||||
Tone3000PkceParams pkceParams;
|
||||
std::string downloadPath;
|
||||
Tone3000DownloadType downloadType = Tone3000DownloadType::Nam;
|
||||
std::atomic<bool> cancelled = false;
|
||||
};
|
||||
|
||||
void DownloadTone3000ToneBg(
|
||||
std::shared_ptr<DownloadRequest> request);
|
||||
void OnTone3000DownloadError(int64_t handle, const std::string &error)
|
||||
{
|
||||
if (listener)
|
||||
{
|
||||
listener->OnTone3000DownloadError(handle, error);
|
||||
}
|
||||
}
|
||||
std::shared_ptr<Tone3000Download> GetTone3000Tone(
|
||||
int64_t toneId);
|
||||
std::string DownloadTone3000Files(
|
||||
const Tone3000FileDownloadRequest &request,
|
||||
Tone3000DownloadProgress &progress);
|
||||
|
||||
void OnTone3000DownloadComplete(int64_t handle, const std::string &path)
|
||||
{
|
||||
if (listener)
|
||||
{
|
||||
listener->OnTone3000DownloadComplete(handle, path);
|
||||
}
|
||||
}
|
||||
void OnTone3000DownloadCancelled(int64_t handle)
|
||||
{
|
||||
if (listener)
|
||||
{
|
||||
listener->OnTone3000DownloadComplete(handle, "");
|
||||
}
|
||||
}
|
||||
using clock_t = std::chrono::steady_clock;
|
||||
std::recursive_mutex pkceCacheMutex;
|
||||
|
||||
Listener *listener = nullptr;
|
||||
|
||||
void ThreadProc();
|
||||
|
||||
|
||||
|
||||
// Performs the actual download with cancellation support
|
||||
std::string PerformDownload(
|
||||
Tone3000DownloadType downloadType,
|
||||
const std::string &downloadPath,
|
||||
const std::string &downloadUrl,
|
||||
int64_t downloadHandle,
|
||||
std::function<bool()> isCancelled
|
||||
);
|
||||
|
||||
struct DownloadRequest {
|
||||
Tone3000DownloadType downloadType;
|
||||
std::atomic<bool> cancelled = false;
|
||||
handle_t handle;
|
||||
const std::string downloadPath;
|
||||
const std::string downloadUrl;
|
||||
// std::string PerformFileDownloads(
|
||||
// const Tone3000FileDownloadRequest &downloadRequets,
|
||||
// Tone3000DownloadProgress &progress,
|
||||
// std::function<void(Tone3000DownloadProgress &progress)> updateProgress,
|
||||
// std::function<bool()> isCancelled);
|
||||
|
||||
struct PostResult
|
||||
{
|
||||
bool ok = false;
|
||||
int errorCode = 0;
|
||||
std::string error;
|
||||
std::string body;
|
||||
};
|
||||
|
||||
PostResult Post(
|
||||
const uri &requestedUri,
|
||||
std::vector<std::string> headers,
|
||||
const std::string &body);
|
||||
|
||||
|
||||
std::string Tone3000GetText(
|
||||
const uri &requestedUri
|
||||
);
|
||||
struct OAuthCallbackResult
|
||||
{
|
||||
std::optional<int64_t> toneId;
|
||||
std::optional<int64_t> modelId;
|
||||
};
|
||||
|
||||
OAuthCallbackResult handleOAuthCallback(
|
||||
const uri &callbackUri,
|
||||
const Tone3000PkceParams &pkce);
|
||||
|
||||
std::atomic<bool> closed = false;
|
||||
handle_t nextHandle = 1;
|
||||
handle_t NextHandle();
|
||||
|
||||
std::vector<std::shared_ptr<DownloadRequest>> requestQueue;
|
||||
ThreadedQueue<DownloadRequest> requestQueue;
|
||||
|
||||
Tone3000DownloadProgress fgDownloadProgress;
|
||||
void bgUpdateDownloadProgress(const Tone3000DownloadProgress &progress);
|
||||
@@ -87,9 +205,10 @@ namespace pipedal {
|
||||
std::shared_ptr<DownloadRequest> activeRequest;
|
||||
|
||||
std::mutex mutex;
|
||||
std::condition_variable thread_cv;
|
||||
|
||||
|
||||
std::unique_ptr<std::jthread> thread;
|
||||
std::shared_ptr<Tone3000AccessTokens> accessTokens;
|
||||
|
||||
std::string GetBearerToken() const;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+162
-109
@@ -29,55 +29,61 @@ std::string uri::segment(int index) const
|
||||
const char *p = path_start;
|
||||
int segment = 0;
|
||||
|
||||
if (p != path_end && *p == '/') ++p;
|
||||
if (p != path_end && *p == '/')
|
||||
++p;
|
||||
|
||||
while (p != path_end && *p != '?' && *p != '#')
|
||||
{
|
||||
if (segment == index)
|
||||
{
|
||||
const char*segmentStart = p;
|
||||
while(p != path_end && *p != '/' && *p != '?' && *p != '#')
|
||||
const char *segmentStart = p;
|
||||
while (p != path_end && *p != '/' && *p != '?' && *p != '#')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
return HtmlHelper::decode_url_segment(segmentStart,p);
|
||||
} else {
|
||||
while(p != path_end && *p != '/' && *p != '?' && *p != '#')
|
||||
return HtmlHelper::decode_url_segment(segmentStart, p);
|
||||
}
|
||||
else
|
||||
{
|
||||
while (p != path_end && *p != '/' && *p != '?' && *p != '#')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
}
|
||||
if (p != path_end && *p == '/') ++p;
|
||||
if (p != path_end && *p == '/')
|
||||
++p;
|
||||
++segment;
|
||||
}
|
||||
throw std::invalid_argument("Invalid segement number.");
|
||||
};
|
||||
|
||||
|
||||
int uri::segment_count() const {
|
||||
int uri::segment_count() const
|
||||
{
|
||||
const char *p = path_start;
|
||||
int segment = 0;
|
||||
|
||||
if (p != path_end && *p == '/') ++p;
|
||||
if (p != path_end && *p == '/')
|
||||
++p;
|
||||
|
||||
while (p != path_end && *p != '?' && *p != '#')
|
||||
{
|
||||
while(p != path_end && *p != '/' && *p != '?' && *p != '#')
|
||||
while (p != path_end && *p != '/' && *p != '?' && *p != '#')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
if (p != path_end && *p == '/') ++p;
|
||||
if (p != path_end && *p == '/')
|
||||
++p;
|
||||
++segment;
|
||||
}
|
||||
return segment;
|
||||
}
|
||||
|
||||
void uri::set(const char*start, const char*end)
|
||||
void uri::set(const char *start, const char *end)
|
||||
{
|
||||
this->text = std::string(start,end);
|
||||
this->text = std::string(start, end);
|
||||
set_();
|
||||
}
|
||||
void uri::set(const char*text)
|
||||
void uri::set(const char *text)
|
||||
{
|
||||
this->text = text;
|
||||
set_();
|
||||
@@ -85,10 +91,10 @@ void uri::set(const char*text)
|
||||
|
||||
void uri::set_()
|
||||
{
|
||||
const char*start = this->text.c_str();
|
||||
const char*end = start+this->text.size();
|
||||
const char *start = this->text.c_str();
|
||||
const char *end = start + this->text.size();
|
||||
|
||||
const char* p = start;
|
||||
const char *p = start;
|
||||
|
||||
this->isRelative = true;
|
||||
|
||||
@@ -96,7 +102,8 @@ void uri::set_()
|
||||
while (p != end)
|
||||
{
|
||||
char c = *p;
|
||||
if (c == ':') {
|
||||
if (c == ':')
|
||||
{
|
||||
hasScheme = true;
|
||||
break;
|
||||
}
|
||||
@@ -111,12 +118,14 @@ void uri::set_()
|
||||
scheme_start = start;
|
||||
scheme_end = p;
|
||||
++p;
|
||||
} else {
|
||||
}
|
||||
else
|
||||
{
|
||||
scheme_start = start;
|
||||
scheme_end = start;
|
||||
p = start;
|
||||
}
|
||||
if (p[0] == '/' && p[1] == '/')
|
||||
if (p[0] == '/' && p[1] == '/')
|
||||
{
|
||||
this->isRelative = false;
|
||||
// authority.
|
||||
@@ -127,20 +136,22 @@ void uri::set_()
|
||||
authority_start = p;
|
||||
authority_end = nullptr;
|
||||
|
||||
const char*port_start = nullptr;
|
||||
const char *port_start = nullptr;
|
||||
while (p != end)
|
||||
{
|
||||
char c = *p;
|
||||
if (c == '@')
|
||||
{
|
||||
user_end = p;
|
||||
authority_start = p+1;
|
||||
authority_start = p + 1;
|
||||
port_start = nullptr;
|
||||
} else if (c == ':')
|
||||
}
|
||||
else if (c == ':')
|
||||
{
|
||||
authority_end = p;
|
||||
port_start = p+1;
|
||||
} else if (c == ']') // ignore colon inside ipv6 address.
|
||||
port_start = p + 1;
|
||||
}
|
||||
else if (c == ']') // ignore colon inside ipv6 address.
|
||||
{
|
||||
port_start = nullptr;
|
||||
}
|
||||
@@ -150,16 +161,22 @@ void uri::set_()
|
||||
}
|
||||
++p;
|
||||
}
|
||||
if (authority_end == nullptr) authority_end = p;
|
||||
if (authority_end == nullptr)
|
||||
authority_end = p;
|
||||
if (port_start != nullptr)
|
||||
{
|
||||
char*intEnd;
|
||||
port_ = (int)std::strtol(port_start,&intEnd,10);
|
||||
if (intEnd != p) throwInvalid();
|
||||
} else {
|
||||
char *intEnd;
|
||||
port_ = (int)std::strtol(port_start, &intEnd, 10);
|
||||
if (intEnd != p)
|
||||
throwInvalid();
|
||||
}
|
||||
else
|
||||
{
|
||||
port_ = -1;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else
|
||||
{
|
||||
user_start = p;
|
||||
user_end = p;
|
||||
authority_start = p;
|
||||
@@ -168,7 +185,8 @@ void uri::set_()
|
||||
}
|
||||
|
||||
path_start = p;
|
||||
if (p != end && *p == '/') this->isRelative = false;
|
||||
if (p != end && *p == '/')
|
||||
this->isRelative = false;
|
||||
|
||||
while (p != end && *p != '?' && *p != '#')
|
||||
{
|
||||
@@ -185,7 +203,9 @@ void uri::set_()
|
||||
++p;
|
||||
}
|
||||
query_end = p;
|
||||
} else {
|
||||
}
|
||||
else
|
||||
{
|
||||
query_start = query_end = p;
|
||||
}
|
||||
if (p != end && *p == '#')
|
||||
@@ -193,62 +213,64 @@ void uri::set_()
|
||||
++p;
|
||||
fragment_start = p;
|
||||
fragment_end = end;
|
||||
|
||||
} else {
|
||||
}
|
||||
else
|
||||
{
|
||||
fragment_start = fragment_end = p;
|
||||
}
|
||||
}
|
||||
|
||||
void uri::throwInvalid() {
|
||||
void uri::throwInvalid()
|
||||
{
|
||||
throw std::invalid_argument("Invalid uri.");
|
||||
|
||||
}
|
||||
|
||||
static bool compare_name(const char*start, const char*end, const char*szName)
|
||||
static bool compare_name(const char *start, const char *end, const char *szName)
|
||||
{
|
||||
while (start != end)
|
||||
{
|
||||
if (*start != *szName) return false;
|
||||
++start; ++szName;
|
||||
if (*start != *szName)
|
||||
return false;
|
||||
++start;
|
||||
++szName;
|
||||
}
|
||||
return *szName == 0;
|
||||
}
|
||||
|
||||
int uri::query_count() const
|
||||
int uri::query_count() const
|
||||
{
|
||||
int count = 0;
|
||||
const char*p = query_start;
|
||||
const char *p = query_start;
|
||||
while (p != query_end && *p != '#')
|
||||
{
|
||||
const char*nameStart = p;
|
||||
const char *nameStart = p;
|
||||
while (p != query_end && *p != '&' && *p != '#')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
if (p != query_end && *p == '&') ++p;
|
||||
if (p != query_end && *p == '&')
|
||||
++p;
|
||||
++count;
|
||||
if (p == query_end || *p == '#')
|
||||
{
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
return count;
|
||||
|
||||
}
|
||||
|
||||
bool uri::has_query(const char*name) const
|
||||
bool uri::has_query(const char *name) const
|
||||
{
|
||||
const char*p = query_start;
|
||||
const char *p = query_start;
|
||||
while (p != query_end && *p != '#')
|
||||
{
|
||||
const char*nameStart = p;
|
||||
const char *nameStart = p;
|
||||
while (p != query_end && *p != '&' && *p != '=' && *p != '#')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
// compare names.
|
||||
if (compare_name(nameStart,p,name))
|
||||
if (compare_name(nameStart, p, name))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -256,111 +278,128 @@ bool uri::has_query(const char*name) const
|
||||
{
|
||||
++p;
|
||||
}
|
||||
if (p != query_end && *p == '&') ++p;
|
||||
if (p != query_end && *p == '&')
|
||||
++p;
|
||||
}
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
query_segment uri::query(int index) const
|
||||
{
|
||||
const char*p = query_start;
|
||||
const char *p = query_start;
|
||||
int ix = 0;
|
||||
while (p != query_end && *p != '#')
|
||||
{
|
||||
const char*nameStart = p;
|
||||
const char *nameStart = p;
|
||||
while (p != query_end && *p != '&' && *p != '=' && *p != '#')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
// compare names.
|
||||
|
||||
//if (compare_name(nameStart,p,name))
|
||||
// if (compare_name(nameStart,p,name))
|
||||
if (ix == index)
|
||||
{
|
||||
const char *nameEnd = p;
|
||||
if (p != query_end && *p == '=')
|
||||
{
|
||||
++p;
|
||||
const char*value_start = p;
|
||||
while(p != query_end && *p != '&' && *p != '#')
|
||||
const char *value_start = p;
|
||||
while (p != query_end && *p != '&' && *p != '#')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
return query_segment(
|
||||
std::string(nameStart,nameEnd),
|
||||
std::string(value_start,p)
|
||||
);
|
||||
|
||||
} else {
|
||||
std::string(nameStart, nameEnd),
|
||||
std::string(value_start, p));
|
||||
}
|
||||
else
|
||||
{
|
||||
return query_segment(
|
||||
std::string(nameStart,nameEnd),
|
||||
""
|
||||
);
|
||||
std::string(nameStart, nameEnd),
|
||||
"");
|
||||
}
|
||||
}
|
||||
while (p != query_end && *p != '&' && *p != '#')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
if (p != query_end && *p == '&')
|
||||
if (p != query_end && *p == '&')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
++ix;
|
||||
|
||||
}
|
||||
throw PiPedalArgumentException("Index out of range.");
|
||||
}
|
||||
|
||||
std::string uri::query(const char*name) const
|
||||
std::optional<std::string> uri::optionalQuery(const char *name) const
|
||||
{
|
||||
const char*p = query_start;
|
||||
const char *p = query_start;
|
||||
while (p != query_end && *p != '#')
|
||||
{
|
||||
const char*nameStart = p;
|
||||
const char *nameStart = p;
|
||||
while (p != query_end && *p != '&' && *p != '=' && *p != '#')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
// compare names.
|
||||
if (compare_name(nameStart,p,name))
|
||||
if (compare_name(nameStart, p, name))
|
||||
{
|
||||
if (p != query_end && *p == '=')
|
||||
{
|
||||
++p;
|
||||
const char*value_start = p;
|
||||
while(p != query_end && *p != '&' && *p != '#')
|
||||
const char *value_start = p;
|
||||
while (p != query_end && *p != '&' && *p != '#')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
return HtmlHelper::decode_url_segment(value_start,p);
|
||||
} else {
|
||||
return "";
|
||||
return HtmlHelper::decode_url_segment(value_start, p);
|
||||
}
|
||||
else
|
||||
{
|
||||
return std::optional<std::string>();
|
||||
}
|
||||
}
|
||||
while (p != query_end && *p != '&' && *p != '#')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
if (p != query_end && *p == '&')
|
||||
if (p != query_end && *p == '&')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
|
||||
}
|
||||
return std::optional<std::string>();
|
||||
}
|
||||
std::string uri::query(const char *name) const
|
||||
{
|
||||
auto result = optionalQuery(name);
|
||||
if (result.has_value())
|
||||
{
|
||||
return result.value();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
std::string uri::requiredQuery(const char *name) const
|
||||
{
|
||||
auto result = optionalQuery(name);
|
||||
if (result.has_value())
|
||||
{
|
||||
return result.value();
|
||||
}
|
||||
throw std::runtime_error("Invalid url.");
|
||||
}
|
||||
|
||||
query_segment uri::query(int index)
|
||||
{
|
||||
const char*p = query_start;
|
||||
const char *p = query_start;
|
||||
int count = 0;
|
||||
while (p != query_end && *p != '#')
|
||||
{
|
||||
if (count == index)
|
||||
{
|
||||
const char*nameStart = p;
|
||||
const char *nameStart = p;
|
||||
while (p != query_end && *p != '&' && *p != '=' && *p != '#')
|
||||
{
|
||||
++p;
|
||||
@@ -368,28 +407,32 @@ query_segment uri::query(int index)
|
||||
const char *nameEnd = p;
|
||||
const char *valueStart, *valueEnd;
|
||||
|
||||
if(p == query_end || *p != '=')
|
||||
if (p == query_end || *p != '=')
|
||||
{
|
||||
valueStart = valueEnd = p;
|
||||
} else {
|
||||
}
|
||||
else
|
||||
{
|
||||
++p;
|
||||
valueStart = p;
|
||||
|
||||
while(p != query_end && *p != '&' && *p != '#')
|
||||
while (p != query_end && *p != '&' && *p != '#')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
valueEnd = p;
|
||||
}
|
||||
return query_segment(std::string(nameStart,nameEnd), HtmlHelper::decode_url_segment(valueStart,valueEnd));
|
||||
} else {
|
||||
return query_segment(std::string(nameStart, nameEnd), HtmlHelper::decode_url_segment(valueStart, valueEnd));
|
||||
}
|
||||
else
|
||||
{
|
||||
while (p != query_end && *p != '&' && *p != '#')
|
||||
{
|
||||
++p;
|
||||
}
|
||||
|
||||
}
|
||||
if (p != query_end && *p == '&') ++p;
|
||||
if (p != query_end && *p == '&')
|
||||
++p;
|
||||
++count;
|
||||
}
|
||||
throw std::invalid_argument("Argument out of range.");
|
||||
@@ -407,66 +450,76 @@ std::vector<std::string> uri::segments()
|
||||
|
||||
std::string uri::get_extension() const
|
||||
{
|
||||
const char*p = this->path_end;
|
||||
const char *p = this->path_end;
|
||||
while (p != this->path_start)
|
||||
{
|
||||
char c = p[-1];
|
||||
if (c == '/') return "";
|
||||
if (c == '.') {
|
||||
--p;
|
||||
return std::string(p,this->path_end-p);
|
||||
}
|
||||
--p;
|
||||
char c = p[-1];
|
||||
if (c == '/')
|
||||
return "";
|
||||
if (c == '.')
|
||||
{
|
||||
--p;
|
||||
return std::string(p, this->path_end - p);
|
||||
}
|
||||
--p;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string uri::to_canonical_form() const {
|
||||
std::string uri::to_canonical_form() const
|
||||
{
|
||||
uri_builder builder(*this);
|
||||
return builder.str();
|
||||
}
|
||||
|
||||
std::string uri_builder::str() const {
|
||||
std::string uri_builder::str() const
|
||||
{
|
||||
std::stringstream s;
|
||||
if (scheme_.length() != 0)
|
||||
{
|
||||
s << scheme_ << ':';
|
||||
}
|
||||
if (authority_.length() != 0) {
|
||||
if (authority_.length() != 0)
|
||||
{
|
||||
s << "//";
|
||||
if (user_.length() != 0)
|
||||
if (user_.length() != 0)
|
||||
{
|
||||
s << user_ << '@';
|
||||
}
|
||||
s << authority_;
|
||||
if (port_ != -1) {
|
||||
if (port_ != -1)
|
||||
{
|
||||
s << ':' << (uint16_t)port_;
|
||||
}
|
||||
}
|
||||
if ( authority_.length() != 0 || !isRelative_) { // canonical root form is http://xyz/ not httP://xyz
|
||||
|
||||
if (authority_.length() != 0 || !isRelative_)
|
||||
{ // canonical root form is http://xyz/ not httP://xyz
|
||||
|
||||
s << '/';
|
||||
}
|
||||
for (size_t i = 0; i < segments_.size(); ++i)
|
||||
{
|
||||
if (i != 0) s << '/';
|
||||
HtmlHelper::encode_url_segment(s,segments_[i],false);
|
||||
if (i != 0)
|
||||
s << '/';
|
||||
HtmlHelper::encode_url_segment(s, segments_[i], false);
|
||||
}
|
||||
for (size_t i = 0; i < queries_.size(); ++i)
|
||||
{
|
||||
if (i == 0)
|
||||
if (i == 0)
|
||||
{
|
||||
s << '?';
|
||||
} else {
|
||||
}
|
||||
else
|
||||
{
|
||||
s << '&';
|
||||
}
|
||||
s << queries_[i].key << "=";
|
||||
HtmlHelper::encode_url_segment(s,queries_[i].value,true);
|
||||
HtmlHelper::encode_url_segment(s, queries_[i].value, true);
|
||||
}
|
||||
if (fragment_.length() != 0)
|
||||
{
|
||||
s << "#" << fragment_;
|
||||
}
|
||||
return s.str();
|
||||
}
|
||||
|
||||
}
|
||||
+209
-184
@@ -24,214 +24,239 @@
|
||||
#include <stdexcept>
|
||||
#include <sstream>
|
||||
#include <cstdlib>
|
||||
#include <optional>
|
||||
|
||||
namespace pipedal {
|
||||
|
||||
|
||||
class query_segment {
|
||||
|
||||
public:
|
||||
query_segment(const std::string &key, const std::string &value)
|
||||
: key(key)
|
||||
, value(value) {
|
||||
|
||||
}
|
||||
const std::string key;
|
||||
const std::string value;
|
||||
|
||||
bool operator==(const query_segment &other)
|
||||
{
|
||||
return key == other.key && value == other.value;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
class uri
|
||||
namespace pipedal
|
||||
{
|
||||
private:
|
||||
std::string text;
|
||||
const char*scheme_start, *scheme_end;
|
||||
const char *user_start, *user_end;
|
||||
const char* authority_start, *authority_end;
|
||||
const char* path_start, *path_end;
|
||||
bool isRelative;
|
||||
int port_;
|
||||
const char*query_start, *query_end;
|
||||
const char*fragment_start, *fragment_end;
|
||||
public:
|
||||
uri()
|
||||
{
|
||||
set("");
|
||||
}
|
||||
const std::string& str() const {
|
||||
return text;
|
||||
}
|
||||
uri(const char*text)
|
||||
{
|
||||
set(text);
|
||||
}
|
||||
uri(const std::string&text)
|
||||
{
|
||||
set(text.c_str());
|
||||
}
|
||||
void set(const char*text);
|
||||
void set(const char*start, const char*end);
|
||||
private:
|
||||
void set_();
|
||||
static void throwInvalid();
|
||||
public:
|
||||
bool has_scheme() const { return scheme_start != scheme_end; }
|
||||
std::string scheme() const{ return std::string(scheme_start, scheme_end); }
|
||||
|
||||
bool has_user() const{ return user_start != user_end; }
|
||||
std::string user() const{ return std::string(user_start,user_end); }
|
||||
|
||||
bool has_authority() const{ return authority_start != authority_end; }
|
||||
std::string authority() const{ return std::string(authority_start,authority_end);}
|
||||
|
||||
bool has_port() const{ return port_ != -1; }
|
||||
int port() const{ return port_; }
|
||||
|
||||
bool is_relative() const { return isRelative; }
|
||||
|
||||
std::string path() const { return std::string(path_start, path_end); }
|
||||
|
||||
int segment_count() const;
|
||||
|
||||
std::string segment(int n) const;
|
||||
|
||||
std::vector<std::string> segments();
|
||||
const std::vector<std::string> segments() const;
|
||||
|
||||
int query_count() const;
|
||||
|
||||
query_segment query(int index);
|
||||
|
||||
bool has_query(const char*name) const;
|
||||
|
||||
std::string query(const char*name) const;
|
||||
query_segment query(int index) const;
|
||||
|
||||
std::string get_extension() const;
|
||||
|
||||
std::string fragment() const { return std::string(fragment_start, fragment_end);}
|
||||
|
||||
std::string to_canonical_form() const;
|
||||
|
||||
};
|
||||
|
||||
class uri_builder {
|
||||
|
||||
private:
|
||||
std::string scheme_ = "http";
|
||||
std::string user_ = "";
|
||||
std::string authority_ = "";
|
||||
int port_ = -1;
|
||||
bool isRelative_ = false;
|
||||
std::vector<std::string> segments_;
|
||||
|
||||
std::vector<query_segment> queries_;
|
||||
std::string fragment_;
|
||||
|
||||
public:
|
||||
uri_builder()
|
||||
class query_segment
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
uri_builder(const uri&uri)
|
||||
: scheme_(uri.scheme()),
|
||||
user_(uri.user()),
|
||||
authority_(uri.authority()),
|
||||
port_(uri.port()),
|
||||
isRelative_(uri.is_relative()),
|
||||
fragment_(uri.fragment())
|
||||
{
|
||||
for (int i = 0; i < uri.segment_count(); ++i)
|
||||
public:
|
||||
query_segment(const std::string &key, const std::string &value)
|
||||
: key(key), value(value)
|
||||
{
|
||||
segments_.push_back(uri.segment(i));
|
||||
}
|
||||
for (int i = 0; i < uri.query_count(); ++i)
|
||||
const std::string key;
|
||||
const std::string value;
|
||||
|
||||
bool operator==(const query_segment &other)
|
||||
{
|
||||
queries_.push_back(uri.query(i));
|
||||
return key == other.key && value == other.value;
|
||||
}
|
||||
}
|
||||
std::string str() const;
|
||||
};
|
||||
|
||||
const std::string & scheme() const { return scheme_; }
|
||||
void set_scheme(const std::string &scheme_) {
|
||||
this->scheme_ = scheme_;
|
||||
}
|
||||
const std::string & user() const { return this->user_;}
|
||||
void set_user(const std::string&user) { this->user_ = user; }
|
||||
|
||||
const std::string &authority() const {
|
||||
return this->authority_;
|
||||
}
|
||||
void set_authority(const std::string &authority) {
|
||||
this->authority_ = authority;
|
||||
}
|
||||
int port() const { return port_; }
|
||||
void set_port(int port) {
|
||||
this->port_ = port;
|
||||
}
|
||||
|
||||
bool is_relative() const { return this->isRelative_ && authority_.length() == 0; }
|
||||
void set_is_relative(bool isRelative)
|
||||
class uri
|
||||
{
|
||||
this->isRelative_ = isRelative;
|
||||
}
|
||||
int segment_count() const { return (int)segments_.size(); }
|
||||
const std::string& segment(int i) const { return segments_[i];}
|
||||
private:
|
||||
std::string text;
|
||||
const char *scheme_start, *scheme_end;
|
||||
const char *user_start, *user_end;
|
||||
const char *authority_start, *authority_end;
|
||||
const char *path_start, *path_end;
|
||||
bool isRelative;
|
||||
int port_;
|
||||
const char *query_start, *query_end;
|
||||
const char *fragment_start, *fragment_end;
|
||||
|
||||
void append_segment(const std::string & segment) {
|
||||
segments_.push_back(segment);
|
||||
}
|
||||
void insert_segment(int position, std::string & segment) {
|
||||
segments_.insert(segments_.begin()+position,segment);
|
||||
}
|
||||
void erase_segment(int position)
|
||||
public:
|
||||
uri()
|
||||
{
|
||||
set("");
|
||||
}
|
||||
const std::string &str() const
|
||||
{
|
||||
return text;
|
||||
}
|
||||
uri(const char *text)
|
||||
{
|
||||
set(text);
|
||||
}
|
||||
uri(const std::string &text)
|
||||
{
|
||||
set(text.c_str());
|
||||
}
|
||||
uri(const uri&other)
|
||||
{
|
||||
set(other.text.c_str());
|
||||
}
|
||||
void set(const char *text);
|
||||
void set(const char *start, const char *end);
|
||||
|
||||
private:
|
||||
void set_();
|
||||
static void throwInvalid();
|
||||
|
||||
public:
|
||||
bool has_scheme() const { return scheme_start != scheme_end; }
|
||||
std::string scheme() const { return std::string(scheme_start, scheme_end); }
|
||||
|
||||
bool has_user() const { return user_start != user_end; }
|
||||
std::string user() const { return std::string(user_start, user_end); }
|
||||
|
||||
bool has_authority() const { return authority_start != authority_end; }
|
||||
std::string authority() const { return std::string(authority_start, authority_end); }
|
||||
|
||||
bool has_port() const { return port_ != -1; }
|
||||
int port() const { return port_; }
|
||||
|
||||
bool is_relative() const { return isRelative; }
|
||||
|
||||
std::string path() const { return std::string(path_start, path_end); }
|
||||
|
||||
int segment_count() const;
|
||||
|
||||
std::string segment(int n) const;
|
||||
|
||||
std::vector<std::string> segments();
|
||||
const std::vector<std::string> segments() const;
|
||||
|
||||
int query_count() const;
|
||||
|
||||
query_segment query(int index);
|
||||
|
||||
bool has_query(const char *name) const;
|
||||
|
||||
std::string query(const char *name) const;
|
||||
std::optional<std::string> optionalQuery(const char *name) const;
|
||||
std::string requiredQuery(const char *name) const;
|
||||
|
||||
query_segment query(int index) const;
|
||||
|
||||
std::string get_extension() const;
|
||||
|
||||
std::string fragment() const { return std::string(fragment_start, fragment_end); }
|
||||
|
||||
std::string to_canonical_form() const;
|
||||
};
|
||||
|
||||
class uri_builder
|
||||
{
|
||||
segments_.erase(segments_.begin()+position);
|
||||
}
|
||||
void replace_segment(int position, std::string& segment) {
|
||||
segments_[position] = segment;
|
||||
}
|
||||
|
||||
private:
|
||||
std::string scheme_ = "http";
|
||||
std::string user_ = "";
|
||||
std::string authority_ = "";
|
||||
int port_ = -1;
|
||||
bool isRelative_ = false;
|
||||
std::vector<std::string> segments_;
|
||||
|
||||
int query_count() const { return (int)(queries_.size());}
|
||||
std::vector<query_segment> queries_;
|
||||
std::string fragment_;
|
||||
|
||||
bool has_query(const std::string &key) const {
|
||||
for (size_t i = 0; i < queries_.size(); ++i)
|
||||
public:
|
||||
uri_builder()
|
||||
{
|
||||
if (queries_[i].key == key) true;
|
||||
}
|
||||
return false;
|
||||
|
||||
}
|
||||
std::string query(const std::string &key) const {
|
||||
for (size_t i = 0; i < queries_.size(); ++i)
|
||||
uri_builder(const uri &uri)
|
||||
: scheme_(uri.scheme()),
|
||||
user_(uri.user()),
|
||||
authority_(uri.authority()),
|
||||
port_(uri.port()),
|
||||
isRelative_(uri.is_relative()),
|
||||
fragment_(uri.fragment())
|
||||
{
|
||||
if (queries_[i].key == key) return queries_[i].value;
|
||||
for (int i = 0; i < uri.segment_count(); ++i)
|
||||
{
|
||||
segments_.push_back(uri.segment(i));
|
||||
}
|
||||
for (int i = 0; i < uri.query_count(); ++i)
|
||||
{
|
||||
queries_.push_back(uri.query(i));
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
std::vector<std::string> queries(const std::string &key) const {
|
||||
std::vector<std::string> result;
|
||||
for (size_t i = 0; i < queries_.size(); ++i)
|
||||
std::string str() const;
|
||||
|
||||
const std::string &scheme() const { return scheme_; }
|
||||
void set_scheme(const std::string &scheme_)
|
||||
{
|
||||
if (queries_[i].key == key) result.push_back(queries_[i].value);
|
||||
this->scheme_ = scheme_;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const query_segment & query(int index) {
|
||||
return queries_[index];
|
||||
}
|
||||
const std::string &user() const { return this->user_; }
|
||||
void set_user(const std::string &user) { this->user_ = user; }
|
||||
|
||||
const std::string&fragment() { return this->fragment_; }
|
||||
void set_fragment(const std::string & fragment) { this->fragment_ = fragment;}
|
||||
const std::string &authority() const
|
||||
{
|
||||
return this->authority_;
|
||||
}
|
||||
void set_authority(const std::string &authority)
|
||||
{
|
||||
this->authority_ = authority;
|
||||
}
|
||||
int port() const { return port_; }
|
||||
void set_port(int port)
|
||||
{
|
||||
this->port_ = port;
|
||||
}
|
||||
|
||||
bool is_relative() const { return this->isRelative_ && authority_.length() == 0; }
|
||||
void set_is_relative(bool isRelative)
|
||||
{
|
||||
this->isRelative_ = isRelative;
|
||||
}
|
||||
int segment_count() const { return (int)segments_.size(); }
|
||||
const std::string &segment(int i) const { return segments_[i]; }
|
||||
|
||||
};
|
||||
void append_segment(const std::string &segment)
|
||||
{
|
||||
segments_.push_back(segment);
|
||||
}
|
||||
void insert_segment(int position, std::string &segment)
|
||||
{
|
||||
segments_.insert(segments_.begin() + position, segment);
|
||||
}
|
||||
void erase_segment(int position)
|
||||
{
|
||||
segments_.erase(segments_.begin() + position);
|
||||
}
|
||||
void replace_segment(int position, std::string &segment)
|
||||
{
|
||||
segments_[position] = segment;
|
||||
}
|
||||
|
||||
void set_path(const std::string &path);
|
||||
|
||||
int query_count() const { return (int)(queries_.size()); }
|
||||
|
||||
bool has_query(const std::string &key) const
|
||||
{
|
||||
for (size_t i = 0; i < queries_.size(); ++i)
|
||||
{
|
||||
if (queries_[i].key == key)
|
||||
true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
std::string query(const std::string &key) const
|
||||
{
|
||||
for (size_t i = 0; i < queries_.size(); ++i)
|
||||
{
|
||||
if (queries_[i].key == key)
|
||||
return queries_[i].value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
std::vector<std::string> queries(const std::string &key) const
|
||||
{
|
||||
std::vector<std::string> result;
|
||||
for (size_t i = 0; i < queries_.size(); ++i)
|
||||
{
|
||||
if (queries_[i].key == key)
|
||||
result.push_back(queries_[i].value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const query_segment &query(int index)
|
||||
{
|
||||
return queries_[index];
|
||||
}
|
||||
void add_query(const std::string &key, const std::string &value)
|
||||
{
|
||||
queries_.push_back(query_segment(key, value));
|
||||
}
|
||||
|
||||
const std::string &fragment() { return this->fragment_; }
|
||||
void set_fragment(const std::string &fragment) { this->fragment_ = fragment; }
|
||||
};
|
||||
}; // namespace pipedal.
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ public:
|
||||
std::istream &get_body_input_stream();
|
||||
|
||||
const std::filesystem::path &get_body_input_file();
|
||||
void detach_body_input_file();
|
||||
size_t content_length() const { return m_content_length; }
|
||||
|
||||
/// Returns the full raw request (including the body)
|
||||
@@ -163,6 +164,13 @@ const std::filesystem::path &request_with_file_upload::get_body_input_file()
|
||||
throw std::runtime_error("Request does not have a body.");
|
||||
return this->m_temporaryFile->Path();
|
||||
}
|
||||
void request_with_file_upload::detach_body_input_file()
|
||||
{
|
||||
if (this->m_temporaryFile)
|
||||
{
|
||||
this->m_temporaryFile->Detach();
|
||||
}
|
||||
}
|
||||
std::istream &request_with_file_upload::get_body_input_stream()
|
||||
{
|
||||
if (!m_outputOpen)
|
||||
@@ -711,6 +719,10 @@ namespace pipedal
|
||||
return m_request.get_body_input_file();
|
||||
}
|
||||
|
||||
virtual void detach_body_temporary_file() override {
|
||||
return m_request.detach_body_input_file();
|
||||
}
|
||||
|
||||
virtual size_t content_length() const { return m_request.content_length(); }
|
||||
|
||||
virtual const std::string &method() const { return m_request.get_method(); }
|
||||
|
||||
@@ -30,6 +30,7 @@ public:
|
||||
//virtual const std::string&body() const = 0;
|
||||
virtual std::istream &get_body_input_stream() = 0;
|
||||
virtual const std::filesystem::path& get_body_temporary_file() = 0;
|
||||
virtual void detach_body_temporary_file() = 0;
|
||||
virtual size_t content_length() const = 0;
|
||||
virtual const std::string &method() const = 0;
|
||||
virtual const std::string&get(const std::string&key) const = 0;
|
||||
|
||||
+229
-18
@@ -81,6 +81,17 @@ static std::string GetMimeType(const std::filesystem::path &path)
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool IsSafeMediaPath(const fs::path path)
|
||||
{
|
||||
if (!HtmlHelper::IsSafeFileName(path)) {
|
||||
return false;
|
||||
}
|
||||
if (!(path.string().starts_with("/var/pipedal/audio_uploads")))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
int32_t ConvertThumbnailSize(const std::string ¶m)
|
||||
{
|
||||
if (param.empty())
|
||||
@@ -128,8 +139,8 @@ private:
|
||||
std::vector<std::string> extensions;
|
||||
};
|
||||
|
||||
|
||||
static fs::path GetTone3000ThumbnailFile(const fs::path &thumbnailDirectory, const std::string & id) {
|
||||
static fs::path GetTone3000ThumbnailFile(const fs::path &thumbnailDirectory, const std::string &id)
|
||||
{
|
||||
std::string stem = id;
|
||||
// find a file in thumbnailDirectory which has a filename of {stem}.*.
|
||||
if (!fs::exists(thumbnailDirectory) || !fs::is_directory(thumbnailDirectory))
|
||||
@@ -150,8 +161,7 @@ static fs::path GetTone3000ThumbnailFile(const fs::path &thumbnailDirectory, con
|
||||
}
|
||||
}
|
||||
return {};
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadIntercept : public RequestHandler
|
||||
{
|
||||
@@ -172,6 +182,14 @@ public:
|
||||
}
|
||||
std::string segment = request_uri.segment(1);
|
||||
|
||||
if (segment == "t3k_uploadAsset")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (segment == "t3k_response.html")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (segment == "tone3000_thumbnail")
|
||||
{
|
||||
return true;
|
||||
@@ -181,6 +199,11 @@ public:
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (segment == "displayMediaFile")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (segment == "uploadPluginPresets")
|
||||
{
|
||||
return true;
|
||||
@@ -330,7 +353,20 @@ public:
|
||||
{
|
||||
std::string segment = request_uri.segment(1);
|
||||
|
||||
if (segment == "tone3000_thumbnail")
|
||||
if (segment == "t3k_uploadAsset")
|
||||
{
|
||||
res.set(HttpField::content_length, "0");
|
||||
return;
|
||||
}
|
||||
if (segment == "t3k_response.html")
|
||||
{
|
||||
std::string mimeType = GetMimeType("x.html");
|
||||
auto text = T3kResponse();
|
||||
res.set(HttpField::content_type, mimeType);
|
||||
res.set(HttpField::content_length, SS(text.size()));
|
||||
return;
|
||||
}
|
||||
else if (segment == "tone3000_thumbnail")
|
||||
{
|
||||
std::string id = request_uri.query("id");
|
||||
if (id == "")
|
||||
@@ -338,7 +374,7 @@ public:
|
||||
throw PiPedalException("Invalid request.");
|
||||
}
|
||||
fs::path path = GetTone3000ThumbnailFile(this->model->Tone3000ThumbnailDirectory(), id);
|
||||
if (!fs::exists(path))
|
||||
if (!fs::exists(path))
|
||||
{
|
||||
throw PiPedalException("File not found.");
|
||||
}
|
||||
@@ -353,7 +389,7 @@ public:
|
||||
return;
|
||||
}
|
||||
|
||||
if (segment == "downloadMediaFile")
|
||||
if (segment == "downloadMediaFile" || segment == "displayMediaFile")
|
||||
{
|
||||
fs::path path = request_uri.query("path");
|
||||
|
||||
@@ -375,8 +411,11 @@ public:
|
||||
}
|
||||
res.set(HttpField::content_type, mimeType);
|
||||
res.set(HttpField::cache_control, "no-cache");
|
||||
std::string disposition = GetContentDispositionHeader(path.stem().string(), path.extension().string());
|
||||
res.set(HttpField::content_disposition, disposition);
|
||||
if (segment == "downloadMediaFile")
|
||||
{
|
||||
std::string disposition = GetContentDispositionHeader(path.stem().string(), path.extension().string());
|
||||
res.set(HttpField::content_disposition, disposition);
|
||||
}
|
||||
size_t contentLength = std::filesystem::file_size(path);
|
||||
res.setContentLength(contentLength);
|
||||
return;
|
||||
@@ -465,18 +504,41 @@ public:
|
||||
bool isInfoFile(const fs::path &path)
|
||||
{
|
||||
auto extension = path.extension();
|
||||
if (extension == ".md" || extension == ".txt")
|
||||
if (extension == ".pdf")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
auto filename = path.stem();
|
||||
if (filename == "LICENSE" || filename == "README")
|
||||
auto filename = path.filename();
|
||||
if (filename == "LICENSE.txt" || filename == "README.txt")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (filename == "LICENSE.md" || filename == "README.md")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if (filename == "license.txt" || filename == "readme.txt")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
std::string T3kResponse()
|
||||
{
|
||||
return "<html>\n"
|
||||
"<head>\n"
|
||||
"<script>\n"
|
||||
" window.close();"
|
||||
"</script>\n"
|
||||
"</head>\n"
|
||||
"<body>\n"
|
||||
"</body>\n"
|
||||
"</html>\n";
|
||||
}
|
||||
|
||||
void DownloadTone3000Tone(const uri &requestUri)
|
||||
{
|
||||
}
|
||||
virtual void get_response(
|
||||
const uri &request_uri,
|
||||
HttpRequest &req,
|
||||
@@ -487,11 +549,15 @@ public:
|
||||
{
|
||||
std::string segment = request_uri.segment(1);
|
||||
|
||||
if (segment == "tone3000_thumbnail")
|
||||
if (segment == "t3k_response.html")
|
||||
{
|
||||
throw std::runtime_error("Not implemented.");
|
||||
}
|
||||
if (segment == "tone3000_thumbnail")
|
||||
{
|
||||
std::string id = request_uri.query("id");
|
||||
fs::path path = GetTone3000ThumbnailFile(this->model->Tone3000ThumbnailDirectory(), id);
|
||||
if (!fs::exists(path))
|
||||
if (!fs::exists(path))
|
||||
{
|
||||
throw PiPedalException("File not found.");
|
||||
}
|
||||
@@ -505,8 +571,8 @@ public:
|
||||
res.setContentLength(contentLength);
|
||||
res.setBodyFile(path, false);
|
||||
return;
|
||||
|
||||
} else if (segment == "downloadMediaFile")
|
||||
}
|
||||
else if (segment == "downloadMediaFile" || segment == "displayMediaFile")
|
||||
{
|
||||
fs::path path = request_uri.query("path");
|
||||
|
||||
@@ -528,8 +594,11 @@ public:
|
||||
}
|
||||
res.set(HttpField::content_type, mimeType);
|
||||
res.set(HttpField::cache_control, "no-cache");
|
||||
std::string disposition = GetContentDispositionHeader(path.stem().string(), path.extension().string());
|
||||
res.set(HttpField::content_disposition, disposition);
|
||||
if (segment == "downloadMediaFile")
|
||||
{
|
||||
std::string disposition = GetContentDispositionHeader(path.stem().string(), path.extension().string());
|
||||
res.set(HttpField::content_disposition, disposition);
|
||||
}
|
||||
size_t contentLength = std::filesystem::file_size(path);
|
||||
res.setContentLength(contentLength);
|
||||
res.setBodyFile(path, false);
|
||||
@@ -826,6 +895,75 @@ public:
|
||||
return true;
|
||||
}
|
||||
|
||||
void UploadEmbeddedZipFile(
|
||||
PiPedalModel *model,
|
||||
pipedal::zip_file_input_stream &si,
|
||||
const std::string &inputFileName,
|
||||
fs::path directory,
|
||||
ExtensionChecker &extensionChecker,
|
||||
int64_t instanceId,
|
||||
const std::string &patchProperty)
|
||||
{
|
||||
TemporaryFile tempFile{WEB_TEMP_DIR};
|
||||
// Extract the zip_file_input_stream to the temporary file.
|
||||
{
|
||||
std::ofstream out(tempFile.Path(), std::ios::binary);
|
||||
if (!out)
|
||||
{
|
||||
throw std::runtime_error("Failed to open temporary file for writing.");
|
||||
}
|
||||
constexpr std::size_t bufferSize = 16384;
|
||||
char buffer[bufferSize];
|
||||
while (si)
|
||||
{
|
||||
si.read(buffer, bufferSize);
|
||||
std::streamsize bytesRead = si.gcount();
|
||||
if (bytesRead > 0)
|
||||
{
|
||||
out.write(buffer, bytesRead);
|
||||
}
|
||||
}
|
||||
out.close();
|
||||
}
|
||||
|
||||
// determine toplevel directory path.
|
||||
auto zipFile = ZipFileReader::Create(tempFile.Path());
|
||||
std::vector<std::string> files = zipFile->GetFiles();
|
||||
bool hasSingleRootDirectory = HasSingleRootDirectory(*zipFile);
|
||||
directory = directory / fs::path(inputFileName).parent_path();
|
||||
if (!hasSingleRootDirectory)
|
||||
{
|
||||
directory = (fs::path(directory) / fs::path(inputFileName).filename().stem()).string();
|
||||
}
|
||||
for (const auto &inputFile : files)
|
||||
{
|
||||
if (!inputFile.ends_with("/")) // don't process directory entries.
|
||||
{
|
||||
fs::path inputPath{inputFile};
|
||||
std::string extension = inputPath.extension();
|
||||
if (extensionChecker.IsValidExtension(extension) || isInfoFile(inputFile))
|
||||
{
|
||||
auto si = zipFile->GetFileInputStream(inputFile);
|
||||
std::string path = this->model->UploadUserFile(directory, instanceId, patchProperty, inputFile, si, zipFile->GetFileSize(inputFile));
|
||||
}
|
||||
else if (extension == ".zip")
|
||||
{
|
||||
// recursively included zip file. :-/
|
||||
auto si = zipFile->GetFileInputStream(inputFile);
|
||||
|
||||
UploadEmbeddedZipFile(
|
||||
this->model,
|
||||
si,
|
||||
inputPath,
|
||||
directory,
|
||||
extensionChecker,
|
||||
instanceId,
|
||||
patchProperty);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
virtual void post_response(
|
||||
const uri &request_uri,
|
||||
HttpRequest &req,
|
||||
@@ -836,6 +974,65 @@ public:
|
||||
{
|
||||
std::string segment = request_uri.segment(1);
|
||||
|
||||
if (segment == "t3k_uploadAsset")
|
||||
{
|
||||
std::string responseText;
|
||||
try
|
||||
{
|
||||
fs::path targetPath = request_uri.query("path");
|
||||
if (targetPath.empty())
|
||||
{
|
||||
throw std::runtime_error("Invalid path");
|
||||
}
|
||||
if (!IsSafeMediaPath(targetPath))
|
||||
{
|
||||
throw std::runtime_error("Unsafe path.");
|
||||
}
|
||||
fs::path filePath = req.get_body_temporary_file();
|
||||
if (filePath.empty())
|
||||
{
|
||||
throw std::runtime_error("Unexpected.");
|
||||
}
|
||||
|
||||
try {
|
||||
fs::create_directories(targetPath.parent_path());
|
||||
// try moving into place.
|
||||
if (fs::exists(targetPath))
|
||||
{
|
||||
fs::remove(targetPath);
|
||||
}
|
||||
fs::rename(req.get_body_temporary_file(), targetPath);
|
||||
req.detach_body_temporary_file();
|
||||
|
||||
} catch (const std::exception &e) {
|
||||
// ok Copy into place instead.
|
||||
fs::copy(req.get_body_temporary_file(), targetPath);
|
||||
}
|
||||
// set target permissions to "pipedal_d:pipedald -rw-rw-r-- if we can.
|
||||
try {
|
||||
fs::permissions(targetPath, fs::perms::owner_read | fs::perms::owner_write |
|
||||
fs::perms::group_read | fs::perms::group_write |
|
||||
fs::perms::others_read,
|
||||
fs::perm_options::replace);
|
||||
} catch (const std::exception&)
|
||||
{
|
||||
// ignore.
|
||||
}
|
||||
|
||||
responseText = "{\"ok\": true}";
|
||||
}
|
||||
catch (const std::exception &e)
|
||||
{
|
||||
std::string jsonString = json_writer::encode_string(e.what());
|
||||
std::ostringstream os;
|
||||
os << "{\"ok\": false, \"error\": " << jsonString << "}";
|
||||
responseText = os.str();
|
||||
}
|
||||
res.set(HttpField::content_length, SS(responseText.size()));
|
||||
res.set(HttpField::content_type, "text/json");
|
||||
res.setBody(responseText);
|
||||
return;
|
||||
}
|
||||
if (segment == "uploadPluginPresets")
|
||||
{
|
||||
PluginPresets presets;
|
||||
@@ -1013,6 +1210,20 @@ public:
|
||||
auto si = zipFile->GetFileInputStream(inputFile);
|
||||
std::string path = this->model->UploadUserFile(directory, instanceId, patchProperty, inputFile, si, zipFile->GetFileSize(inputFile));
|
||||
}
|
||||
else if (extension == ".zip")
|
||||
{
|
||||
// recursively included zip file. :-/
|
||||
auto si = zipFile->GetFileInputStream(inputFile);
|
||||
|
||||
UploadEmbeddedZipFile(
|
||||
this->model,
|
||||
si,
|
||||
inputPath,
|
||||
directory,
|
||||
extensionChecker,
|
||||
instanceId,
|
||||
patchProperty);
|
||||
}
|
||||
}
|
||||
}
|
||||
// set outputPath to the file or folder we would like focus to go to.
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tone3000 Download Received</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script>
|
||||
// Parse URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const toneUrl = urlParams.get('tone_url');
|
||||
|
||||
|
||||
var popupWindow = null;;
|
||||
try {
|
||||
// Send the data back to the opener window
|
||||
if (window.opener) {
|
||||
popupWindow =
|
||||
window.opener.postMessage({
|
||||
type: 'tone3000Download',
|
||||
toneUrl: toneUrl,
|
||||
popupX: window.screenX,
|
||||
popupY: window.screenY,
|
||||
popupWidth: window.innerWidth,
|
||||
popupHeight: window.innerHeight
|
||||
}, '*');
|
||||
} else {
|
||||
alert("No opener window found.");
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// Close the window after sending the data
|
||||
alert(e.message);
|
||||
}
|
||||
window.close();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>T3K Response</title>
|
||||
<script>
|
||||
window.opener.postMessage(
|
||||
{
|
||||
type: "t3k_response",
|
||||
uri: window.location.href,
|
||||
storedState: sessionStorage.getItem('t3k_state'),
|
||||
codeVerifier: sessionStorage.getItem('t3k_code_verifier')
|
||||
}, '*'
|
||||
);
|
||||
function returnResponse() {
|
||||
window.opener.postMessage(
|
||||
{
|
||||
type: "t3k_response",
|
||||
uri: window.location.href,
|
||||
storedState: sessionStorage.getItem('t3k_state'),
|
||||
codeVerifier: sessionStorage.getItem('t3k_code_verifier')
|
||||
}, '*'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body style="background: #000000" onload="returnResponse()"></body>
|
||||
</html>
|
||||
@@ -0,0 +1,148 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tone3000 Download Received</title>
|
||||
</head>
|
||||
|
||||
<body style="background: black;">
|
||||
<script>
|
||||
async function handleOAuthCallback(
|
||||
publishableKey,
|
||||
redirectUri
|
||||
) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
|
||||
alert("Debug me"); // xxx
|
||||
|
||||
const code = params.get('code');
|
||||
const error = params.get('error');
|
||||
const returnedState = params.get('state');
|
||||
const toneId = params.get('tone_id') ?? undefined;
|
||||
const modelId = params.get('model_id') ?? undefined;
|
||||
const canceled = params.get('canceled') === 'true';
|
||||
|
||||
const storedState = sessionStorage.getItem('t3k_state');
|
||||
const codeVerifier = sessionStorage.getItem('t3k_code_verifier');
|
||||
|
||||
// Clean up PKCE state regardless of outcome
|
||||
sessionStorage.removeItem('t3k_state');
|
||||
sessionStorage.removeItem('t3k_code_verifier');
|
||||
|
||||
// Verify state to prevent CSRF
|
||||
if (returnedState !== storedState) {
|
||||
return { ok: false, error: 'state_mismatch' };
|
||||
}
|
||||
|
||||
// User closed without signing in — no code to exchange
|
||||
if (canceled && !code) {
|
||||
return { ok: false, error: 'canceled' };
|
||||
}
|
||||
|
||||
// Access denied — e.g. model is private and user clicked "Back"
|
||||
if (error) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
if (!code || !codeVerifier) {
|
||||
return { ok: false, error: 'missing_code' };
|
||||
}
|
||||
|
||||
const res = await fetch(`${T3K_API}/api/v1/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: publishableKey,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
return { ok: false, error: (err).error ?? 'token_exchange_failed' };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const tokens = {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_at: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
|
||||
return { ok: true, tokens, toneId, modelId, ...(canceled ? { canceled: true } : {}) };
|
||||
}
|
||||
|
||||
|
||||
handleOAuthCallback().then(result => {
|
||||
if (window.opener) {
|
||||
|
||||
|
||||
window.opener.postMessage({
|
||||
type: 'tone3000DownloadCallback',
|
||||
...result,
|
||||
}, '*');
|
||||
} else {
|
||||
alert("No opener window found.");
|
||||
}
|
||||
window.close();
|
||||
}).catch(e => {
|
||||
alert(`Error handling OAuth callback: ${e.message}`);
|
||||
window.close();
|
||||
});
|
||||
// // Parse URL parameters
|
||||
// const urlParams = new URLSearchParams(window.location.search);
|
||||
|
||||
// alert(urlParams.toString());
|
||||
|
||||
// const code = urlParams.get('code');
|
||||
// const error = urlParams.get('error');
|
||||
// const state = urlParams.get('state');
|
||||
// const toneId = urlParams.get('tone_id');
|
||||
// const modelId = urlParams.get('model_id');
|
||||
// const canceled = urlParams.get('canceled') === 'true';
|
||||
|
||||
// if (state !== sessionStorage.getItem('t3k_state')) {
|
||||
// alert('State mismatch. Possible CSRF attack.');
|
||||
// }
|
||||
|
||||
// if (canceled) {
|
||||
// // User exited without selecting a tone.
|
||||
// // If code is present, you can still exchange it for tokens.
|
||||
// // If code is absent, the user closed before signing in.
|
||||
// window.close();
|
||||
// } else {
|
||||
// const toneUrl = urlParams.get('tone_url');
|
||||
|
||||
|
||||
// var popupWindow = null;;
|
||||
// try {
|
||||
// // Send the data back to the opener window
|
||||
// if (window.opener) {
|
||||
// popupWindow =
|
||||
// window.opener.postMessage({
|
||||
// type: 'tone3000Download',
|
||||
// code: code,
|
||||
// error: error,
|
||||
// state: state,
|
||||
// tone_id: toneId,
|
||||
// toneId: toneId
|
||||
// }, '*');
|
||||
// } else {
|
||||
// alert("No opener window found.");
|
||||
// }
|
||||
// }
|
||||
// catch (e) {
|
||||
// alert(e.message);
|
||||
// }
|
||||
// window.close();
|
||||
// }
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -58,7 +58,6 @@ import { PiPedalModelFactory, PiPedalModel, State, ZoomedControlInfo, wantsReloa
|
||||
import ZoomedUiControl from './ZoomedUiControl'
|
||||
import MainPage from './MainPage';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogContentText from '@mui/material/DialogContentText';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import ListSubheader from '@mui/material/ListSubheader';
|
||||
import { BankIndex, BankIndexEntry } from './Banks';
|
||||
@@ -1086,13 +1085,11 @@ export
|
||||
aria-describedby="alert-dialog-description"
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-description">
|
||||
<Typography variant="body2">
|
||||
{
|
||||
this.state.alertDialogMessage
|
||||
}
|
||||
</Typography>
|
||||
</DialogContentText>
|
||||
<Typography variant="body2" id="alert-dialog-description">
|
||||
{
|
||||
this.state.alertDialogMessage
|
||||
}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="dialogPrimary" onClick={this.handleCloseAlert} color="primary" autoFocus>
|
||||
|
||||
@@ -724,7 +724,7 @@ export default withStyles(
|
||||
if (this.state.previousSelection == selectedItem) {
|
||||
return;
|
||||
}
|
||||
if (!this.isTextFile(selectedItem) && !this.isFolderArtwork(selectedItem)) {
|
||||
if (!this.isTextFile(selectedItem) && !this.isFolderArtwork(selectedItem) && !this.isPdfFile(selectedItem)) {
|
||||
this.props.onApply(fileProperty, selectedItem);
|
||||
}
|
||||
this.setState({ previousSelection: selectedItem });
|
||||
@@ -1812,6 +1812,19 @@ export default withStyles(
|
||||
return false;
|
||||
|
||||
}
|
||||
isPdfFile(fileName: string) {
|
||||
let extension = pathExtension(fileName);
|
||||
if (extension === ".pdf") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
}
|
||||
handleShowPdfFile(fileName: string) {
|
||||
// open pdf in new tab
|
||||
this.model.displayMediaFile(fileName);
|
||||
}
|
||||
|
||||
handleShowTextFile(fileName: string) {
|
||||
this.setState({ textFileName: fileName });
|
||||
}
|
||||
@@ -1827,7 +1840,9 @@ export default withStyles(
|
||||
this.requestFiles(this.state.selectedFile);
|
||||
this.setState({ navDirectory: this.state.selectedFile });
|
||||
} else {
|
||||
if (this.isTextFile(this.state.selectedFile)) {
|
||||
if (this.isPdfFile(this.state.selectedFile)) {
|
||||
this.handleShowPdfFile(this.state.selectedFile);
|
||||
} else if (this.isTextFile(this.state.selectedFile)) {
|
||||
this.handleShowTextFile(this.state.selectedFile);
|
||||
} else {
|
||||
this.props.onOk(this.props.fileProperty, this.state.selectedFile);
|
||||
|
||||
@@ -27,7 +27,7 @@ import { Pedalboard, PedalboardItem, ControlValue, Snapshot } from './Pedalboard
|
||||
import PluginClass from './PluginClass';
|
||||
import ScreenOrientation from './ScreenOrientation';
|
||||
import PiPedalSocket, { PiPedalMessageHeader } from './PiPedalSocket';
|
||||
import {Tone3000DownloadHandler} from './Tone3000Dialog';
|
||||
import { Tone3000DownloadHandler } from './Tone3000Downloader';
|
||||
import Tone3000DownloadType from './Tone3000DownloadType';
|
||||
import { nullCast } from './Utility'
|
||||
import { JackConfiguration, JackChannelSelection } from './Jack';
|
||||
@@ -51,6 +51,7 @@ import { getDefaultModGuiPreference } from './ModGuiHost';
|
||||
import ChannelRouterSettings from './ChannelRouterSettings';
|
||||
import Tone3000DownloadProgress from './Tone3000DownloadProgress';
|
||||
|
||||
|
||||
export enum State {
|
||||
Loading,
|
||||
Ready,
|
||||
@@ -64,6 +65,15 @@ export enum State {
|
||||
HotspotChanging,
|
||||
};
|
||||
|
||||
export interface Tone3000PkceParams {
|
||||
publishableKey: string;
|
||||
redirectUrl: string;
|
||||
codeVerifier: string;
|
||||
codeChallenge: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
|
||||
export function getErrorMessage(error: any) {
|
||||
if (error instanceof Error) {
|
||||
return (error as Error).message;
|
||||
@@ -87,6 +97,38 @@ export enum ReconnectReason {
|
||||
HotspotChanging
|
||||
};
|
||||
|
||||
export interface Tone3000ModelInfo {
|
||||
url: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export interface Tone3000DownloadRequest {
|
||||
|
||||
downloadType: Tone3000DownloadType;
|
||||
downloadPath: string;
|
||||
|
||||
appId: string;
|
||||
state: string;
|
||||
codeChallenge: string;
|
||||
codeVerifier: string;
|
||||
|
||||
authToken: string;
|
||||
|
||||
id: number,
|
||||
title: string;
|
||||
description: string;
|
||||
imageUrl: string;
|
||||
models: Tone3000ModelInfo[];
|
||||
|
||||
updated_at: string;
|
||||
user: string;
|
||||
sizes: string[];
|
||||
platform: string;
|
||||
gear: string;
|
||||
license: string;
|
||||
links: string[];
|
||||
};
|
||||
|
||||
export class HostVersion {
|
||||
constructor(hostVersion: string) {
|
||||
this.versionString = hostVersion;
|
||||
@@ -488,6 +530,7 @@ export class PiPedalModel //implements PiPedalModel
|
||||
serverVersion?: PiPedalVersion;
|
||||
countryCodes: { [Name: string]: string } = {};
|
||||
|
||||
serverUrl: string = "";
|
||||
socketServerUrl: string = "";
|
||||
varServerUrl: string = "";
|
||||
modResourcesUrl: string = "";
|
||||
@@ -557,8 +600,8 @@ export class PiPedalModel //implements PiPedalModel
|
||||
);
|
||||
zoomedUiControl: ObservableProperty<ZoomedControlInfo | undefined> = new ObservableProperty<ZoomedControlInfo | undefined>(undefined);
|
||||
|
||||
tone3000Downloading : ObservableProperty<boolean> = new ObservableProperty<boolean>(false);
|
||||
tone3000DownloadProgress : ObservableProperty<Tone3000DownloadProgress | null> = new ObservableProperty<Tone3000DownloadProgress | null>(null);
|
||||
tone3000Downloading: ObservableProperty<boolean> = new ObservableProperty<boolean>(false);
|
||||
tone3000DownloadProgress: ObservableProperty<Tone3000DownloadProgress | null> = new ObservableProperty<Tone3000DownloadProgress | null>(null);
|
||||
|
||||
uiPluginsByUri: Map<string, UiPlugin> = new Map<string, UiPlugin>();
|
||||
|
||||
@@ -664,7 +707,7 @@ export class PiPedalModel //implements PiPedalModel
|
||||
|
||||
}
|
||||
|
||||
async pingTone3000Server() : Promise<boolean> {
|
||||
async pingTone3000Server(): Promise<boolean> {
|
||||
if (this.webSocket === undefined) {
|
||||
return false;
|
||||
|
||||
@@ -677,41 +720,39 @@ export class PiPedalModel //implements PiPedalModel
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async downloadModelsFromTone3000(
|
||||
tone3000DownloadUrl: string,
|
||||
downloadPath: string,
|
||||
downloadType: Tone3000DownloadType
|
||||
downloadRequest: Tone3000DownloadRequest
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!this.webSocket) {
|
||||
return;
|
||||
}
|
||||
this.webSocket.send("downloadModelsFromTone3000",
|
||||
{
|
||||
downloadPath: downloadPath,
|
||||
tone3000Url: tone3000DownloadUrl,
|
||||
downloadType: downloadType
|
||||
});
|
||||
} catch (error) {
|
||||
this.webSocket.send("downloadModelsFromTone3000", downloadRequest);
|
||||
} catch (error) {
|
||||
this.showAlert(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
private cancelTone3000Download(): void {
|
||||
let downloadProgress = this.tone3000DownloadProgress.get();
|
||||
if (downloadProgress !== null) {
|
||||
if (downloadProgress.handle !== -1) {
|
||||
if (!this.webSocket) {
|
||||
this.tone3000DownloadProgress.set(null);
|
||||
this.tone3000Downloading.set(false);
|
||||
return;
|
||||
}
|
||||
this.webSocket.send("cancelTone3000Download",downloadProgress.handle);
|
||||
if (this.tone3000DownloadHandler) {
|
||||
|
||||
this.tone3000DownloadHandler.cancelDownload(this.tone3000DownloadProgress.get()?.handle ?? -1);
|
||||
}
|
||||
// if (downloadProgress.handle !== -1) {
|
||||
// if (!this.webSocket) {
|
||||
// this.tone3000DownloadProgress.set(null);
|
||||
// this.tone3000Downloading.set(false);
|
||||
// return;
|
||||
// }
|
||||
// this.webSocket.send("cancelTone3000Download", downloadProgress.handle);
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
showTone3000DownloadPopup(
|
||||
downloadType: Tone3000DownloadType,
|
||||
downloadType: Tone3000DownloadType,
|
||||
downloadPath: string
|
||||
): void {
|
||||
if (this.tone3000DownloadHandler === null) {
|
||||
@@ -1084,34 +1125,34 @@ export class PiPedalModel //implements PiPedalModel
|
||||
// this.webSocket?.reconnect(); // let the server do it for us.
|
||||
|
||||
}
|
||||
|
||||
|
||||
// TONE3000 download notification handlers (stub implementations)
|
||||
private onTone3000DownloadStarted(handle: number, title: string): void {
|
||||
onTone3000DownloadStarted(handle: number, title: string): void {
|
||||
this.tone3000Downloading.set(true);
|
||||
}
|
||||
|
||||
private onTone3000DownloadProgress(progress: Tone3000DownloadProgress): void {
|
||||
onTone3000DownloadProgress(progress: Tone3000DownloadProgress): void {
|
||||
this.tone3000Downloading.set(true);
|
||||
this.tone3000DownloadProgress.set(progress);
|
||||
}
|
||||
|
||||
private onTone3000DownloadComplete(resultPath: string): void {
|
||||
onTone3000DownloadComplete(resultPath: string): void {
|
||||
this.tone3000Downloading.set(false);
|
||||
this.tone3000DownloadProgress.set(null);
|
||||
this.onTone3000DownloadCompleteEvent.fire(resultPath);
|
||||
}
|
||||
|
||||
private onTone3000DownloadError(handle: number, errorMessage: string): void {
|
||||
onTone3000DownloadError(handle: number, errorMessage: string): void {
|
||||
console.error(`TONE3000 download error: handle=${handle}, error=${errorMessage}`);
|
||||
this.tone3000Downloading.set(false);
|
||||
this.tone3000DownloadProgress.set(null);
|
||||
this.onTone3000DownloadCompleteEvent.fire("");
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
this.showAlert(errorMessage);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
|
||||
setError(message: string): void {
|
||||
this.errorMessage.set(message);
|
||||
this.setState(State.Error);
|
||||
@@ -1220,7 +1261,9 @@ export class PiPedalModel //implements PiPedalModel
|
||||
}
|
||||
private makeVarServerUrl(protocol: string, hostName: string, port: number): string {
|
||||
return protocol + "://" + hostName + ":" + port + "/var/";
|
||||
|
||||
}
|
||||
private makeServerUrl(protocol: string, hostName: string, port: number): string {
|
||||
return protocol + "://" + hostName + ":" + port;
|
||||
}
|
||||
private makeModResourceUrl(protocol: string, hostName: string, port: number): string {
|
||||
return protocol + "://" + hostName + ":" + port + "/resources/";
|
||||
@@ -1303,6 +1346,7 @@ export class PiPedalModel //implements PiPedalModel
|
||||
|
||||
this.socketServerUrl = socket_server;
|
||||
this.varServerUrl = var_server_url;
|
||||
this.serverUrl = this.makeServerUrl("http", socket_server_address, socket_server_port);
|
||||
this.maxFileUploadSize = parseInt(max_upload_size);
|
||||
} catch (error: any) {
|
||||
this.setError("Can't connect to server. " + getErrorMessage(error));
|
||||
@@ -2511,7 +2555,7 @@ export class PiPedalModel //implements PiPedalModel
|
||||
}
|
||||
}).catch((error) => {
|
||||
// failed to subscribe.
|
||||
this.vuSubscriptions[instanceId] = undefined;
|
||||
this.vuSubscriptions[instanceId] = undefined;
|
||||
});
|
||||
|
||||
this.vuSubscriptions[instanceId] = newTarget;
|
||||
@@ -2866,6 +2910,10 @@ export class PiPedalModel //implements PiPedalModel
|
||||
this.webSocket?.send("cancelListenForMidiEvent", listenHandle._handle);
|
||||
}
|
||||
|
||||
displayMediaFile(filePath: string) {
|
||||
let url = this.varServerUrl + "displayMediaFile?path=" + encodeURIComponent(filePath);
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
downloadAudioFile(filePath: string) {
|
||||
let downloadUrl = this.varServerUrl + "downloadMediaFile?path=" + encodeURIComponent(filePath);
|
||||
|
||||
@@ -3803,6 +3851,21 @@ export class PiPedalModel //implements PiPedalModel
|
||||
this.webSocket.send("setChannelRouterSettings", settings);
|
||||
}
|
||||
}
|
||||
DownloadModelsFromTone3000(
|
||||
responseUri: string,
|
||||
tone3000PckceParams: Tone3000PkceParams,
|
||||
downloadPath: string,
|
||||
downloadType: Tone3000DownloadType
|
||||
): void {
|
||||
if (this.webSocket) {
|
||||
this.webSocket.send("DownloadModelsFromTone3000", {
|
||||
responseUri: responseUri,
|
||||
tone3000PckceParams: tone3000PckceParams,
|
||||
downloadPath: downloadPath,
|
||||
downloadType: downloadType === Tone3000DownloadType.Nam ? 0: 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export function safeFilenameEncode(filename: string): string {
|
||||
let result = "";
|
||||
|
||||
for (let i = 0; i < filename.length; i++) {
|
||||
let c = filename[i];
|
||||
let charCode = filename.charCodeAt(i);
|
||||
|
||||
// Handle UTF-16 surrogate pairs
|
||||
@@ -98,7 +99,8 @@ export function safeFilenameEncode(filename: string): string {
|
||||
}
|
||||
|
||||
// Handle control characters (< 0x20)
|
||||
if (charCode < 0x20) {
|
||||
if (charCode < 0x20 || c === '/' || c === '\\' || c === ':')
|
||||
{
|
||||
result += '%' + charCode.toString(16).padStart(2, '0');
|
||||
}
|
||||
// Handle ASCII printable characters (0x20-0x7F), space is NOT encoded
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { JSX } from "@emotion/react/jsx-dev-runtime";
|
||||
import { PiPedalModel, State, getErrorMessage } from "./PiPedalModel";
|
||||
import { PiPedalModel, State } from "./PiPedalModel";
|
||||
import { Button, Typography } from "@mui/material";
|
||||
import { useEffect, useState } from "react";
|
||||
import LinearProgress from "@mui/material/LinearProgress";
|
||||
@@ -7,189 +7,9 @@ import Tone3000DownloadProgress from "./Tone3000DownloadProgress";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import Tone3000DownloadType from "./Tone3000DownloadType";
|
||||
|
||||
|
||||
|
||||
|
||||
export type OnTone3000DownloadHandler = (tone3000DownloadUrl: string) => void;
|
||||
|
||||
// used to check for online status.
|
||||
let TONE3000_PING_URL = "https://www.tone3000.com/robots.txt";
|
||||
|
||||
export class Tone3000DownloadHandler {
|
||||
|
||||
private async handleTone3000Download(tone3000DownloadUrl: string, downloadType: Tone3000DownloadType): Promise<void> {
|
||||
try {
|
||||
await this.model.downloadModelsFromTone3000(tone3000DownloadUrl, this.downloadPath, downloadType);
|
||||
return;
|
||||
} catch (e) {
|
||||
this.handleTone3000DownloadError(getErrorMessage(e));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private messageEventListener: ((event: MessageEvent) => void) | null = null;
|
||||
|
||||
private model: PiPedalModel;
|
||||
private downloadType: Tone3000DownloadType = Tone3000DownloadType.Nam;
|
||||
|
||||
public constructor(model: PiPedalModel) {
|
||||
this.model = model;
|
||||
this.messageEventListener = (event: MessageEvent) => {
|
||||
if (event.data.type !== "tone3000Download") {
|
||||
return;
|
||||
}
|
||||
this.handleTone3000Download(event.data.toneUrl as string, this.downloadType);
|
||||
};
|
||||
|
||||
window.addEventListener("message", this.messageEventListener);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this.messageEventListener) {
|
||||
window.removeEventListener("message", this.messageEventListener);
|
||||
this.messageEventListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
private popupWindow: Window | null = null;
|
||||
|
||||
private appId: string = "pipedal_app4";
|
||||
private redirectUrl(): string {
|
||||
let hostname = window.location.hostname;
|
||||
let port = window.location.port;
|
||||
let protocol = window.location.protocol;
|
||||
let serverUrl: string;
|
||||
if (protocol === "http:" && (port === "80" || port === "")) {
|
||||
serverUrl = `${protocol}//${hostname}`;
|
||||
} else if (protocol === "https:" && (port === "443" || port === "")) {
|
||||
serverUrl = `${protocol}//${hostname}`;
|
||||
} else {
|
||||
serverUrl = `${protocol}//${hostname}:${port}`;
|
||||
}
|
||||
return `${serverUrl}/public/handleTone3000download.html`;
|
||||
}
|
||||
|
||||
private tone3000SelectUrl(downloadType: Tone3000DownloadType): string {
|
||||
let redirectUrl_ = this.redirectUrl();
|
||||
let result = `https://www.tone3000.com/api/v1/select?app_id=${this.appId}&redirect_url=${redirectUrl_}`;
|
||||
if (downloadType === Tone3000DownloadType.CabIr) {
|
||||
result += "&platform=ir";
|
||||
} else if (downloadType === Tone3000DownloadType.Nam) {
|
||||
result += "&platform=nam";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
private handleTone3000DownloadComplete() {
|
||||
if (this.popupWindow) {
|
||||
if (!this.popupWindow.closed) {
|
||||
this.popupWindow.close();
|
||||
}
|
||||
this.popupWindow = null;
|
||||
}
|
||||
this.model.hideTone3000DownloadStatus();
|
||||
|
||||
}
|
||||
closeTone3000Popup() {
|
||||
this.handleTone3000DownloadComplete();
|
||||
}
|
||||
private handleTone3000DownloadError(errorMessage: string) {
|
||||
if (this.open)
|
||||
{
|
||||
this.handleTone3000DownloadComplete();
|
||||
this.model.showAlert(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private downloadPath: string = "";
|
||||
private open = false;
|
||||
|
||||
private launchInstance = 0;
|
||||
public async launchTone3000Popup(
|
||||
downloadType: Tone3000DownloadType,
|
||||
downloadPath: string,
|
||||
|
||||
): Promise<void> {
|
||||
this.closeTone3000Dialog();
|
||||
// online
|
||||
|
||||
this.downloadPath = downloadPath;
|
||||
this.downloadType = downloadType;
|
||||
|
||||
let popupWidth = Math.floor(window.innerWidth * 0.8);
|
||||
let popupHeight = Math.floor(window.innerHeight * 0.8);
|
||||
if (window.innerWidth < 410) {
|
||||
popupWidth = 400;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
|
||||
this.model.showTone3000DownloadStatus();
|
||||
|
||||
let launchInstance = ++this.launchInstance;
|
||||
let cancelled = () => {
|
||||
return (launchInstance !== this.launchInstance) || this.popupWindow == null || this.popupWindow.closed;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
this.popupWindow =
|
||||
window.open(
|
||||
this.tone3000SelectUrl(downloadType),
|
||||
"popup",
|
||||
`innerWidth=${popupWidth},innerHeight=${popupHeight},scrollbars=yes`
|
||||
);
|
||||
|
||||
if (!this.popupWindow) {
|
||||
console.error("Failed to open TONE3000 dialog popup window.");
|
||||
throw new Error("Cannot open popup window.");
|
||||
}
|
||||
// No procedure for detecting 404, so let's ping the server to see if it's reachable, and cancel if it's not.
|
||||
try {
|
||||
let response = await fetch(TONE3000_PING_URL, { method: "HEAD", cache: "no-cache" });
|
||||
if (cancelled()) return;
|
||||
if (!response.ok) {
|
||||
// offline
|
||||
throw new Error("Unable to connect to the TONE3000 server.");
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error("Not online. Unable to connect to the TONE3000 server. Your client device must have access to the internet to use this feature..");
|
||||
};
|
||||
|
||||
let pipedalServerCanReachTone3000 = false;
|
||||
|
||||
// check to see that the PiPedal server can reach TONE3000.
|
||||
try {
|
||||
pipedalServerCanReachTone3000 = await this.model.pingTone3000Server();
|
||||
if (cancelled()) return;
|
||||
} catch (error) {
|
||||
}
|
||||
if (!pipedalServerCanReachTone3000) {
|
||||
throw new Error("The PiPedal server cannot reach a TONE3000 server. The PiPedal server must have access to the internet to use this feature.");
|
||||
}
|
||||
while (true) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
if (launchInstance != this.launchInstance) {
|
||||
// A new dialog launch has been initiated. Stop monitoring this one.
|
||||
return;
|
||||
}
|
||||
if (this.popupWindow == null || this.popupWindow.closed) {
|
||||
this.closeTone3000Dialog();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleTone3000DownloadError(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
public closeTone3000Dialog(): void {
|
||||
this.handleTone3000DownloadComplete();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function Tone3000DownloadStaus(
|
||||
@@ -225,6 +45,9 @@ export function Tone3000DownloadStaus(
|
||||
if (filename === "") {
|
||||
filename = "\u00A0";
|
||||
}
|
||||
if (progress && progress.total !== 0) {
|
||||
filename = `(${progress.progress}/${progress.total}) ${filename}`;
|
||||
}
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
|
||||
@@ -22,9 +22,22 @@
|
||||
*/
|
||||
|
||||
|
||||
export default interface Tone3000DownloadProgress {
|
||||
handle: number;
|
||||
title: string;
|
||||
progress: number;
|
||||
total: number;
|
||||
export default class Tone3000DownloadProgress {
|
||||
|
||||
clone() {
|
||||
let copy = new Tone3000DownloadProgress();
|
||||
copy.handle = this.handle;
|
||||
copy.title = this.title;
|
||||
copy.progress = this.progress;
|
||||
copy.total = this.total;
|
||||
copy.cancelled = this.cancelled;
|
||||
copy.transferring = this.transferring;
|
||||
return copy;
|
||||
}
|
||||
handle: number = 0;
|
||||
title: string = ""
|
||||
progress: number = 0;
|
||||
total: number = 0;
|
||||
cancelled: boolean = false;
|
||||
transferring: boolean = false;
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
|
||||
// Must agree with Tone3000DownloadType.hpp
|
||||
enum Tone3000DownloadType {
|
||||
Nam = "nam",
|
||||
CabIr = "cabir",
|
||||
Nam = 0,
|
||||
CabIr = 1,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,546 @@
|
||||
import { PiPedalModel, getErrorMessage, Tone3000PkceParams } from "./PiPedalModel";
|
||||
import Tone3000DownloadType from "./Tone3000DownloadType";
|
||||
import { PkceParams, buildPkceParams, handleOAuthCallback } from "./t3k/tone3000-client.ts";
|
||||
|
||||
import { Model, PaginatedResponse, Platform, Tone } from "./t3k/types.ts";
|
||||
import { PUBLISHABLE_KEY } from './t3k/config.ts';
|
||||
|
||||
|
||||
import { startSelectFlowPopup, T3KClient } from "./t3k/tone3000-client.ts";
|
||||
import Tone3000DownloadProgress from './Tone3000DownloadProgress';
|
||||
import { safeFilenameEncode } from "./SafeFilename";
|
||||
|
||||
|
||||
// const T3K_API = "https://www.tone3000.com";
|
||||
// used to check for online status.
|
||||
let TONE3000_PING_URL = "https://www.tone3000.com/robots.txt";
|
||||
|
||||
|
||||
async function asyncSleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
|
||||
const RUN_THROTTLER_TEST = true;
|
||||
const ALLOWED_DOWNLOADS_PER_MINUTE = 25;
|
||||
const THROTTLING_TRIGGGER_LEVEL = Math.floor(ALLOWED_DOWNLOADS_PER_MINUTE/2);
|
||||
|
||||
|
||||
class DownloadThrottler {
|
||||
|
||||
private throttleActive: boolean = false;
|
||||
private fifo: number[] = [];
|
||||
private downloadCount: number = 0;
|
||||
|
||||
|
||||
resetThrottling(now: number = Date.now()): void {
|
||||
// clean out entries that are no longer counted by the Ton3000 throttler.
|
||||
while (this.fifo.length > 0 && this.fifo[0] + 60000 <= now) {
|
||||
this.fifo.shift();
|
||||
}
|
||||
|
||||
if (this.fifo.length > THROTTLING_TRIGGGER_LEVEL) {
|
||||
this.throttleActive = true;
|
||||
} else {
|
||||
this.throttleActive = false;
|
||||
}
|
||||
}
|
||||
getThrottleDelay(now : number = Date.now()): number {
|
||||
while (this.fifo.length > 0 && this.fifo[0] + 60000 <= now) {
|
||||
this.fifo.shift();
|
||||
}
|
||||
|
||||
|
||||
let downloadCount = this.downloadCount;
|
||||
let delay: number;
|
||||
if (downloadCount < THROTTLING_TRIGGGER_LEVEL) {
|
||||
delay = 0;
|
||||
} else if (downloadCount < ALLOWED_DOWNLOADS_PER_MINUTE) {
|
||||
delay = Math.ceil((60000 / (ALLOWED_DOWNLOADS_PER_MINUTE-THROTTLING_TRIGGGER_LEVEL)));
|
||||
} else {
|
||||
delay = Math.ceil(60000 / (ALLOWED_DOWNLOADS_PER_MINUTE-1));
|
||||
}
|
||||
this.fifo.push(now + delay);
|
||||
console.debug(` ${now}: Download: ${downloadCount} Delay: ${delay} ms Throttle count: ${this.fifo.length}`);
|
||||
++this.downloadCount;
|
||||
return delay;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function runThrottlerTest() {
|
||||
// just dump the delay times for a few downloads to the debugger console, to verify that the throttler is working as expected;
|
||||
let throttler = new DownloadThrottler();
|
||||
|
||||
let download = 0;
|
||||
let now = 0;
|
||||
console.debug("Tone3000 Throttler Test");
|
||||
|
||||
while (download < ALLOWED_DOWNLOADS_PER_MINUTE *4) {
|
||||
let delay = throttler.getThrottleDelay(now);
|
||||
now += delay;
|
||||
++download;
|
||||
}
|
||||
}
|
||||
|
||||
export class Tone3000DownloadHandler {
|
||||
|
||||
// private async handleTone3000Download(
|
||||
// code: string, toneId: String, downloadType: Tone3000DownloadType): Promise<void>
|
||||
// {
|
||||
// const {access_token} = await exchangeCode(code);
|
||||
// try {
|
||||
// const xxx;
|
||||
// await this.model.downloadModelsFromTone3000(tone3000DownloadUrl, this.downloadPath, downloadType);
|
||||
// return;
|
||||
// } catch (e) {
|
||||
// this.handleTone3000DownloadError(getErrorMessage(e));
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
|
||||
private messageEventListener: ((event: MessageEvent) => void) | null = null;
|
||||
private t3kClient: T3KClient;
|
||||
private throttler: DownloadThrottler = new DownloadThrottler();
|
||||
|
||||
private model: PiPedalModel;
|
||||
pkceParams: PkceParams | null = null;
|
||||
|
||||
|
||||
public constructor(model: PiPedalModel) {
|
||||
if (RUN_THROTTLER_TEST) {
|
||||
runThrottlerTest();
|
||||
}
|
||||
this.model = model;
|
||||
this.t3kClient = new T3KClient(
|
||||
PUBLISHABLE_KEY,
|
||||
() => {
|
||||
model.showAlert("TONE3000 authentication failed. Please try again.");
|
||||
}
|
||||
);
|
||||
let this_ = this;
|
||||
this.messageEventListener = (event: MessageEvent) => {
|
||||
if (event.data?.type === "t3k_response") {
|
||||
let uri = event.data.uri;
|
||||
this.handleTone3000DownloadComplete();
|
||||
if (uri) {
|
||||
this_.handleT3kSelectResponse(uri, event.data.storedState, event.data.codeVerifier)
|
||||
.then(() => { })
|
||||
.catch((error) => {
|
||||
model.showAlert(getErrorMessage(error));
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
window.addEventListener("message", this.messageEventListener);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this.messageEventListener) {
|
||||
window.removeEventListener("message", this.messageEventListener);
|
||||
this.messageEventListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleT3kSelectResponse(
|
||||
uri: string,
|
||||
popupStoredState: string | null,
|
||||
popupCodeVerifier: string | null
|
||||
): Promise<void> {
|
||||
if (this.pkceParams === null) {
|
||||
this.model.showAlert("Invalid PKCE parameters. Please try again.");
|
||||
return;
|
||||
|
||||
}
|
||||
let tone3000PckceParams: Tone3000PkceParams = {
|
||||
publishableKey: PUBLISHABLE_KEY,
|
||||
redirectUrl: this.redirectUrl(),
|
||||
codeChallenge: this.pkceParams.codeChallenge,
|
||||
codeVerifier: this.pkceParams.codeVerifier,
|
||||
state: this.pkceParams.state
|
||||
|
||||
};
|
||||
|
||||
this.DownloadModelsFromTone3000(uri, tone3000PckceParams, this.downloadPath, this.downloadType);
|
||||
}
|
||||
|
||||
private handle = 0;
|
||||
|
||||
private nextHandle(): number {
|
||||
return ++this.handle;
|
||||
}
|
||||
|
||||
|
||||
private progress: Tone3000DownloadProgress = new Tone3000DownloadProgress();
|
||||
|
||||
private async throttleRequests(): Promise<void> {
|
||||
let now = Date.now();
|
||||
let delay = this.throttler.getThrottleDelay(now);
|
||||
while (delay > 0) {
|
||||
await asyncSleep(250);
|
||||
if (this.checkForCancel())
|
||||
{
|
||||
return;
|
||||
}
|
||||
delay -= 250;
|
||||
}
|
||||
}
|
||||
|
||||
private onTone3000DownloadStarted() {
|
||||
this.throttler.resetThrottling(); // allow full-rate burst of downloads at the start of each tone download.
|
||||
|
||||
this.progress.handle = this.nextHandle();
|
||||
this.progress.title = "";
|
||||
this.progress.progress = 0;
|
||||
this.progress.total = 0;
|
||||
this.progress.cancelled = false;
|
||||
this.progress.transferring = true;
|
||||
this.model.onTone3000DownloadStarted(this.progress.handle, this.progress.title);
|
||||
}
|
||||
private onTone3000DownloadProgress(progress: Tone3000DownloadProgress) {
|
||||
let newProgress = progress.clone();
|
||||
this.model.onTone3000DownloadProgress(newProgress);
|
||||
}
|
||||
private onTone3000DownloadError(errorMessage: string) {
|
||||
this.progress.transferring = false;
|
||||
this.model.onTone3000DownloadError(this.progress.handle, errorMessage);
|
||||
}
|
||||
private onTone3000DownloadComplete(resultPath: string) {
|
||||
this.progress.transferring = false;
|
||||
this.model.onTone3000DownloadComplete(resultPath);
|
||||
}
|
||||
private async DownloadModelsFromTone3000(
|
||||
responseUri: string,
|
||||
tone3000PckceParams: Tone3000PkceParams,
|
||||
downloadPath: string,
|
||||
downloadType: Tone3000DownloadType
|
||||
): Promise<void> {
|
||||
try {
|
||||
this.onTone3000DownloadStarted();
|
||||
this.progress.title = "Authenticating..."
|
||||
this.onTone3000DownloadProgress(this.progress);
|
||||
if (this.checkForCancel()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.throttleRequests();
|
||||
let tokenResponse = await handleOAuthCallback(
|
||||
PUBLISHABLE_KEY,
|
||||
this.redirectUrl(),
|
||||
responseUri);
|
||||
if (!tokenResponse.ok) {
|
||||
;
|
||||
throw new Error(tokenResponse.error);
|
||||
}
|
||||
if (this.checkForCancel()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.t3kClient.setTokens(tokenResponse.tokens);
|
||||
|
||||
if (tokenResponse.toneId == undefined) {
|
||||
throw new Error("No tone selected. Please try again.");
|
||||
}
|
||||
let toneId = tokenResponse.toneId;
|
||||
|
||||
this.progress.title = "Fetching tone information...";
|
||||
this.onTone3000DownloadProgress(this.progress);
|
||||
|
||||
|
||||
await this.throttleRequests();
|
||||
let tone: Tone = await this.t3kClient.getTone(tokenResponse.toneId);
|
||||
|
||||
if (this.checkForCancel()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.progress.title = tone.title;
|
||||
this.onTone3000DownloadProgress(this.progress);
|
||||
|
||||
let page = 1;
|
||||
let models: Model[] = [];
|
||||
let architectureFilter: number | undefined = undefined;
|
||||
// Take A2 models only for NAM downloads, and only if the tone actually has A2 models.
|
||||
if (downloadType === Tone3000DownloadType.Nam && !!tone.a2_models_count) {
|
||||
architectureFilter = 2; // NAM models only
|
||||
}
|
||||
|
||||
while (true) {
|
||||
await this.throttleRequests();
|
||||
if (this.checkForCancel()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let modelsThisTime: PaginatedResponse<Model> = await this.t3kClient.listModels(toneId, page, 800, architectureFilter);
|
||||
models.push(...modelsThisTime.data);
|
||||
if (modelsThisTime.total_pages <= page) {
|
||||
break;
|
||||
}
|
||||
++page;
|
||||
}
|
||||
let uploadPath = this.downloadPath;
|
||||
let extension: string;
|
||||
|
||||
switch (tone.platform) {
|
||||
case Platform.Nam:
|
||||
extension = ".nam";
|
||||
break;
|
||||
case Platform.Ir:
|
||||
extension = ".wav";
|
||||
break;
|
||||
case Platform.Proteus:
|
||||
extension = ".json";
|
||||
break;
|
||||
default:
|
||||
throw new Error("Unsupported platform: " + tone.platform);
|
||||
break;
|
||||
}
|
||||
let toneUploadPath = uploadPath + "/" + safeFilenameEncode(tone.title) + "/";
|
||||
this.progress.total = models.length;
|
||||
this.onTone3000DownloadProgress(this.progress);
|
||||
|
||||
let lastUpdateTime = Date.now();
|
||||
|
||||
|
||||
|
||||
for (const model of models) {
|
||||
this.progress.title = model.name;;
|
||||
if (Date.now() - lastUpdateTime > 250) {
|
||||
this.onTone3000DownloadProgress(this.progress);
|
||||
lastUpdateTime = Date.now();
|
||||
}
|
||||
|
||||
await this.throttleRequests();
|
||||
|
||||
if (!model.model_url) {
|
||||
throw new Error("Model " + model.name + " does not have a model URL.");
|
||||
}
|
||||
let modelUploadPath = toneUploadPath + safeFilenameEncode(model.name) + extension;
|
||||
|
||||
let accessToken = await this.t3kClient.getAccessToken();
|
||||
let modelResult = await fetch(model.model_url,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
duplex: 'half',
|
||||
} as RequestInit & { duplex: 'half' }
|
||||
|
||||
);
|
||||
if (!modelResult.ok) {
|
||||
throw new Error(`Model download failed: ${modelResult.status} ${modelResult.statusText}`);
|
||||
}
|
||||
if (this.checkForCancel()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let serverUrl = this.model.varServerUrl + "t3k_uploadAsset?path="
|
||||
+ encodeURIComponent(modelUploadPath);
|
||||
|
||||
// get the content length of modelResult from modelResult headers (modelresult is the return value from fetch())
|
||||
const strContentLength = modelResult.headers.get('Content-Length');
|
||||
let contentLength: number = 0;
|
||||
if (strContentLength) {
|
||||
contentLength = parseInt(strContentLength);
|
||||
if (isNaN(contentLength)) {
|
||||
throw new Error("File download from Tone3000 server failed: Content-Length header is invalid.");
|
||||
}
|
||||
|
||||
}
|
||||
// POST binary modelResult to serverUrl.
|
||||
// Prefer streaming request body when available, else fall back to blob().
|
||||
const uploadBody: Blob =
|
||||
await modelResult.blob();
|
||||
const uploadResponse = await fetch(serverUrl, {
|
||||
method: 'POST',
|
||||
body: uploadBody,
|
||||
headers: {
|
||||
'Content-Type': modelResult.headers.get('Content-Type') ?? 'application/octet-stream',
|
||||
"Content-Length": contentLength.toString(),
|
||||
"Transfer-Encoding": "chunked"
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`Upload failed: ${uploadResponse.statusText}`);
|
||||
}
|
||||
// discard the response body, if any, to free up memory
|
||||
let uploadResult: any = await uploadResponse.json();
|
||||
|
||||
if (!uploadResult.ok === true) {
|
||||
throw new Error(`Upload failed: ${uploadResult.error ?? "Unknown error."}`);
|
||||
}
|
||||
this.progress.progress++;
|
||||
|
||||
}
|
||||
// yyy: get the image file.
|
||||
// yyy: write the reaadme file.
|
||||
|
||||
this.onTone3000DownloadComplete(uploadPath);
|
||||
} catch (error) {
|
||||
this.onTone3000DownloadError(getErrorMessage(error)
|
||||
+ " " + this.progress.progress.toString() + "/" + this.progress.total.toString() + " models downloaded.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private popupWindow: Window | null = null;
|
||||
|
||||
private redirectUrl(): string {
|
||||
let hostname = window.location.hostname;
|
||||
let port = window.location.port;
|
||||
let protocol = window.location.protocol;
|
||||
let serverUrl: string;
|
||||
if (protocol === "http:" && (port === "80" || port === "")) {
|
||||
serverUrl = `${protocol}//${hostname}`;
|
||||
} else if (protocol === "https:" && (port === "443" || port === "")) {
|
||||
serverUrl = `${protocol}//${hostname}`;
|
||||
} else {
|
||||
serverUrl = `${protocol}//${hostname}:${port}`;
|
||||
}
|
||||
return `${serverUrl}/html/t3k_response.html`;
|
||||
// return `${serverUrl}/t3k_response.html`; // for a debuggable react version of the page
|
||||
}
|
||||
|
||||
|
||||
|
||||
private handleTone3000DownloadComplete() {
|
||||
if (this.popupWindow) {
|
||||
if (!this.popupWindow.closed) {
|
||||
this.popupWindow.close();
|
||||
}
|
||||
this.popupWindow = null;
|
||||
}
|
||||
this.progress.transferring = false;
|
||||
this.model.hideTone3000DownloadStatus();
|
||||
|
||||
}
|
||||
closeTone3000Popup() {
|
||||
this.handleTone3000DownloadComplete();
|
||||
}
|
||||
private handleTone3000DownloadError(errorMessage: string) {
|
||||
this.progress.transferring = false;
|
||||
this.handleTone3000DownloadComplete();
|
||||
this.model.showAlert(errorMessage);
|
||||
}
|
||||
|
||||
private downloadPath: string = "";
|
||||
private downloadType: Tone3000DownloadType = Tone3000DownloadType.Nam;
|
||||
|
||||
private checkForCancel(): boolean {
|
||||
if (this.progress.cancelled) {
|
||||
this.handleTone3000DownloadComplete();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private launchInstance = 0;
|
||||
public async launchTone3000Popup(
|
||||
downloadType: Tone3000DownloadType,
|
||||
downloadPath: string,
|
||||
|
||||
): Promise<void> {
|
||||
if (this.progress.transferring) {
|
||||
return;
|
||||
}
|
||||
this.closeTone3000Dialog();
|
||||
// online
|
||||
|
||||
this.downloadPath = downloadPath;
|
||||
this.downloadType = downloadType;
|
||||
|
||||
let popupWidth = Math.floor(window.innerWidth * 0.8);
|
||||
let popupHeight = Math.floor(window.innerHeight * 0.8);
|
||||
if (window.innerWidth < 410) {
|
||||
popupWidth = 400;
|
||||
}
|
||||
|
||||
this.model.showTone3000DownloadStatus();
|
||||
|
||||
let launchInstance = ++this.launchInstance;
|
||||
let cancelled = () => {
|
||||
return (launchInstance !== this.launchInstance) || this.popupWindow == null || this.popupWindow.closed;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
let pkceParams = await buildPkceParams();
|
||||
this.pkceParams = pkceParams;
|
||||
this.popupWindow = await startSelectFlowPopup(
|
||||
pkceParams,
|
||||
PUBLISHABLE_KEY,
|
||||
this.redirectUrl(),
|
||||
{
|
||||
architecture: downloadType === Tone3000DownloadType.CabIr ? undefined : 2,
|
||||
platform: downloadType === Tone3000DownloadType.CabIr ? Platform.Ir : Platform.Nam,
|
||||
menubar: true,
|
||||
width: popupWidth,
|
||||
height: popupHeight
|
||||
},
|
||||
|
||||
);
|
||||
|
||||
if (!this.popupWindow) {
|
||||
console.error("Failed to open TONE3000 dialog popup window.");
|
||||
throw new Error("Cannot open popup window.");
|
||||
}
|
||||
// No procedure for detecting 404, so let's ping the server to see if it's reachable, and cancel if it's not.
|
||||
try {
|
||||
let response = await fetch(TONE3000_PING_URL, { method: "HEAD", cache: "no-cache" });
|
||||
if (cancelled()) return;
|
||||
if (!response.ok) {
|
||||
// offline
|
||||
throw new Error("Unable to connect to the TONE3000 server.");
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error("Not online. Unable to connect to the TONE3000 server. Your client device must have access to the internet to use this feature..");
|
||||
};
|
||||
|
||||
let pipedalServerCanReachTone3000 = false;
|
||||
|
||||
// check to see that the PiPedal server can reach TONE3000.
|
||||
try {
|
||||
pipedalServerCanReachTone3000 = await this.model.pingTone3000Server();
|
||||
if (cancelled()) return;
|
||||
} catch (error) {
|
||||
}
|
||||
if (!pipedalServerCanReachTone3000) {
|
||||
throw new Error("The PiPedal server cannot reach a TONE3000 server. The PiPedal server must have access to the internet to use this feature.");
|
||||
}
|
||||
while (true) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
if (launchInstance != this.launchInstance) {
|
||||
// A new dialog launch has been initiated. Stop monitoring this one.
|
||||
return;
|
||||
}
|
||||
if (this.popupWindow === null || this.popupWindow.closed) {
|
||||
this.closeTone3000Dialog();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleTone3000DownloadError(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
cancelDownload(handle: number): void {
|
||||
this.closeTone3000Dialog();
|
||||
if (this.progress.handle == handle) {
|
||||
this.progress.cancelled = true;
|
||||
}
|
||||
}
|
||||
public closeTone3000Dialog(): void {
|
||||
if (this.popupWindow !== null) {
|
||||
this.popupWindow.close();
|
||||
this.popupWindow = null;
|
||||
if (!this.progress.transferring) {
|
||||
this.handleTone3000DownloadComplete();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { PiPedalModel, getErrorMessage, Tone3000PkceParams } from "./PiPedalModel";
|
||||
import Tone3000DownloadType from "./Tone3000DownloadType";
|
||||
import { PkceParams, buildPkceParams } from "./t3k/tone3000-client.ts";
|
||||
|
||||
import { Platform } from "./t3k/types.ts";
|
||||
import { PUBLISHABLE_KEY } from './t3k/config.ts';
|
||||
|
||||
|
||||
import { startSelectFlowPopup} from "./t3k/tone3000-client.ts";
|
||||
|
||||
|
||||
// const T3K_API = "https://www.tone3000.com";
|
||||
// used to check for online status.
|
||||
let TONE3000_PING_URL = "https://www.tone3000.com/robots.txt";
|
||||
|
||||
|
||||
export class Tone3000DownloadHandler {
|
||||
|
||||
// private async handleTone3000Download(
|
||||
// code: string, toneId: String, downloadType: Tone3000DownloadType): Promise<void>
|
||||
// {
|
||||
// const {access_token} = await exchangeCode(code);
|
||||
// try {
|
||||
// const xxx;
|
||||
// await this.model.downloadModelsFromTone3000(tone3000DownloadUrl, this.downloadPath, downloadType);
|
||||
// return;
|
||||
// } catch (e) {
|
||||
// this.handleTone3000DownloadError(getErrorMessage(e));
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
|
||||
private messageEventListener: ((event: MessageEvent) => void) | null = null;
|
||||
|
||||
private model: PiPedalModel;
|
||||
pkceParams: PkceParams | null = null;
|
||||
|
||||
|
||||
public constructor(model: PiPedalModel) {
|
||||
this.model = model;
|
||||
let this_ = this;
|
||||
this.messageEventListener = (event: MessageEvent) => {
|
||||
if (event.data?.type === "t3k_response") {
|
||||
let uri = event.data.uri;
|
||||
this.handleTone3000DownloadComplete();
|
||||
if (uri) {
|
||||
this_.handleT3kSelectResponse(uri)
|
||||
.then(() => { })
|
||||
.catch((error) => {
|
||||
;
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
window.addEventListener("message", this.messageEventListener);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this.messageEventListener) {
|
||||
window.removeEventListener("message", this.messageEventListener);
|
||||
this.messageEventListener = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleT3kSelectResponse(
|
||||
uri: string,
|
||||
): Promise<void> {
|
||||
if (this.pkceParams === null) {
|
||||
this.model.showAlert("Invalid PKCE parameters. Please try again.");
|
||||
return;
|
||||
|
||||
}
|
||||
let tone3000PckceParams: Tone3000PkceParams = {
|
||||
publishableKey: PUBLISHABLE_KEY,
|
||||
redirectUrl: this.redirectUrl(),
|
||||
codeChallenge: this.pkceParams.codeChallenge,
|
||||
codeVerifier: this.pkceParams.codeVerifier,
|
||||
state: this.pkceParams.state
|
||||
|
||||
};
|
||||
|
||||
this.model.DownloadModelsFromTone3000(uri, tone3000PckceParams, this.downloadPath, this.downloadType);
|
||||
}
|
||||
|
||||
private popupWindow: Window | null = null;
|
||||
|
||||
private redirectUrl(): string {
|
||||
let hostname = window.location.hostname;
|
||||
let port = window.location.port;
|
||||
let protocol = window.location.protocol;
|
||||
let serverUrl: string;
|
||||
if (protocol === "http:" && (port === "80" || port === "")) {
|
||||
serverUrl = `${protocol}//${hostname}`;
|
||||
} else if (protocol === "https:" && (port === "443" || port === "")) {
|
||||
serverUrl = `${protocol}//${hostname}`;
|
||||
} else {
|
||||
serverUrl = `${protocol}//${hostname}:${port}`;
|
||||
}
|
||||
return `${serverUrl}/t3k_response.html`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private handleTone3000DownloadComplete() {
|
||||
if (this.popupWindow) {
|
||||
if (!this.popupWindow.closed) {
|
||||
this.popupWindow.close();
|
||||
}
|
||||
this.popupWindow = null;
|
||||
}
|
||||
this.model.hideTone3000DownloadStatus();
|
||||
|
||||
}
|
||||
closeTone3000Popup() {
|
||||
this.handleTone3000DownloadComplete();
|
||||
}
|
||||
private handleTone3000DownloadError(errorMessage: string) {
|
||||
if (this.open) {
|
||||
this.handleTone3000DownloadComplete();
|
||||
this.model.showAlert(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
private downloadPath: string = "";
|
||||
private downloadType: Tone3000DownloadType = Tone3000DownloadType.Nam;
|
||||
private open = false;
|
||||
|
||||
private launchInstance = 0;
|
||||
public async launchTone3000Popup(
|
||||
downloadType: Tone3000DownloadType,
|
||||
downloadPath: string,
|
||||
|
||||
): Promise<void> {
|
||||
this.closeTone3000Dialog();
|
||||
// online
|
||||
|
||||
this.downloadPath = downloadPath;
|
||||
this.downloadType = downloadType;
|
||||
|
||||
let popupWidth = Math.floor(window.innerWidth * 0.8);
|
||||
let popupHeight = Math.floor(window.innerHeight * 0.8);
|
||||
if (window.innerWidth < 410) {
|
||||
popupWidth = 400;
|
||||
}
|
||||
|
||||
this.open = true;
|
||||
|
||||
this.model.showTone3000DownloadStatus();
|
||||
|
||||
let launchInstance = ++this.launchInstance;
|
||||
let cancelled = () => {
|
||||
return (launchInstance !== this.launchInstance) || this.popupWindow == null || this.popupWindow.closed;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
let pkceParams = await buildPkceParams();
|
||||
this.pkceParams = pkceParams;
|
||||
this.popupWindow = await startSelectFlowPopup(
|
||||
pkceParams,
|
||||
PUBLISHABLE_KEY,
|
||||
this.redirectUrl(),
|
||||
{
|
||||
architecture: downloadType === Tone3000DownloadType.CabIr ? undefined : 2,
|
||||
platform: downloadType === Tone3000DownloadType.CabIr ? Platform.Ir : Platform.Nam,
|
||||
menubar: true,
|
||||
width: popupWidth,
|
||||
height: popupHeight
|
||||
},
|
||||
|
||||
);
|
||||
|
||||
if (!this.popupWindow) {
|
||||
console.error("Failed to open TONE3000 dialog popup window.");
|
||||
throw new Error("Cannot open popup window.");
|
||||
}
|
||||
// No procedure for detecting 404, so let's ping the server to see if it's reachable, and cancel if it's not.
|
||||
try {
|
||||
let response = await fetch(TONE3000_PING_URL, { method: "HEAD", cache: "no-cache" });
|
||||
if (cancelled()) return;
|
||||
if (!response.ok) {
|
||||
// offline
|
||||
throw new Error("Unable to connect to the TONE3000 server.");
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error("Not online. Unable to connect to the TONE3000 server. Your client device must have access to the internet to use this feature..");
|
||||
};
|
||||
|
||||
let pipedalServerCanReachTone3000 = false;
|
||||
|
||||
// check to see that the PiPedal server can reach TONE3000.
|
||||
try {
|
||||
pipedalServerCanReachTone3000 = await this.model.pingTone3000Server();
|
||||
if (cancelled()) return;
|
||||
} catch (error) {
|
||||
}
|
||||
if (!pipedalServerCanReachTone3000) {
|
||||
throw new Error("The PiPedal server cannot reach a TONE3000 server. The PiPedal server must have access to the internet to use this feature.");
|
||||
}
|
||||
while (true) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
if (launchInstance != this.launchInstance) {
|
||||
// A new dialog launch has been initiated. Stop monitoring this one.
|
||||
return;
|
||||
}
|
||||
if (this.popupWindow == null || this.popupWindow.closed) {
|
||||
this.closeTone3000Dialog();
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.handleTone3000DownloadError(getErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
public closeTone3000Dialog(): void {
|
||||
this.handleTone3000DownloadComplete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// config.ts — TONE3000 API configuration
|
||||
// T3K_API points to production. VITE_T3K_API_DOMAIN can override for development.
|
||||
// Trailing slashes are stripped so `${T3K_API}/api/...` never produces a double slash —
|
||||
// Vercel 308-redirects double-slash paths, and redirects drop CORS headers.
|
||||
export const T3K_API = (
|
||||
(import.meta.env.VITE_T3K_API_DOMAIN as string | undefined) ?? 'https://www.tone3000.com'
|
||||
).replace(/\/+$/, '');
|
||||
|
||||
export const PUBLISHABLE_KEY = import.meta.env.VITE_PUBLISHABLE_KEY as string;
|
||||
|
||||
// Per-demo keys — fall back to the shared PUBLISHABLE_KEY for local dev
|
||||
export const PUBLISHABLE_KEY_SELECT =
|
||||
(import.meta.env.VITE_PUBLISHABLE_KEY_SELECT as string | undefined) ?? PUBLISHABLE_KEY;
|
||||
export const PUBLISHABLE_KEY_LOAD =
|
||||
(import.meta.env.VITE_PUBLISHABLE_KEY_LOAD as string | undefined) ?? PUBLISHABLE_KEY;
|
||||
export const PUBLISHABLE_KEY_FULL =
|
||||
(import.meta.env.VITE_PUBLISHABLE_KEY_FULL as string | undefined) ?? PUBLISHABLE_KEY;
|
||||
|
||||
export const REDIRECT_URI =
|
||||
(import.meta.env.VITE_REDIRECT_URI as string | undefined) ?? 'http://localhost:3001';
|
||||
@@ -0,0 +1,713 @@
|
||||
/**
|
||||
* tone3000-client.ts — TONE3000 OAuth + API client
|
||||
*
|
||||
* A zero-dependency helper for integrating with the TONE3000 API.
|
||||
* Uses built-in WebCrypto and fetch — no npm install required.
|
||||
*
|
||||
* Quick start:
|
||||
* 1. Import the flow initiator for your use case
|
||||
* 2. Call it when the user triggers the integration (e.g. clicks "Browse Tones")
|
||||
* 3. In your callback handler, call handleOAuthCallback()
|
||||
* 4. Use T3KClient to make authenticated API requests
|
||||
*/
|
||||
|
||||
import { T3K_API } from './config';
|
||||
import type {
|
||||
User, Tone, Model, PublicUser,
|
||||
PaginatedResponse, SearchTonesParams, ListUsersParams,
|
||||
} from './types';
|
||||
|
||||
// ─── Public types ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface T3KTokens {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
/** Unix timestamp (ms) when the access token expires. */
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
/** Result of handleOAuthCallback(). Always check `ok` before using fields. */
|
||||
export type OAuthCallbackResult =
|
||||
| { ok: true; tokens: T3KTokens; toneId?: string; modelId?: string; canceled?: boolean }
|
||||
| { ok: false; error: string };
|
||||
|
||||
// ─── Internal PKCE helpers ────────────────────────────────────────────────────
|
||||
|
||||
async function randomBase64url(bytes: number): Promise<string> {
|
||||
const buf = crypto.getRandomValues(new Uint8Array(bytes));
|
||||
return btoa(String.fromCharCode(...buf))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
|
||||
async function sha256Base64url(input: string): Promise<string> {
|
||||
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
||||
return btoa(String.fromCharCode(...new Uint8Array(hash)))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
|
||||
export interface PkceParams {
|
||||
codeVerifier: string;
|
||||
codeChallenge: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export async function buildPkceParams(): Promise<PkceParams> {
|
||||
const codeVerifier = await randomBase64url(32);
|
||||
const [codeChallenge, state] = await Promise.all([
|
||||
sha256Base64url(codeVerifier),
|
||||
randomBase64url(16),
|
||||
]);
|
||||
sessionStorage.setItem('t3k_code_verifier', codeVerifier);
|
||||
sessionStorage.setItem('t3k_state', state);
|
||||
return { codeVerifier, codeChallenge, state };
|
||||
}
|
||||
|
||||
|
||||
function buildAuthorizeUrl(
|
||||
publishableKey: string,
|
||||
redirectUri: string,
|
||||
extra: Record<string, string>,
|
||||
pkce: { codeChallenge: string; state: string }
|
||||
): string {
|
||||
const url = new URL(`${T3K_API}/api/v1/oauth/authorize`);
|
||||
url.searchParams.set('client_id', publishableKey);
|
||||
url.searchParams.set('redirect_uri', redirectUri);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('code_challenge', pkce.codeChallenge);
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
url.searchParams.set('state', pkce.state);
|
||||
for (const [k, v] of Object.entries(extra)) url.searchParams.set(k, v);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
// ─── Flow initiators ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* **Select Flow** — Send the user to TONE3000 to browse and pick a tone.
|
||||
*
|
||||
* Use this when your app wants to let users discover tones from the TONE3000
|
||||
* catalog. After the user selects a tone, they're redirected back to your app
|
||||
* with an authorization code and the selected `tone_id`.
|
||||
*
|
||||
* @param gears - Optional underscore-separated gear filter (e.g. 'amp_pedal')
|
||||
* @param platform - Optional platform filter (e.g. 'nam', 'aida-x')
|
||||
*/
|
||||
export async function startSelectFlow(
|
||||
publishableKey: string,
|
||||
redirectUri: string,
|
||||
options?: { gears?: string; platform?: string; menubar?: boolean, loginHint?: string }
|
||||
): Promise<void> {
|
||||
const pkce = await buildPkceParams();
|
||||
const extra: Record<string, string> = { prompt: 'select_tone' };
|
||||
if (options?.gears) extra.gears = options.gears;
|
||||
if (options?.platform) extra.platform = options.platform;
|
||||
if (options?.menubar) extra.menubar = 'true';
|
||||
if (options?.loginHint) extra.login_hint = options.loginHint;
|
||||
window.location.href = buildAuthorizeUrl(publishableKey, redirectUri, extra, pkce);
|
||||
}
|
||||
|
||||
/**
|
||||
* **Select Flow (Popup)** — Open TONE3000 tone browsing and selection in a popup window.
|
||||
*
|
||||
* Same as `startSelectFlow` but opens in a popup. The user stays on your app while
|
||||
* browsing TONE3000. When a tone is selected, the popup relays the result back via
|
||||
* `postMessage` or `BroadcastChannel` — handle it with `handleOAuthCallbackFromPopup`.
|
||||
*/
|
||||
export async function startSelectFlowPopup(
|
||||
pkce: PkceParams,
|
||||
publishableKey: string,
|
||||
redirectUri: string,
|
||||
options?: { gears?: string; platform?: string; menubar?: boolean, loginHint?: string, architecture?: number
|
||||
width?: number, height?: number
|
||||
}
|
||||
): Promise<Window | null> {
|
||||
// Set before window.open so the popup inherits this flag via sessionStorage copy;
|
||||
// remove it from the parent immediately so only the popup retains it.
|
||||
sessionStorage.setItem('t3k_popup_mode', '1');
|
||||
const extra: Record<string, string> = { prompt: 'select_tone' };
|
||||
if (options?.gears) extra.gears = options.gears;
|
||||
if (options?.platform) extra.platform = options.platform;
|
||||
if (options?.menubar) extra.menubar = 'true';
|
||||
if (options?.loginHint) extra.login_hint = options.loginHint;
|
||||
if (options?.architecture) extra.architecture = options.architecture.toString();
|
||||
const url = buildAuthorizeUrl(publishableKey, redirectUri, extra, pkce);
|
||||
|
||||
const width = options?.width ?? 480;
|
||||
const height = options?.height ?? 700;
|
||||
const left = Math.round(window.screenX + (window.outerWidth - width) / 2);
|
||||
const top = Math.round(window.screenY + (window.outerHeight - height) / 2);
|
||||
const popup = window.open(url, 't3k_select', `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=no,status=no,resizable=yes,scrollbars=yes`);
|
||||
sessionStorage.removeItem('t3k_popup_mode');
|
||||
return popup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an OAuth callback relayed from a select popup.
|
||||
*
|
||||
* Pass events from both a `message` listener and a `BroadcastChannel('t3k_oauth')`
|
||||
* listener to this function. Returns `null` if the event is not a TONE3000 callback.
|
||||
* Verifies state, exchanges the code for tokens, and returns the same result shape
|
||||
* as `handleOAuthCallback`.
|
||||
*/
|
||||
export async function handleOAuthCallbackFromPopup(
|
||||
publishableKey: string,
|
||||
redirectUri: string,
|
||||
event: MessageEvent
|
||||
): Promise<OAuthCallbackResult | null> {
|
||||
if (event.data?.type !== 't3k_oauth_callback') return null;
|
||||
|
||||
const { code, state: returnedState, error, tone_id: toneId, model_id: modelId, canceled } = event.data;
|
||||
|
||||
const storedState = sessionStorage.getItem('t3k_state');
|
||||
const codeVerifier = sessionStorage.getItem('t3k_code_verifier');
|
||||
|
||||
sessionStorage.removeItem('t3k_state');
|
||||
sessionStorage.removeItem('t3k_code_verifier');
|
||||
|
||||
if (returnedState !== storedState) return { ok: false, error: 'state_mismatch' };
|
||||
|
||||
// User closed without signing in — no code to exchange
|
||||
if (canceled && !code) return { ok: false, error: 'canceled' };
|
||||
|
||||
if (error) return { ok: false, error };
|
||||
if (!code || !codeVerifier) return { ok: false, error: 'missing_code' };
|
||||
|
||||
const res = await fetch(`${T3K_API}/api/v1/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: publishableKey,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
return { ok: false, error: (err as any).error ?? 'token_exchange_failed' };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const tokens: T3KTokens = {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_at: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
|
||||
return { ok: true, tokens, toneId, modelId, ...(canceled ? { canceled: true } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* **Load Tone Flow** — Send the user to TONE3000 to authenticate and load a specific tone.
|
||||
*
|
||||
* Use this when your app already has a `tone_id` and wants to ensure the user
|
||||
* is authenticated and has access to that tone. TONE3000 handles the auth check
|
||||
* and redirects back immediately — no tone browsing required.
|
||||
*
|
||||
* If the tone is private or has been deleted, TONE3000 shows an error page
|
||||
* where the user can browse for a replacement. In that case, the `tone_id` in
|
||||
* the callback may differ from the one you requested. Any `gears` or `platform`
|
||||
* filters you pass are applied to that replacement browse view.
|
||||
*
|
||||
* @param gears - Optional underscore-separated gear filter (e.g. 'amp_full-rig')
|
||||
* @param platform - Optional platform filter (e.g. 'nam', 'aida-x')
|
||||
*/
|
||||
export async function startLoadToneFlow(
|
||||
publishableKey: string,
|
||||
redirectUri: string,
|
||||
toneId: number | string,
|
||||
options?: { gears?: string; platform?: string; menubar?: boolean, loginHint?: string }
|
||||
): Promise<void> {
|
||||
const pkce = await buildPkceParams();
|
||||
const extra: Record<string, string> = { prompt: 'load_tone', tone_id: String(toneId) };
|
||||
if (options?.gears) extra.gears = options.gears;
|
||||
if (options?.platform) extra.platform = options.platform;
|
||||
if (options?.menubar) extra.menubar = 'true';
|
||||
if (options?.loginHint) extra.login_hint = options.loginHint;
|
||||
window.location.href = buildAuthorizeUrl(publishableKey, redirectUri, extra, pkce);
|
||||
}
|
||||
|
||||
/**
|
||||
* **Load Tone Flow (Popup)** — Open TONE3000 in a popup to authenticate and load a specific tone.
|
||||
*
|
||||
* Same as `startLoadToneFlow` but opens in a popup. When the flow completes, the
|
||||
* popup relays the result back via `postMessage` or `BroadcastChannel` — handle it
|
||||
* with `handleOAuthCallbackFromPopup`. Any `gears` or `platform` filters you pass
|
||||
* are applied if the user needs to browse for a replacement tone.
|
||||
*/
|
||||
export async function startLoadToneFlowPopup(
|
||||
publishableKey: string,
|
||||
redirectUri: string,
|
||||
toneId: number | string,
|
||||
options?: { gears?: string; platform?: string; menubar?: boolean, loginHint?: string }
|
||||
): Promise<Window | null> {
|
||||
// Set before window.open so the popup inherits this flag via sessionStorage copy;
|
||||
// remove it from the parent immediately so only the popup retains it.
|
||||
sessionStorage.setItem('t3k_popup_mode', '1');
|
||||
const pkce = await buildPkceParams();
|
||||
const extra: Record<string, string> = { prompt: 'load_tone', tone_id: String(toneId) };
|
||||
if (options?.gears) extra.gears = options.gears;
|
||||
if (options?.platform) extra.platform = options.platform;
|
||||
if (options?.menubar) extra.menubar = 'true';
|
||||
if (options?.loginHint) extra.login_hint = options.loginHint;
|
||||
const url = buildAuthorizeUrl(publishableKey, redirectUri, extra, pkce);
|
||||
const width = 480;
|
||||
const height = 700;
|
||||
const left = Math.round(window.screenX + (window.outerWidth - width) / 2);
|
||||
const top = Math.round(window.screenY + (window.outerHeight - height) / 2);
|
||||
const popup = window.open(url, 't3k_load_tone', `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=no,status=no,resizable=yes,scrollbars=yes`);
|
||||
sessionStorage.removeItem('t3k_popup_mode');
|
||||
return popup;
|
||||
}
|
||||
|
||||
/**
|
||||
* **Load Tone Flow (Popup, model_id variant)** — Open TONE3000 in a popup to
|
||||
* authenticate and load a tone resolved from a specific model.
|
||||
*/
|
||||
export async function startLoadToneFlowPopupByModelId(
|
||||
publishableKey: string,
|
||||
redirectUri: string,
|
||||
modelId: number | string,
|
||||
options?: { gears?: string; platform?: string; menubar?: boolean, loginHint?: string }
|
||||
): Promise<Window | null> {
|
||||
sessionStorage.setItem('t3k_popup_mode', '1');
|
||||
const pkce = await buildPkceParams();
|
||||
const extra: Record<string, string> = { prompt: 'load_tone', model_id: String(modelId) };
|
||||
if (options?.gears) extra.gears = options.gears;
|
||||
if (options?.platform) extra.platform = options.platform;
|
||||
if (options?.menubar) extra.menubar = 'true';
|
||||
if (options?.loginHint) extra.login_hint = options.loginHint;
|
||||
const url = buildAuthorizeUrl(publishableKey, redirectUri, extra, pkce);
|
||||
const width = 480;
|
||||
const height = 700;
|
||||
const left = Math.round(window.screenX + (window.outerWidth - width) / 2);
|
||||
const top = Math.round(window.screenY + (window.outerHeight - height) / 2);
|
||||
const popup = window.open(url, 't3k_load_tone', `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=no,status=no,resizable=yes,scrollbars=yes`);
|
||||
sessionStorage.removeItem('t3k_popup_mode');
|
||||
return popup;
|
||||
}
|
||||
|
||||
/**
|
||||
* **Load Model Flow** — Send the user to TONE3000 to authenticate and load a specific model.
|
||||
*
|
||||
* Use this when your app has a `model_id` and wants to load that exact model.
|
||||
* Unlike the Load Tone flow, if the model is inaccessible, TONE3000 redirects
|
||||
* back to your app with `error=access_denied` rather than offering a replacement.
|
||||
* Your callback handler must check for this error.
|
||||
*/
|
||||
export async function startLoadModelFlow(
|
||||
publishableKey: string,
|
||||
redirectUri: string,
|
||||
modelId: number | string
|
||||
): Promise<void> {
|
||||
const pkce = await buildPkceParams();
|
||||
window.location.href = buildAuthorizeUrl(
|
||||
publishableKey, redirectUri,
|
||||
{ prompt: 'load_model', model_id: String(modelId) },
|
||||
pkce
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* **Standard Flow** — Send the user to TONE3000 to connect their account.
|
||||
*
|
||||
* Use this when your app wants long-lived access to the TONE3000 API without
|
||||
* having the user browse or select a tone during auth. After connecting, your
|
||||
* app can fetch any tone by ID using the access token.
|
||||
*/
|
||||
export async function startStandardFlow(
|
||||
publishableKey: string,
|
||||
redirectUri: string,
|
||||
options?: { loginHint?: string }
|
||||
): Promise<void> {
|
||||
const pkce = await buildPkceParams();
|
||||
const extra: Record<string, string> = {};
|
||||
if (options?.loginHint) extra.login_hint = options.loginHint;
|
||||
window.location.href = buildAuthorizeUrl(publishableKey, redirectUri, extra, pkce);
|
||||
}
|
||||
|
||||
/**
|
||||
* **LAN-relay Flow** — For headless devices on a LAN. The "device" (here, the
|
||||
* laptop's Vite dev server) opens an HTTP listener at an RFC1918 address; the
|
||||
* user scans a QR with their phone, completes auth in the phone browser, and
|
||||
* the OAuth code lands at the device's LAN listener via tone3000's bridge.
|
||||
*
|
||||
* This helper only generates the authorize URL — actually receiving the
|
||||
* callback requires a real LAN listener (see vite-plugin-lan-bridge.ts in this
|
||||
* repo for the dev-time implementation, or your device firmware in
|
||||
* production). PKCE state is stored in sessionStorage as with the other
|
||||
* flows; pair this call with `exchangeCode()` once the listener captures
|
||||
* code+state.
|
||||
*
|
||||
* @param lanCallbackUri The redirect_uri the device's listener will receive.
|
||||
* Must be `http://` to RFC1918 / link-local
|
||||
* (10/8, 172.16-31, 192.168/16, 169.254/16).
|
||||
*/
|
||||
export async function startLanRelayFlow(
|
||||
publishableKey: string,
|
||||
lanCallbackUri: string,
|
||||
): Promise<{ authorizeUrl: string; state: string }> {
|
||||
const pkce = await buildPkceParams();
|
||||
const authorizeUrl = buildAuthorizeUrl(publishableKey, lanCallbackUri, {}, pkce);
|
||||
return { authorizeUrl, state: pkce.state };
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange an authorization code for tokens. Used by `handleOAuthCallback`
|
||||
* (URL-driven callbacks) and by the LAN-relay demo (callbacks that arrive via
|
||||
* the LAN listener and are forwarded to the React UI by the dev plugin).
|
||||
*
|
||||
* Verifies that `returnedState` matches the value `buildPkceParams()` stored
|
||||
* in sessionStorage, then redeems the code with the verifier. The PKCE
|
||||
* values are cleared from sessionStorage regardless of outcome.
|
||||
*/
|
||||
export async function exchangeCode(
|
||||
publishableKey: string,
|
||||
redirectUri: string,
|
||||
code: string,
|
||||
returnedState: string,
|
||||
): Promise<OAuthCallbackResult> {
|
||||
const storedState = sessionStorage.getItem('t3k_state');
|
||||
const codeVerifier = sessionStorage.getItem('t3k_code_verifier');
|
||||
sessionStorage.removeItem('t3k_state');
|
||||
sessionStorage.removeItem('t3k_code_verifier');
|
||||
|
||||
if (returnedState !== storedState) return { ok: false, error: 'state_mismatch' };
|
||||
if (!codeVerifier) return { ok: false, error: 'missing_verifier' };
|
||||
|
||||
const res = await fetch(`${T3K_API}/api/v1/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: publishableKey,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
return { ok: false, error: (err as { error?: string }).error ?? 'token_exchange_failed' };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return {
|
||||
ok: true,
|
||||
tokens: {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_at: Date.now() + data.expires_in * 1000,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Callback handler ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handle the OAuth callback after TONE3000 redirects back to your app.
|
||||
*
|
||||
* Call this once when your callback page loads and detects a `?code=` or
|
||||
* `?error=` query parameter. It verifies the state, exchanges the code for
|
||||
* tokens, and returns a typed result object.
|
||||
*
|
||||
* Always check `result.ok` before using the tokens. A `result.ok === false`
|
||||
* with `error === 'access_denied'` is expected for the Load Model flow when
|
||||
* the model is private — handle it by showing the user an appropriate error UI.
|
||||
*/
|
||||
export async function handleOAuthCallback(
|
||||
publishableKey: string,
|
||||
redirectUri: string,
|
||||
responseUri: string = window.location.href
|
||||
): Promise<OAuthCallbackResult> {
|
||||
// Make URLSearchParams from the responseUri (not necessarily window.location) to support LAN-relay flow where the code lands at a different URL
|
||||
const url = new URL(responseUri);
|
||||
const params = new URLSearchParams(url.search);
|
||||
const code = params.get('code');
|
||||
const error = params.get('error');
|
||||
const returnedState = params.get('state');
|
||||
const toneId = params.get('tone_id') ?? undefined;
|
||||
const modelId = params.get('model_id') ?? undefined;
|
||||
const canceled = params.get('canceled') === 'true';
|
||||
|
||||
const storedState = sessionStorage.getItem('t3k_state');
|
||||
const codeVerifier = sessionStorage.getItem('t3k_code_verifier');
|
||||
|
||||
// Clean up PKCE state regardless of outcome
|
||||
sessionStorage.removeItem('t3k_state');
|
||||
sessionStorage.removeItem('t3k_code_verifier');
|
||||
|
||||
// Verify state to prevent CSRF
|
||||
if (returnedState !== storedState) {
|
||||
return { ok: false, error: 'state_mismatch' };
|
||||
}
|
||||
|
||||
// User closed without signing in — no code to exchange
|
||||
if (canceled && !code) {
|
||||
return { ok: false, error: 'canceled' };
|
||||
}
|
||||
|
||||
// Access denied — e.g. model is private and user clicked "Back"
|
||||
if (error) {
|
||||
return { ok: false, error };
|
||||
}
|
||||
|
||||
if (!code || !codeVerifier) {
|
||||
return { ok: false, error: 'missing_code' };
|
||||
}
|
||||
|
||||
const res = await fetch(`${T3K_API}/api/v1/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: publishableKey,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
return { ok: false, error: (err as any).error ?? 'token_exchange_failed' };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const tokens: T3KTokens = {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_at: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
|
||||
return { ok: true, tokens, toneId, modelId, ...(canceled ? { canceled: true } : {}) };
|
||||
}
|
||||
|
||||
// ─── Token refresh ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Exchange a refresh token for a new access token. */
|
||||
export async function refreshTokens(
|
||||
refreshToken: string,
|
||||
publishableKey: string
|
||||
): Promise<T3KTokens> {
|
||||
const res = await fetch(`${T3K_API}/api/v1/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: publishableKey,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error('Token refresh failed');
|
||||
|
||||
const data = await res.json();
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
expires_at: Date.now() + data.expires_in * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Authenticated API client ─────────────────────────────────────────────────
|
||||
|
||||
const STORAGE_KEY = 't3k_tokens';
|
||||
|
||||
/**
|
||||
* T3KClient — Authenticated API client with automatic token refresh.
|
||||
*
|
||||
* Create one instance at module scope. Tokens are stored in sessionStorage
|
||||
* by default — they survive page refreshes within a tab but are cleared when
|
||||
* the tab closes. For cross-session persistence without re-auth, store the
|
||||
* refresh token server-side and call POST /api/v1/oauth/token on page load.
|
||||
*
|
||||
* @param publishableKey - Your `t3k_pub_` key (same as `client_id` in OAuth)
|
||||
* @param onAuthRequired - Called when tokens are missing or expired beyond refresh.
|
||||
* Typically you'd call startStandardFlow() here to silently
|
||||
* re-authenticate (the user won't see a login screen if
|
||||
* they still have an active TONE3000 session).
|
||||
*/
|
||||
export class T3KClient {
|
||||
private refreshPromise: Promise<T3KTokens> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly publishableKey: string,
|
||||
private readonly onAuthRequired: () => void
|
||||
) {}
|
||||
|
||||
setTokens(tokens: T3KTokens): void {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(tokens));
|
||||
}
|
||||
|
||||
getTokens(): T3KTokens | null {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
return raw ? (JSON.parse(raw) as T3KTokens) : null;
|
||||
}
|
||||
|
||||
clearTokens(): void {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.getTokens() !== null;
|
||||
}
|
||||
|
||||
async getAccessToken(): Promise<string> {
|
||||
const tokens = this.getTokens();
|
||||
if (!tokens) {
|
||||
this.onAuthRequired();
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
// Proactively refresh 60 s before expiry to avoid mid-request failures
|
||||
if (Date.now() > tokens.expires_at - 60_000) {
|
||||
if (!this.refreshPromise) {
|
||||
this.refreshPromise = refreshTokens(tokens.refresh_token, this.publishableKey)
|
||||
.then((t) => { this.setTokens(t); this.refreshPromise = null; return t; })
|
||||
.catch((err) => {
|
||||
this.clearTokens();
|
||||
this.refreshPromise = null;
|
||||
this.onAuthRequired();
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return (await this.refreshPromise).access_token;
|
||||
}
|
||||
|
||||
return tokens.access_token;
|
||||
}
|
||||
|
||||
/** Make an authenticated request to the TONE3000 API. */
|
||||
async fetch(path: string, init?: RequestInit): Promise<Response> {
|
||||
const token = await this.getAccessToken();
|
||||
const res = await globalThis.fetch(`${T3K_API}${path}`, {
|
||||
...init,
|
||||
headers: { ...init?.headers, Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
// Retry once on 401 — handles expiry race conditions between refresh check and request
|
||||
if (res.status === 401) {
|
||||
const stored = this.getTokens();
|
||||
if (stored) {
|
||||
this.setTokens({ ...stored, expires_at: 0 }); // force a refresh on next call
|
||||
const retryToken = await this.getAccessToken();
|
||||
return globalThis.fetch(`${T3K_API}${path}`, {
|
||||
...init,
|
||||
headers: { ...init?.headers, Authorization: `Bearer ${retryToken}` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── Resource methods ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Get the authenticated user's profile. */
|
||||
async getUser(): Promise<User> {
|
||||
const res = await this.fetch('/api/v1/user');
|
||||
if (!res.ok) throw new Error(`getUser failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a tone by ID. Returns tone metadata only — models are not embedded.
|
||||
* To get download URLs, call `listModels(tone.id)` after fetching the tone.
|
||||
*/
|
||||
async getTone(id: number | string): Promise<Tone> {
|
||||
const res = await this.fetch(`/api/v1/tones/${id}`);
|
||||
if (!res.ok) throw new Error(`getTone failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Get a model by ID. */
|
||||
async getModel(id: number | string): Promise<Model> {
|
||||
const res = await this.fetch(`/api/v1/models/${id}`);
|
||||
if (!res.ok) throw new Error(`getModel failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Search and filter the TONE3000 tone catalog. */
|
||||
async searchTones(params?: SearchTonesParams): Promise<PaginatedResponse<Tone>> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.query) qs.set('query', params.query);
|
||||
if (params?.page) qs.set('page', String(params.page));
|
||||
if (params?.pageSize) qs.set('page_size', String(params.pageSize));
|
||||
if (params?.sort) qs.set('sort', params.sort);
|
||||
if (params?.gears?.length) qs.set('gears', params.gears.join('_'));
|
||||
if (params?.sizes?.length) qs.set('sizes', params.sizes.join('_'));
|
||||
const res = await this.fetch(`/api/v1/tones/search?${qs}`);
|
||||
if (!res.ok) throw new Error(`searchTones failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Get tones created by the authenticated user. */
|
||||
async listCreatedTones(page = 1, pageSize = 10): Promise<PaginatedResponse<Tone>> {
|
||||
const res = await this.fetch(`/api/v1/tones/created?page=${page}&page_size=${pageSize}`);
|
||||
if (!res.ok) throw new Error(`listCreatedTones failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Get tones favorited by the authenticated user. */
|
||||
async listFavoritedTones(page = 1, pageSize = 10): Promise<PaginatedResponse<Tone>> {
|
||||
const res = await this.fetch(`/api/v1/tones/favorited?page=${page}&page_size=${pageSize}`);
|
||||
if (!res.ok) throw new Error(`listFavoritedTones failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** List models for a tone. */
|
||||
async listModels(toneId: number | string, page = 1, pageSize = 10, architecture?: number): Promise<PaginatedResponse<Model>> {
|
||||
let uri = `/api/v1/models?tone_id=${toneId}&page=${page}&page_size=${pageSize}`;
|
||||
if (architecture != undefined) {
|
||||
uri += `&architecture=${architecture.toString()}`;
|
||||
}
|
||||
if (architecture !== undefined) {
|
||||
uri += `&architecture=${architecture}`;
|
||||
}
|
||||
const res = await this.fetch(uri);
|
||||
if (!res.ok) throw new Error(`listModels failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Get public users, sortable by activity metrics. */
|
||||
async listUsers(params?: ListUsersParams): Promise<PaginatedResponse<PublicUser>> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params?.sort) qs.set('sort', params.sort);
|
||||
if (params?.page) qs.set('page', String(params.page));
|
||||
if (params?.pageSize) qs.set('page_size', String(params.pageSize));
|
||||
if (params?.query) qs.set('query', params.query);
|
||||
const res = await this.fetch(`/api/v1/users?${qs}`);
|
||||
if (!res.ok) throw new Error(`listUsers failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a model file and trigger a browser file download.
|
||||
* The `model_url` from the API must be fetched with Bearer auth — use this
|
||||
* method rather than calling fetch(model_url) directly.
|
||||
*/
|
||||
async downloadModel(modelUrl: string, name: string): Promise<void> {
|
||||
// Strip the base URL so client.fetch() can prepend T3K_API + auth header
|
||||
const path = modelUrl.replace(T3K_API, '');
|
||||
const res = await this.fetch(path);
|
||||
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
||||
|
||||
// Use the extension from the storage URL with a human-readable name
|
||||
const storageFilename = new URL(modelUrl).pathname.split('/').pop() ?? '';
|
||||
const ext = storageFilename.includes('.') ? '.' + storageFilename.split('.').pop() : '';
|
||||
const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
const filename = sanitized + ext;
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = Object.assign(document.createElement('a'), { href: url, download: filename });
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// types.ts — TONE3000 API type definitions
|
||||
|
||||
export type Demo = 'select' | 'load-tone' | 'load-model' | 'full-api' | 'lan-flow';
|
||||
|
||||
export enum Gear {
|
||||
Amp = 'amp',
|
||||
FullRig = 'full-rig',
|
||||
Pedal = 'pedal',
|
||||
Outboard = 'outboard',
|
||||
Ir = 'ir',
|
||||
}
|
||||
|
||||
export enum Platform {
|
||||
Nam = 'nam',
|
||||
Ir = 'ir',
|
||||
AidaX = 'aida-x',
|
||||
AaSnapshot = 'aa-snapshot',
|
||||
Proteus = 'proteus',
|
||||
}
|
||||
|
||||
export enum License {
|
||||
T3k = 't3k',
|
||||
CcBy = 'cc-by',
|
||||
CcBySa = 'cc-by-sa',
|
||||
CcByNc = 'cc-by-nc',
|
||||
CcByNcSa = 'cc-by-nc-sa',
|
||||
CcByNd = 'cc-by-nd',
|
||||
CcByNcNd = 'cc-by-nc-nd',
|
||||
Cco = 'cco',
|
||||
}
|
||||
|
||||
export enum Size {
|
||||
Standard = 'standard',
|
||||
Lite = 'lite',
|
||||
Feather = 'feather',
|
||||
Nano = 'nano',
|
||||
Custom = 'custom',
|
||||
}
|
||||
|
||||
export enum TonesSort {
|
||||
BestMatch = 'best-match',
|
||||
Newest = 'newest',
|
||||
Oldest = 'oldest',
|
||||
Trending = 'trending',
|
||||
DownloadsAllTime = 'downloads-all-time',
|
||||
}
|
||||
|
||||
export enum UsersSort {
|
||||
Tones = 'tones',
|
||||
Downloads = 'downloads',
|
||||
Favorites = 'favorites',
|
||||
Models = 'models',
|
||||
}
|
||||
|
||||
export interface EmbeddedUser {
|
||||
id: string;
|
||||
username: string;
|
||||
avatar_url: string | null;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface User extends EmbeddedUser {
|
||||
bio: string | null;
|
||||
links: string[] | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PublicUser {
|
||||
id: number;
|
||||
username: string;
|
||||
bio: string | null;
|
||||
links: string[] | null;
|
||||
avatar_url: string | null;
|
||||
downloads_count: number;
|
||||
favorites_count: number;
|
||||
models_count: number;
|
||||
tones_count: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface Make {
|
||||
id?: number; // absent when returned via RPC (search results)
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id?: number; // absent when returned via RPC (search results)
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Tone {
|
||||
id: number;
|
||||
user_id: string;
|
||||
user: EmbeddedUser;
|
||||
created_at?: string; // absent from GET /tones/{id} — present on search/created/favorited
|
||||
updated_at?: string; // absent from GET /tones/{id} — present on search/created/favorited
|
||||
title: string;
|
||||
description: string | null;
|
||||
gear: Gear;
|
||||
images: string[] | null;
|
||||
is_public: boolean | null;
|
||||
links: string[] | null;
|
||||
platform: Platform;
|
||||
license: License;
|
||||
sizes: Size[];
|
||||
makes: Make[];
|
||||
tags: Tag[];
|
||||
models_count: number;
|
||||
a1_models_count: number | undefined;
|
||||
a2_models_count: number | undefined;
|
||||
irs_count: number | undefined;
|
||||
custom_models_count: number | undefined;
|
||||
downloads_count: number;
|
||||
favorites_count: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface Model {
|
||||
id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user_id: string;
|
||||
model_url: string;
|
||||
name: string;
|
||||
size: Size;
|
||||
tone_id: number;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface SearchTonesParams {
|
||||
query?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: TonesSort;
|
||||
gears?: Gear[];
|
||||
sizes?: Size[];
|
||||
}
|
||||
|
||||
export interface ListUsersParams {
|
||||
sort?: UsersSort;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
query?: string;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
//import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import React from 'react';
|
||||
|
||||
|
||||
import './index.css'
|
||||
|
||||
// createRoot(document.getElementById('root')!).render(
|
||||
// <StrictMode>
|
||||
// <App />
|
||||
// </StrictMode>,
|
||||
// )
|
||||
|
||||
function ResponseComponent() {
|
||||
|
||||
React.useEffect(() => {
|
||||
alert("yyy: Delete me");
|
||||
debugger;
|
||||
if (window.opener === null) {
|
||||
console.error('No window.opener found for OAuth callback');
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
window.opener.postMessage(
|
||||
{
|
||||
type: "t3k_response",
|
||||
uri: window.location.href,
|
||||
storedState: sessionStorage.getItem('t3k_state'),
|
||||
codeVerifier: sessionStorage.getItem('t3k_code_verifier')
|
||||
}, '*'
|
||||
);
|
||||
|
||||
}, []);
|
||||
|
||||
return <div style={{color: 'white'}}>Processing...</div>;
|
||||
|
||||
}
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<div>
|
||||
<ResponseComponent />
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Tone3000 Download Received</title>
|
||||
</head>
|
||||
|
||||
<body style="background: black;">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/t3k_response.tsx"></script>
|
||||
</body>
|
||||
|
||||
|
||||
|
||||
</html>
|
||||
+7
-1
@@ -5,7 +5,13 @@ import svgr from "vite-plugin-svgr"
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
build: {
|
||||
chunkSizeWarningLimit: 2000
|
||||
chunkSizeWarningLimit: 2000,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: 'index.html',
|
||||
t3k_callback: 't3k_response.html', // your alternate page
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [react(),svgr()],
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user