diff --git a/ext/src/main/java/zeroecho/ext/content/export/PiwigoExportDataContent.java b/ext/src/main/java/zeroecho/ext/content/export/PiwigoExportDataContent.java index 6b6b43d..2232c9d 100644 --- a/ext/src/main/java/zeroecho/ext/content/export/PiwigoExportDataContent.java +++ b/ext/src/main/java/zeroecho/ext/content/export/PiwigoExportDataContent.java @@ -34,36 +34,30 @@ package zeroecho.ext.content.export; import java.io.BufferedWriter; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.OutputStreamWriter; -import java.io.SequenceInputStream; import java.io.Writer; -import java.net.HttpURLConnection; import java.net.URI; -import java.net.URL; import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.HexFormat; +import java.util.Objects; + +import javax.net.ssl.HttpsURLConnection; import zeroecho.sdk.content.api.AbstractExportableDataContent; /** * {@code PiwigoExportDataContent} is a specialized exportable content class - * that supports uploading an image file to a Piwigo photo gallery server, - * either directly via HTTP POST or by generating platform-specific scripts for - * deferred uploading. + * that uploads an image file to a Piwigo photo gallery server using a + * structured HTTPS multipart request. * *
- * Depending on the export mode (RAW, BASH_SCRIPT, CMD_SCRIPT), it can: - *
* This class integrates with {@code AbstractExportableDataContent} and expects @@ -76,159 +70,249 @@ import zeroecho.sdk.content.api.AbstractExportableDataContent; *
*/ class PiwigoExportDataContent extends AbstractExportableDataContent { + private static final String INVALID_ENDPOINT = "Invalid Piwigo endpoint."; + private static final String INVALID_FILE_NAME = "Invalid Piwigo image file name."; + private static final String UNSUPPORTED_MODE = "Piwigo export supports RAW mode only."; + private static final int BOUNDARY_BYTES = 18; + private static final SecureRandom RANDOM = new SecureRandom(); + private final String imageFileName; - private final String piwigoUrl; + private final URI endpoint; private final String username; - private final String password; + private final char[] password; private final String albumId; + private final PiwigoTransport transport; /** * Constructs a new exportable Piwigo upload object. * * @param imageFileName the name of the image file to assign during upload or * script output - * @param piwigoUrl the URL of the Piwigo API endpoint + * @param piwigoUrl the absolute HTTPS URI of the Piwigo API endpoint * @param username the Piwigo username for authentication * @param password the Piwigo password * @param albumId the ID of the Piwigo album to which the image will be * uploaded + * @throws IllegalArgumentException if the endpoint or image filename is not + * safe for the structured request + * @throws NullPointerException if any argument is {@code null} */ public PiwigoExportDataContent(String imageFileName, String piwigoUrl, String username, String password, String albumId) { + this(imageFileName, piwigoUrl, username, password, albumId, new HttpsPiwigoTransport()); + } + + /* package */ PiwigoExportDataContent(String imageFileName, String piwigoUrl, String username, String password, + String albumId, PiwigoTransport transport) { super(); - this.imageFileName = imageFileName; - this.piwigoUrl = piwigoUrl; - this.username = username; - this.password = password; - this.albumId = albumId; + this.imageFileName = requireHeaderFileName(imageFileName); + this.endpoint = requireEndpoint(piwigoUrl); + this.username = Objects.requireNonNull(username, "Piwigo username is required."); + this.password = Objects.requireNonNull(password, "Piwigo password is required.").toCharArray(); + this.albumId = Objects.requireNonNull(albumId, "Piwigo album ID is required."); + this.transport = Objects.requireNonNull(transport, "Piwigo transport is required."); } /** - * Returns an {@code InputStream} that provides either the raw upload stream, or - * a platform-specific script depending on the export mode. + * Selects the export mode. * - * @return the resulting {@code InputStream} - * @throws IOException if reading the input or creating the stream fails + *+ * Piwigo requests support only {@link ExportMode#RAW}; executable script + * representations are rejected. + * + * @param mode the requested export mode + * @throws IllegalArgumentException if {@code mode} is not + * {@link ExportMode#RAW} + * @throws NullPointerException if {@code mode} is {@code null} + */ + @Override + public void setExportMode(ExportMode mode) { + ExportMode requiredMode = Objects.requireNonNull(mode, "Export mode is required."); + if (requiredMode != ExportMode.RAW) { + throw new IllegalArgumentException(UNSUPPORTED_MODE); + } + super.setExportMode(requiredMode); + } + + /** + * Uploads the configured input through a structured HTTPS multipart request. + * + * @return the Piwigo response stream + * @throws IOException if the HTTPS upload fails + * @throws IllegalStateException if input has not been configured */ @Override public InputStream getStream() throws IOException { if (input == null) { throw new IllegalStateException("Input not set."); } - - return switch (mode) { - case RAW -> performDirectUpload(input.getStream()); - case BASH_SCRIPT -> generateBashScript(input.getStream()); - case CMD_SCRIPT -> generateCmdScript(input.getStream()); - }; - } - - /** - * Performs a direct upload of the image data to the Piwigo server using a - * multipart/form-data HTTP POST request. - * - * @param dataStream the input stream of the binary image data - * @return the server's response stream - * @throws IOException if the upload fails - */ - private InputStream performDirectUpload(InputStream dataStream) throws IOException { - String boundary = "----Boundary" + System.currentTimeMillis(); - - URL url = URI.create(piwigoUrl).toURL(); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setDoOutput(true); - conn.setRequestMethod("POST"); - conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); - - try (OutputStream out = conn.getOutputStream(); - BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8))) { - - writeFormField(writer, boundary, "method", "pwg.images.add"); - writeFormField(writer, boundary, "username", username); - writeFormField(writer, boundary, "password", password); - writeFormField(writer, boundary, "category", albumId); - - writer.write("--" + boundary + "\r\n"); - writer.write("Content-Disposition: form-data; name=\"image\"; filename=\"" + imageFileName + "\"\r\n"); - writer.write("Content-Type: image/jpeg\r\n\r\n"); - writer.flush(); - - dataStream.transferTo(out); - out.flush(); - writer.write("\r\n--" + boundary + "--\r\n"); - writer.flush(); + if (mode != ExportMode.RAW) { + throw new IllegalStateException(UNSUPPORTED_MODE); } - - InputStream responseStream; + char[] requestPassword = password.clone(); try { - responseStream = conn.getInputStream(); - } catch (IOException e) { - InputStream errorStream = conn.getErrorStream(); - if (errorStream != null) { - return errorStream; - } - return new ByteArrayInputStream(("Error: " + e.getMessage()).getBytes(StandardCharsets.UTF_8)); + return transport.upload(endpoint, imageFileName, username, requestPassword, albumId, input.getStream()); + } finally { + java.util.Arrays.fill(requestPassword, '\0'); } + } - return responseStream; + @Override + public String toString() { + return "PiwigoExportDataContent[endpoint=" + endpoint.getScheme() + "://" + endpoint.getHost() + + ", configured=true]"; + } + + private static URI requireEndpoint(String value) { + Objects.requireNonNull(value, "Piwigo endpoint is required."); + if (containsControl(value) || containsEncodedControl(value)) { + throw new IllegalArgumentException(INVALID_ENDPOINT); + } + URI candidate; + try { + candidate = URI.create(value); + } catch (IllegalArgumentException exception) { + candidate = null; + } + if (candidate == null || !candidate.isAbsolute() || candidate.isOpaque() + || !"https".equalsIgnoreCase(candidate.getScheme()) + || candidate.getHost() == null || candidate.getHost().isBlank() || candidate.getRawAuthority() == null + || candidate.getRawUserInfo() != null || candidate.getRawFragment() != null) { + throw new IllegalArgumentException(INVALID_ENDPOINT); + } + return candidate; + } + + private static String requireHeaderFileName(String value) { + Objects.requireNonNull(value, "Piwigo image file name is required."); + if (value.isBlank() || containsControl(value) || value.indexOf('"') >= 0 || value.indexOf('\\') >= 0) { + throw new IllegalArgumentException(INVALID_FILE_NAME); + } + return value; + } + + private static boolean containsControl(String value) { + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index); + if (character <= 0x1f || character == 0x7f) { + return true; + } + } + return false; + } + + private static boolean containsEncodedControl(String value) { + int index = 0; + while (index + 2 < value.length()) { + if (value.charAt(index) == '%' && isHex(value.charAt(index + 1)) && isHex(value.charAt(index + 2))) { + int decoded = Character.digit(value.charAt(index + 1), 16) * 16 + + Character.digit(value.charAt(index + 2), 16); + if (decoded <= 0x1f || decoded == 0x7f) { + return true; + } + index += 3; + } else { + index++; + } + } + return false; + } + + private static boolean isHex(char character) { + return Character.digit(character, 16) >= 0; } /** - * Writes a single form field as part of a multipart/form-data HTTP request. - * - * @param writer the writer to output the field to - * @param boundary the multipart boundary - * @param name the name of the form field - * @param value the value of the form field - * @throws IOException if writing fails + * Executes one validated Piwigo upload while retaining URI and value + * boundaries. */ - private void writeFormField(Writer writer, String boundary, String name, String value) throws IOException { + /* package */ + @FunctionalInterface + interface PiwigoTransport { + /** + * Uploads one image using already validated request metadata. + * + * @param endpoint absolute HTTPS endpoint + * @param imageFileName validated multipart filename + * @param username opaque username value + * @param password caller-owned password copy + * @param albumId opaque album value + * @param dataStream image data + * @return response stream + * @throws IOException if the transport fails + */ + InputStream upload(URI endpoint, String imageFileName, String username, char[] password, String albumId, + InputStream dataStream) throws IOException; + } + + /** + * Default structured HTTPS multipart transport. + */ + private static final class HttpsPiwigoTransport implements PiwigoTransport { + @Override + public InputStream upload(URI endpoint, String imageFileName, String username, char[] password, String albumId, + InputStream dataStream) throws IOException { + byte[] boundaryRandom = new byte[BOUNDARY_BYTES]; + RANDOM.nextBytes(boundaryRandom); + String boundary = "----ZeroEcho" + HexFormat.of().formatHex(boundaryRandom); + + HttpsURLConnection connection = (HttpsURLConnection) endpoint.toURL().openConnection(); + connection.setDoOutput(true); + connection.setRequestMethod("POST"); + connection.setChunkedStreamingMode(8192); + connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary); + + try (InputStream source = dataStream; + OutputStream output = connection.getOutputStream()) { + writeMultipart(output, boundary, imageFileName, username, password, albumId, source); + } + + InputStream response = null; + try { + response = connection.getInputStream(); + } catch (IOException exception) { + InputStream errorStream = connection.getErrorStream(); + if (errorStream != null) { + return errorStream; + } + } + if (response == null) { + throw new IOException("Piwigo HTTPS upload failed."); + } + return response; + } + } + + /* package */ static void writeMultipart(OutputStream output, String boundary, String imageFileName, + String username, char[] password, String albumId, InputStream source) throws IOException { + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8)); + writeFormField(writer, boundary, "method", "pwg.images.add"); + writeFormField(writer, boundary, "username", username); + writeSecretFormField(writer, boundary, "password", password); + writeFormField(writer, boundary, "category", albumId); + writer.write("--" + boundary + "\r\n"); + writer.write("Content-Disposition: form-data; name=\"image\"; filename=\""); + writer.write(imageFileName); + writer.write("\"\r\nContent-Type: image/jpeg\r\n\r\n"); + writer.flush(); + source.transferTo(output); + output.flush(); + writer.write("\r\n--" + boundary + "--\r\n"); + writer.flush(); + } + + private static void writeFormField(Writer writer, String boundary, String name, String value) throws IOException { writer.write("--" + boundary + "\r\n"); writer.write("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n"); writer.write(value + "\r\n"); } - /** - * Generates a Bash script that reconstructs the image using a Base64 heredoc - * block and uploads it using {@code curl}. - * - * @param originalStream the original binary stream of the image - * @return a stream containing the complete shell script - */ - private InputStream generateBashScript(InputStream originalStream) { - InputStream header = new ByteArrayInputStream(("#!/bin/bash\nset -e\n\ncurl -X POST \"" + piwigoUrl + "\" \\\n" - + " -F method=\"pwg.images.add\" \\\n" + " -F username=\"" + username + "\" \\\n" + " -F password=\"" - + password + "\" \\\n" + " -F category=\"" + albumId + "\" \\\n" + " -F image=@<(base64 -d <<'EOF'\n") - .getBytes(StandardCharsets.UTF_8)); - - @SuppressWarnings("resource") - InputStream body = new Base64Stream(originalStream, null, 76, new byte[] { 10 }); - InputStream footer = new ByteArrayInputStream("EOF\n)\n".getBytes(StandardCharsets.UTF_8)); - - return new SequenceInputStream(new SequenceInputStream(header, body), footer); - } - - /** - * Generates a CMD batch script that reconstructs the image using certutil and - * uploads it using {@code curl}. - * - * @param originalStream the original binary stream of the image - * @return a stream containing the complete Windows batch script - */ - private InputStream generateCmdScript(InputStream originalStream) { - InputStream header = new ByteArrayInputStream( - "@echo off\nsetlocal\necho -----BEGIN BASE64----- > tmp.b64\n".getBytes(StandardCharsets.UTF_8)); - - @SuppressWarnings("resource") - InputStream body = new Base64Stream(originalStream, "echo ".getBytes(), 76, " >> tmp.b64\r\n".getBytes()); - InputStream footer = new ByteArrayInputStream( - ("echo -----END BASE64----- >> tmp.b64\n" + "certutil -decode tmp.b64 \"" + imageFileName + "\" >nul\n" - + "del tmp.b64\n" + "curl -X POST \"" + piwigoUrl + "\" ^\n" + " -F method=pwg.images.add ^\n" - + " -F username=" + username + " ^\n" + " -F password=" + password + " ^\n" + " -F category=" - + albumId + " ^\n" + " -F image=@" + imageFileName + "\n" + "del \"" + imageFileName + "\"\n") - .getBytes(StandardCharsets.UTF_8)); - - return new SequenceInputStream(new SequenceInputStream(header, body), footer); + private static void writeSecretFormField(Writer writer, String boundary, String name, char... value) + throws IOException { + writer.write("--" + boundary + "\r\n"); + writer.write("Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n"); + writer.write(value); + writer.write("\r\n"); } } diff --git a/ext/src/main/java/zeroecho/ext/content/export/package-info.java b/ext/src/main/java/zeroecho/ext/content/export/package-info.java index f9af688..b834e3c 100644 --- a/ext/src/main/java/zeroecho/ext/content/export/package-info.java +++ b/ext/src/main/java/zeroecho/ext/content/export/package-info.java @@ -36,10 +36,10 @@ * *
* This package provides streaming utilities and exportable content - * implementations that render {@link zeroecho.ext.content.api.DataContent} for + * implementations that render {@link zeroecho.sdk.content.api.DataContent} for * deployment to external platforms or for script-based transport. Exports can * be produced as raw bytes or as platform-specific scripts according to - * {@link zeroecho.ext.content.api.ExportableDataContent.ExportMode}. + * {@link zeroecho.sdk.content.api.ExportableDataContent.ExportMode}. *
* *