From 7bb3e850c02450d765f9999023d77d7bbe2b4b19 Mon Sep 17 00:00:00 2001 From: whaffman Date: Thu, 6 Nov 2025 17:53:07 +0100 Subject: [PATCH] feat: implement file upload handling with multipart support --- config/default.conf | 15 +- webserv/handler/URI.cpp | 6 + webserv/handler/URI.hpp | 1 + webserv/handler/UploadHandler.cpp | 568 ++++++++++++++++++++++++++++-- webserv/handler/UploadHandler.hpp | 47 ++- webserv/router/Router.cpp | 6 + 6 files changed, 610 insertions(+), 33 deletions(-) diff --git a/config/default.conf b/config/default.conf index 2c1260e..dbf9360 100644 --- a/config/default.conf +++ b/config/default.conf @@ -81,11 +81,20 @@ server { index index2.html; } + location /upload { + upload_store ./htdocs/site-1/uploads/; + allowed_methods POST; + } + + location /uploads { + root ./htdocs/site-1/uploads/; + autoindex on; + allowed_methods GET; + } + cgi_enabled yes; cgi_handler .php /usr/bin/php-cgi; - # cgi_handler .py /usr/bin/python3; - # cgi_handler .sh; - client_max_body_size 10M; + # cgi_handler .php /usr/bin/php-cgi; } server { diff --git a/webserv/handler/URI.cpp b/webserv/handler/URI.cpp index 6c38023..dac0043 100644 --- a/webserv/handler/URI.cpp +++ b/webserv/handler/URI.cpp @@ -186,6 +186,12 @@ bool URI::isRedirect() const noexcept return redirectOpt.has_value(); } +bool URI::isUpload() const noexcept +{ + auto uploadStoreOpt = config_->get("upload_store"); + return uploadStoreOpt.has_value(); +} + std::pair URI::getRedirect() const { auto redirectOpt = config_->get>("redirect"); diff --git a/webserv/handler/URI.hpp b/webserv/handler/URI.hpp index 22bdfc6..090a693 100644 --- a/webserv/handler/URI.hpp +++ b/webserv/handler/URI.hpp @@ -22,6 +22,7 @@ class URI [[nodiscard]] bool isValid() const noexcept; [[nodiscard]] bool isCgi() const noexcept; [[nodiscard]] bool isRedirect() const noexcept; + [[nodiscard]] bool isUpload() const noexcept; [[nodiscard]] std::string getExtension() const noexcept; [[nodiscard]] std::string getCgiPath() const; diff --git a/webserv/handler/UploadHandler.cpp b/webserv/handler/UploadHandler.cpp index 7fea4e6..afae5ea 100644 --- a/webserv/handler/UploadHandler.cpp +++ b/webserv/handler/UploadHandler.cpp @@ -1,47 +1,561 @@ -#include "webserv/handler/UploadHandler.hpp" +#include -#include "webserv/handler/ErrorHandler.hpp" -#include "webserv/handler/URI.hpp" -#include "webserv/http/HttpConstants.hpp" - -#include // for Log, LOCATION +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include -UploadHandler::UploadHandler(const HttpRequest &request, HttpResponse &response) : AHandler(request, response) {} +UploadHandler::UploadHandler(const HttpRequest &request, HttpResponse &response) + : AHandler(request, response), maxFileSize_(0) +{ + Log::trace(LOCATION); + + // Get upload configuration from the matched location + const AConfig *config = request_.getUri().getConfig(); + + // Get upload directory from config (default: "uploads") + uploadDir_ = config->get("upload_store").value_or("data/uploads"); + + // Get max file size from config (uses client_max_body_size) + maxFileSize_ = config->get("client_max_body_size").value_or(1048576); // Default 1MB + + Log::debug("Upload handler initialized", { + {"upload_dir", uploadDir_}, + {"max_file_size", std::to_string(maxFileSize_)} + }); +} void UploadHandler::handle() { Log::trace(LOCATION); - std::string fullPath = request_.getUri().getFullPath(); - if (fullPath.empty()) + + // Verify request method is POST + if (request_.getMethod() != "POST") { - Log::warning("UploadHandler: Invalid path for UPLOAD"); - ErrorHandler::createErrorResponse(Http::StatusCode::BAD_REQUEST, response_, request_.getUri().getConfig()); + Log::warning("Upload attempted with non-POST method: " + request_.getMethod()); + sendErrorResponse(405, "Method Not Allowed"); return; } - uploadFile(fullPath, request_.getBody()); -} -void UploadHandler::uploadFile(const std::string &path, const std::string &data) -{ - Log::trace(LOCATION); - std::ofstream fileStream{path.c_str(), std::ios::binary}; - if (!fileStream) + // Check Content-Type header + auto contentType = request_.getHeaders().getContentType(); + if (!contentType.has_value()) { - Log::error("UploadHandler: Failed to open file for upload: " + path); - ErrorHandler::createErrorResponse(Http::StatusCode::FORBIDDEN, response_, request_.getUri().getConfig()); + Log::warning("Upload request missing Content-Type header"); + sendErrorResponse(400, "Missing Content-Type header"); return; } - fileStream.write(data.c_str(), data.size()); - fileStream.close(); - response_.setStatus(Http::StatusCode::CREATED); - response_.setComplete(); + // Check if it's multipart/form-data + if (contentType->find("multipart/form-data") == std::string::npos) + { + // For application/x-www-form-urlencoded or other types, just return success + // The upload endpoint can accept form data without files + Log::debug("Upload request with non-multipart Content-Type: " + *contentType); + response_.setStatus(200); + response_.addHeader("Content-Type", "application/json"); + response_.setBody("{\"success\": true, \"message\": \"Form data received\"}\n"); + return; + } + + // Check body size doesn't exceed limit + if (request_.getBody().size() > maxFileSize_) + { + Log::warning("Upload request body exceeds max size: " + std::to_string(request_.getBody().size()) + + " > " + std::to_string(maxFileSize_)); + sendErrorResponse(413, "Payload Too Large"); + return; + } + + // Verify upload directory exists and is writable + struct stat st = {}; + if (stat(uploadDir_.c_str(), &st) != 0) + { + Log::error("Upload directory does not exist: " + uploadDir_); + sendErrorResponse(500, "Upload directory not configured"); + return; + } + if (!S_ISDIR(st.st_mode)) + { + Log::error("Upload path is not a directory: " + uploadDir_); + sendErrorResponse(500, "Upload directory not configured"); + return; + } + if (access(uploadDir_.c_str(), W_OK) != 0) + { + Log::error("Upload directory is not writable: " + uploadDir_); + sendErrorResponse(500, "Upload directory not writable"); + return; + } + + // Parse multipart form data + try + { + parseMultipartFormData(); + + // Success even if no files were uploaded (could be just form fields or empty file) + sendSuccessResponse(); + } + catch (const std::exception &e) + { + Log::error("Error processing upload: " + std::string(e.what())); + sendErrorResponse(400, "Error processing upload"); + } } void UploadHandler::handleTimeout() { - Log::warning("UploadHandler: Upload operation timed out"); - ErrorHandler::createErrorResponse(504, response_, request_.getUri().getConfig()); -} \ No newline at end of file + Log::warning("Upload handler timeout"); + ErrorHandler::createErrorResponse(504, response_); +} + +std::string UploadHandler::extractBoundary(const std::string &contentType) const +{ + Log::trace(LOCATION); + + size_t boundaryPos = contentType.find("boundary="); + if (boundaryPos == std::string::npos) + { + throw std::runtime_error("No boundary found in Content-Type"); + } + + std::string boundary = contentType.substr(boundaryPos + 9); // "boundary=" is 9 chars + + // Remove quotes if present + if (!boundary.empty() && boundary[0] == '"') + { + boundary = boundary.substr(1); + size_t endQuote = boundary.find('"'); + if (endQuote != std::string::npos) + { + boundary = boundary.substr(0, endQuote); + } + } + + // Remove any trailing characters after semicolon or whitespace + size_t endPos = boundary.find_first_of("; \t\r\n"); + if (endPos != std::string::npos) + { + boundary = boundary.substr(0, endPos); + } + + Log::debug("Extracted boundary: " + boundary); + return boundary; +} + +void UploadHandler::parseMultipartFormData() +{ + Log::trace(LOCATION); + + auto contentType = request_.getHeaders().getContentType(); + if (!contentType.has_value()) + { + throw std::runtime_error("Missing Content-Type header"); + } + + std::string boundary = extractBoundary(*contentType); + std::string fullBoundary = "--" + boundary; + + const std::string &body = request_.getBody(); + + // Find first boundary + size_t pos = body.find(fullBoundary); + if (pos == std::string::npos) + { + throw std::runtime_error("No boundary found in body"); + } + + pos += fullBoundary.length(); + + // Parse each part + while (pos < body.length()) + { + // Skip CRLF after boundary + if (pos + 1 < body.length() && body[pos] == '\r' && body[pos + 1] == '\n') + { + pos += 2; + } + else if (body[pos] == '\n') + { + pos += 1; + } + + // Find next boundary + size_t nextBoundary = body.find(fullBoundary, pos); + if (nextBoundary == std::string::npos) + { + break; + } + + // Extract this part (excluding the boundary and preceding CRLF) + size_t partEnd = nextBoundary; + while (partEnd > pos && (body[partEnd - 1] == '\r' || body[partEnd - 1] == '\n')) + { + partEnd--; + } + + std::string part = body.substr(pos, partEnd - pos); + + // Parse the part + if (!part.empty()) + { + parseMultipartPart(part); + } + + // Move to next part + pos = nextBoundary + fullBoundary.length(); + + // Check if this is the final boundary + if (pos + 2 <= body.length() && body.substr(pos, 2) == "--") + { + break; + } + } + + Log::info("Parsed " + std::to_string(uploadedFiles_.size()) + " file(s) from multipart form data"); +} + +bool UploadHandler::parseMultipartPart(const std::string &part) +{ + Log::trace(LOCATION); + + // Find the blank line separating headers from content + size_t headerEnd = part.find("\r\n\r\n"); + if (headerEnd == std::string::npos) + { + headerEnd = part.find("\n\n"); + if (headerEnd == std::string::npos) + { + Log::warning("Malformed multipart part: no header/content separator"); + return false; + } + } + + std::string headers = part.substr(0, headerEnd); + size_t contentStart = headerEnd + (part[headerEnd] == '\r' ? 4 : 2); + + if (contentStart >= part.length()) + { + Log::debug("Empty multipart part"); + return false; + } + + // Extract Content-Disposition header + std::string disposition = extractHeaderValue(headers, "Content-Disposition"); + if (disposition.empty()) + { + Log::warning("Multipart part missing Content-Disposition header"); + return false; + } + + // Extract filename - if no filename, this is a regular form field, not a file + std::string filename = extractFilenameFromContentDisposition(disposition); + if (filename.empty()) + { + Log::debug("Multipart part is a form field, not a file"); + return false; + } + + // Extract field name + std::string fieldName = extractFieldNameFromContentDisposition(disposition); + + // Extract Content-Type (optional for files) + std::string fileContentType = extractHeaderValue(headers, "Content-Type"); + if (fileContentType.empty()) + { + fileContentType = "application/octet-stream"; + } + + // Extract file content + std::vector fileData(part.begin() + static_cast(contentStart), part.end()); + + // Allow zero-length files + if (fileData.size() > maxFileSize_) + { + Log::warning("File " + filename + " exceeds max size: " + std::to_string(fileData.size())); + throw std::runtime_error("File too large: " + filename); + } + + // Save the file + UploadedFile info; + info.fieldName = fieldName; + info.filename = filename; + info.contentType = fileContentType; + info.size = fileData.size(); + + if (!saveFile(filename, fileData, info)) + { + throw std::runtime_error("Failed to save file: " + filename); + } + + uploadedFiles_.push_back(info); + Log::info("Successfully uploaded file: " + filename + " (" + std::to_string(info.size) + " bytes)"); + + return true; +} + +std::string UploadHandler::extractHeaderValue(const std::string &headers, const std::string &headerName) const +{ + std::string searchName = headerName; + std::transform(searchName.begin(), searchName.end(), searchName.begin(), ::tolower); + + std::istringstream stream(headers); + std::string line; + + while (std::getline(stream, line)) + { + // Remove trailing \r if present + if (!line.empty() && line.back() == '\r') + { + line.pop_back(); + } + + size_t colonPos = line.find(':'); + if (colonPos == std::string::npos) + { + continue; + } + + std::string name = line.substr(0, colonPos); + std::transform(name.begin(), name.end(), name.begin(), ::tolower); + name = utils::trim(name); + + if (name == searchName) + { + std::string value = line.substr(colonPos + 1); + return utils::trim(value); + } + } + + return ""; +} + +std::string UploadHandler::extractFilenameFromContentDisposition(const std::string &disposition) const +{ + // Look for filename="..." or filename*=UTF-8''... + size_t filenamePos = disposition.find("filename="); + if (filenamePos == std::string::npos) + { + return ""; + } + + std::string filename = disposition.substr(filenamePos + 9); + + // Handle quoted filename + if (!filename.empty() && filename[0] == '"') + { + filename = filename.substr(1); + size_t endQuote = filename.find('"'); + if (endQuote != std::string::npos) + { + filename = filename.substr(0, endQuote); + } + } + else + { + // Unquoted - take until semicolon or end + size_t endPos = filename.find(';'); + if (endPos != std::string::npos) + { + filename = filename.substr(0, endPos); + } + } + + return utils::trim(filename); +} + +std::string UploadHandler::extractFieldNameFromContentDisposition(const std::string &disposition) const +{ + size_t namePos = disposition.find("name="); + if (namePos == std::string::npos) + { + return ""; + } + + std::string fieldName = disposition.substr(namePos + 5); + + // Handle quoted name + if (!fieldName.empty() && fieldName[0] == '"') + { + fieldName = fieldName.substr(1); + size_t endQuote = fieldName.find('"'); + if (endQuote != std::string::npos) + { + fieldName = fieldName.substr(0, endQuote); + } + } + else + { + // Unquoted - take until semicolon or end + size_t endPos = fieldName.find(';'); + if (endPos != std::string::npos) + { + fieldName = fieldName.substr(0, endPos); + } + } + + return utils::trim(fieldName); +} + +std::string UploadHandler::sanitizeFilename(const std::string &filename) const +{ + std::string sanitized; + sanitized.reserve(filename.length()); + + for (char c : filename) + { + // Allow alphanumeric, dots, dashes, underscores + if (std::isalnum(static_cast(c)) != 0 || c == '.' || c == '-' || c == '_') + { + sanitized += c; + } + else if (c == ' ') + { + sanitized += '_'; + } + // Skip other characters + } + + // Prevent directory traversal + if (sanitized.empty() || sanitized == "." || sanitized == "..") + { + sanitized = "upload"; + } + + // Limit length + if (sanitized.length() > 255) + { + sanitized = sanitized.substr(0, 255); + } + + return sanitized; +} + +std::string UploadHandler::generateUniqueFilename(const std::string &baseFilename) const +{ + std::string sanitized = sanitizeFilename(baseFilename); + std::string fullPath = uploadDir_ + "/" + sanitized; + + // If file doesn't exist, use it as-is + struct stat st = {}; + if (stat(fullPath.c_str(), &st) != 0) + { + return sanitized; + } + + // File exists - add timestamp and random suffix + std::string name = sanitized; + std::string ext; + + size_t dotPos = sanitized.rfind('.'); + if (dotPos != std::string::npos) + { + name = sanitized.substr(0, dotPos); + ext = sanitized.substr(dotPos); + } + + // Generate unique suffix with timestamp + random number + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution dis(1000, 9999); + + time_t now = time(nullptr); + std::ostringstream oss; + oss << name << "_" << now << "_" << dis(gen) << ext; + + return oss.str(); +} + +bool UploadHandler::saveFile(const std::string &filename, const std::vector &data, UploadedFile &info) +{ + Log::trace(LOCATION); + + std::string uniqueFilename = generateUniqueFilename(filename); + std::string fullPath = uploadDir_ + "/" + uniqueFilename; + + Log::debug("Saving file to: " + fullPath); + + std::ofstream file(fullPath, std::ios::binary); + if (!file.is_open()) + { + Log::error("Failed to open file for writing: " + fullPath); + return false; + } + + file.write(reinterpret_cast(data.data()), static_cast(data.size())); + file.close(); + + if (!file.good()) + { + Log::error("Error writing file: " + fullPath); + return false; + } + + info.savedPath = fullPath; + return true; +} + +void UploadHandler::sendSuccessResponse() +{ + Log::trace(LOCATION); + + response_.setStatus(201); // Created + + // Build JSON response with uploaded file info + std::ostringstream json; + json << "{\n"; + json << " \"success\": true,\n"; + json << " \"message\": \"Files uploaded successfully\",\n"; + json << " \"files\": [\n"; + + for (size_t i = 0; i < uploadedFiles_.size(); ++i) + { + const auto &file = uploadedFiles_[i]; + json << " {\n"; + json << " \"fieldName\": \"" << file.fieldName << "\",\n"; + json << " \"filename\": \"" << file.filename << "\",\n"; + json << " \"contentType\": \"" << file.contentType << "\",\n"; + json << " \"size\": " << file.size << ",\n"; + json << " \"path\": \"" << file.savedPath << "\"\n"; + json << " }"; + + if (i < uploadedFiles_.size() - 1) + { + json << ","; + } + json << "\n"; + } + + json << " ]\n"; + json << "}\n"; + + response_.addHeader("Content-Type", "application/json"); + response_.setBody(json.str()); +} + +void UploadHandler::sendErrorResponse(uint16_t statusCode, const std::string &message) +{ + Log::trace(LOCATION); + + response_.setStatus(statusCode); + + std::ostringstream json; + json << "{\n"; + json << " \"success\": false,\n"; + json << " \"error\": \"" << message << "\"\n"; + json << "}\n"; + + response_.addHeader("Content-Type", "application/json"); + response_.setBody(json.str()); +} diff --git a/webserv/handler/UploadHandler.hpp b/webserv/handler/UploadHandler.hpp index 1bb87ae..dc5a4d8 100644 --- a/webserv/handler/UploadHandler.hpp +++ b/webserv/handler/UploadHandler.hpp @@ -1,15 +1,56 @@ #pragma once -#include // for AHandler +#include + +#include +#include +#include + +class HttpRequest; +class HttpResponse; class UploadHandler : public AHandler { public: UploadHandler(const HttpRequest &request, HttpResponse &response); + UploadHandler(const UploadHandler &other) = delete; + UploadHandler &operator=(const UploadHandler &other) = delete; + UploadHandler(UploadHandler &&other) noexcept = delete; + UploadHandler &operator=(UploadHandler &&other) noexcept = delete; + + ~UploadHandler() = default; + void handle() override; + + protected: void handleTimeout() override; private: - void uploadFile(const std::string &path, const std::string &data); -}; \ No newline at end of file + struct UploadedFile + { + std::string fieldName; + std::string filename; + std::string contentType; + std::string savedPath; + size_t size; + }; + + std::string uploadDir_; + size_t maxFileSize_; + std::vector uploadedFiles_; + + void parseMultipartFormData(); + bool saveFile(const std::string &filename, const std::vector &data, UploadedFile &info); + std::string sanitizeFilename(const std::string &filename) const; + std::string generateUniqueFilename(const std::string &baseFilename) const; + void sendSuccessResponse(); + void sendErrorResponse(uint16_t statusCode, const std::string &message); + + // Multipart parsing helpers + std::string extractBoundary(const std::string &contentType) const; + bool parseMultipartPart(const std::string &part); + std::string extractHeaderValue(const std::string &headers, const std::string &headerName) const; + std::string extractFilenameFromContentDisposition(const std::string &disposition) const; + std::string extractFieldNameFromContentDisposition(const std::string &disposition) const; +}; diff --git a/webserv/router/Router.cpp b/webserv/router/Router.cpp index 4858f2d..0b1ae47 100644 --- a/webserv/router/Router.cpp +++ b/webserv/router/Router.cpp @@ -9,6 +9,7 @@ #include // for FileHandler #include // for RedirectHandler #include // for URI +#include // for UploadHandler #include // for HttpRequest #include #include // for Log, LOCATION @@ -63,6 +64,11 @@ std::unique_ptr Router::handleRequest() { return std::make_unique(request, response); } + if (request.getUri().isUpload() && request.getMethod() == "POST") + { + Log::debug("Handling file upload"); + return std::make_unique(request, response); + } if (request.getUri().isCgi()) { try