Added base64 encoded attachment support (#966)

* Added base64 encoded attachment support

* Added final

* Added full RFC 2397 support

* Added feedback

* Update doc

* Update signal-cli.1.adoc

Co-authored-by: Sebastian Scheibner <asamk@gmx.de>
This commit is contained in:
Kevin 2022-06-04 11:11:35 +02:00 committed by GitHub
parent 63e94a9fb4
commit cb5e3c6bf7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 110 additions and 11 deletions

View file

@ -51,7 +51,7 @@ public class AttachmentHelper {
} }
public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws IOException, AttachmentInvalidException { public SignalServiceAttachmentPointer uploadAttachment(String attachment) throws IOException, AttachmentInvalidException {
var attachmentStream = AttachmentUtils.createAttachmentStream(new File(attachment)); var attachmentStream = AttachmentUtils.createAttachmentStream(attachment);
return uploadAttachment(attachmentStream); return uploadAttachment(attachmentStream);
} }

View file

@ -6,6 +6,7 @@ import org.whispersystems.signalservice.api.util.StreamDetails;
import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec; import org.whispersystems.signalservice.internal.push.http.ResumableUploadSpec;
import java.io.File; import java.io.File;
import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -19,17 +20,18 @@ public class AttachmentUtils {
} }
final var signalServiceAttachments = new ArrayList<SignalServiceAttachmentStream>(attachments.size()); final var signalServiceAttachments = new ArrayList<SignalServiceAttachmentStream>(attachments.size());
for (var attachment : attachments) { for (var attachment : attachments) {
signalServiceAttachments.add(createAttachmentStream(new File(attachment))); signalServiceAttachments.add(createAttachmentStream(attachment));
} }
return signalServiceAttachments; return signalServiceAttachments;
} }
public static SignalServiceAttachmentStream createAttachmentStream(File attachmentFile) throws AttachmentInvalidException { public static SignalServiceAttachmentStream createAttachmentStream(String attachment) throws AttachmentInvalidException {
try { try {
final var streamDetails = Utils.createStreamDetailsFromFile(attachmentFile); final var streamDetails = Utils.createStreamDetails(attachment);
return createAttachmentStream(streamDetails, Optional.of(attachmentFile.getName()));
return createAttachmentStream(streamDetails.first(), streamDetails.second());
} catch (IOException e) { } catch (IOException e) {
throw new AttachmentInvalidException(attachmentFile.toString(), e); throw new AttachmentInvalidException(attachment, e);
} }
} }

View file

@ -0,0 +1,70 @@
package org.asamk.signal.manager.util;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@SuppressWarnings({"java:S6218"})
public record DataURI(String mediaType, Map<String, String> parameter, byte[] data) {
public static final Pattern DATA_URI_PATTERN = Pattern.compile(
"\\Adata:(?<type>.+?/.+?)?(?<parameters>;.+?=.+?)?(?<base64>;base64)?,(?<data>.+)\\z",
Pattern.CASE_INSENSITIVE);
public static final Pattern PARAMETER_PATTERN = Pattern.compile("\\G;(?<key>.+)=(?<value>.+)",
Pattern.CASE_INSENSITIVE);
public static final String DEFAULT_TYPE = "text/plain";
/**
* Generates a new {@link DataURI} object that follows
* <a href="https://datatracker.ietf.org/doc/html/rfc2397">RFC 2397</a> from the given string.
* <p>
* The {@code dataURI} must be of the form:
* <p>
* {@code
* data:[<mediatype>][;base64],<data>
* }
* <p>
* The {@code <mediatype>} is an Internet media type specification (with
* optional parameters.) The appearance of ";base64" means that the data
* is encoded as base64. Without ";base64", the data is represented using (ASCII) URL Escaped encoding.
* If {@code <mediatype>} is omitted, it defaults to {@link DataURI#DEFAULT_TYPE}.
* Parameter values should use the URL Escaped encoding.
*
* @param dataURI the data URI
* @return a data URI object
* @throws IllegalArgumentException if the given string is not a valid data URI
*/
public static DataURI of(final String dataURI) {
final var matcher = DATA_URI_PATTERN.matcher(dataURI);
if (!matcher.find()) {
throw new IllegalArgumentException("The given string is not a valid data URI.");
}
final Map<String, String> parameters = new HashMap<>();
final var params = matcher.group("parameters");
if (params != null) {
final Matcher paramsMatcher = PARAMETER_PATTERN.matcher(params);
while (paramsMatcher.find()) {
final var key = paramsMatcher.group("key");
final var value = URLDecoder.decode(paramsMatcher.group("value"), StandardCharsets.UTF_8);
parameters.put(key, value);
}
}
final boolean isBase64 = matcher.group("base64") != null;
final byte[] data;
if (isBase64) {
data = Base64.getDecoder().decode(matcher.group("data").getBytes(StandardCharsets.UTF_8));
} else {
data = URLDecoder.decode(matcher.group("data"), StandardCharsets.UTF_8).getBytes(StandardCharsets.UTF_8);
}
return new DataURI(Optional.ofNullable(matcher.group("type")).orElse(DEFAULT_TYPE), parameters, data);
}
}

View file

@ -1,5 +1,6 @@
package org.asamk.signal.manager.util; package org.asamk.signal.manager.util;
import org.asamk.signal.manager.api.Pair;
import org.signal.libsignal.protocol.IdentityKey; import org.signal.libsignal.protocol.IdentityKey;
import org.signal.libsignal.protocol.fingerprint.Fingerprint; import org.signal.libsignal.protocol.fingerprint.Fingerprint;
import org.signal.libsignal.protocol.fingerprint.NumericFingerprintGenerator; import org.signal.libsignal.protocol.fingerprint.NumericFingerprintGenerator;
@ -9,6 +10,7 @@ import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.util.StreamDetails; import org.whispersystems.signalservice.api.util.StreamDetails;
import java.io.BufferedInputStream; import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.File; import java.io.File;
import java.io.FileInputStream; import java.io.FileInputStream;
import java.io.IOException; import java.io.IOException;
@ -17,9 +19,11 @@ import java.net.URLConnection;
import java.net.URLDecoder; import java.net.URLDecoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.util.Base64;
import java.util.HashMap; import java.util.HashMap;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import java.util.Spliterator; import java.util.Spliterator;
import java.util.Spliterators; import java.util.Spliterators;
import java.util.function.BiFunction; import java.util.function.BiFunction;
@ -31,10 +35,10 @@ public class Utils {
private final static Logger logger = LoggerFactory.getLogger(Utils.class); private final static Logger logger = LoggerFactory.getLogger(Utils.class);
public static String getFileMimeType(File file, String defaultMimeType) throws IOException { public static String getFileMimeType(final File file, final String defaultMimeType) throws IOException {
var mime = Files.probeContentType(file.toPath()); var mime = Files.probeContentType(file.toPath());
if (mime == null) { if (mime == null) {
try (InputStream bufferedStream = new BufferedInputStream(new FileInputStream(file))) { try (final InputStream bufferedStream = new BufferedInputStream(new FileInputStream(file))) {
mime = URLConnection.guessContentTypeFromStream(bufferedStream); mime = URLConnection.guessContentTypeFromStream(bufferedStream);
} }
} }
@ -44,13 +48,31 @@ public class Utils {
return mime; return mime;
} }
public static StreamDetails createStreamDetailsFromFile(File file) throws IOException { public static Pair<StreamDetails, Optional<String>> createStreamDetailsFromDataURI(final String dataURI) {
InputStream stream = new FileInputStream(file); final DataURI uri = DataURI.of(dataURI);
return new Pair<>(new StreamDetails(
new ByteArrayInputStream(uri.data()), uri.mediaType(), uri.data().length),
Optional.ofNullable(uri.parameter().get("filename")));
}
public static StreamDetails createStreamDetailsFromFile(final File file) throws IOException {
final InputStream stream = new FileInputStream(file);
final var size = file.length(); final var size = file.length();
final var mime = getFileMimeType(file, "application/octet-stream"); final var mime = getFileMimeType(file, "application/octet-stream");
return new StreamDetails(stream, mime, size); return new StreamDetails(stream, mime, size);
} }
public static Pair<StreamDetails, Optional<String>> createStreamDetails(final String value) throws IOException {
try {
return createStreamDetailsFromDataURI(value);
} catch (final IllegalArgumentException e) {
final File f = new File(value);
return new Pair<>(createStreamDetailsFromFile(f), Optional.of(f.getName()));
}
}
public static Fingerprint computeSafetyNumber( public static Fingerprint computeSafetyNumber(
boolean isUuidCapable, boolean isUuidCapable,
SignalServiceAddress ownAddress, SignalServiceAddress ownAddress,

View file

@ -231,6 +231,9 @@ Read the message from standard input.
*-a* [ATTACHMENT [ATTACHMENT ...]], *--attachment* [ATTACHMENT [ATTACHMENT ...]]:: *-a* [ATTACHMENT [ATTACHMENT ...]], *--attachment* [ATTACHMENT [ATTACHMENT ...]]::
Add one or more files as attachment. Add one or more files as attachment.
Can be either a file path or a data URI. Data URI encoded attachments must follow the RFC 2397.
Additionally a file name can be added:
e.g.: `data:<MIME-TYPE>;filename=<FILENAME>;base64,<BASE64 ENCODED DATA>`
*--sticker* STICKER:: *--sticker* STICKER::
Send a sticker of a locally known sticker pack (syntax: stickerPackId:stickerId). Send a sticker of a locally known sticker pack (syntax: stickerPackId:stickerId).

View file

@ -55,7 +55,9 @@ public class SendCommand implements JsonRpcLocalCommand {
mut.addArgument("--message-from-stdin") mut.addArgument("--message-from-stdin")
.action(Arguments.storeTrue()) .action(Arguments.storeTrue())
.help("Read the message from standard input."); .help("Read the message from standard input.");
subparser.addArgument("-a", "--attachment").nargs("*").help("Add file as attachment"); subparser.addArgument("-a", "--attachment").nargs("*").help("Add an attachment. "
+ "Can be either a file path or a data URI. Data URI encoded attachments must follow the RFC 2397. Additionally a file name can be added, e.g. "
+ "data:<MIME-TYPE>;filename=<FILENAME>;base64,<BASE64 ENCODED DATA>.");
subparser.addArgument("-e", "--end-session", "--endsession") subparser.addArgument("-e", "--end-session", "--endsession")
.help("Clear session state and send end session message.") .help("Clear session state and send end session message.")
.action(Arguments.storeTrue()); .action(Arguments.storeTrue());