biskuteri-cafe-JKomasto2/MastodonApi.java
Snowyfox e6fea4c061 Fixed bug when redraft makes no changes
(Before this, JKomasto and sometimes the Mastodon web client would get '411 Record Not Found' when submitting the same text after deleting and redrafting. Presumably the Mastodon server caches both whether an idempotency key was fulfilled and which post it leads to, and for some reason it looks up the second and fails.)
2022-05-31 03:39:56 -04:00

779 lines
24 KiB
Java

import cafe.biskuteri.hinoki.Tree;
import cafe.biskuteri.hinoki.JsonConverter;
import cafe.biskuteri.hinoki.DSVTokeniser;
import java.net.URL;
import java.net.URLConnection;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.net.SocketTimeoutException;
import java.io.Reader;
import java.io.Writer;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.FileInputStream;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
class
MastodonApi {
private String
instanceUrl;
private Tree<String>
appCredentials,
accessToken,
accountDetails;
// - -%- -
private static final String
SCOPES = "read+write";
// ---%-@-%---
public String
getInstanceUrl() { return instanceUrl; }
public Tree<String>
getAppCredentials() { return appCredentials; }
public Tree<String>
getAccessToken() { return accessToken; }
public Tree<String>
getAccountDetails() { return accountDetails; }
public void
setInstanceUrl(String a) { instanceUrl = a; }
public void
setAppCredentials(Tree<String> a) { appCredentials = a; }
public void
setAccessToken(Tree<String> a) { accessToken = a; }
public void
setAccountDetails(Tree<String> a) { accountDetails = a; }
public void
testUrlConnection(String url, RequestListener handler)
{
try
{
URL endpoint = new URL(url);
HttpURLConnection conn = cast(endpoint.openConnection());
conn.connect();
wrapResponseInTree(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
getAppCredentials(RequestListener handler)
{
assert instanceUrl != null;
try
{
URL endpoint = new URL(instanceUrl + "/api/v1/apps");
HttpURLConnection conn = cast(endpoint.openConnection());
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.connect();
Writer output = owriter(conn.getOutputStream());
output.write("client_name=JKomasto alpha");
output.write("&redirect_uris=urn:ietf:wg:oauth:2.0:oob");
output.write("&scopes=" + SCOPES);
output.close();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public URI
getAuthorisationURL()
{
assert instanceUrl != null;
assert appCredentials != null;
String clientId = appCredentials.get("client_id").value;
try
{
StringBuilder b = new StringBuilder();
b.append(instanceUrl);
b.append("/oauth/authorize");
// Be careful of the spelling!!
b.append("?response_type=code");
b.append("&redirect_uri=urn:ietf:wg:oauth:2.0:oob");
b.append("&scope=" + SCOPES);
b.append("&client_id=" + clientId);
return new URI(b.toString());
}
catch (URISyntaxException eUs) { assert false; return null; }
}
public void
getAccessToken(String authorisationCode, RequestListener handler)
{
assert instanceUrl != null;
assert appCredentials != null;
String id = appCredentials.get("client_id").value;
String secret = appCredentials.get("client_secret").value;
try
{
URL endpoint = new URL(instanceUrl + "/oauth/token");
HttpURLConnection conn = cast(endpoint.openConnection());
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.connect();
Writer output = owriter(conn.getOutputStream());
output.write("client_id=" + id);
output.write("&client_secret=" + secret);
output.write("&redirect_uri=urn:ietf:wg:oauth:2.0:oob");
output.write("&grant_type=authorization_code");
output.write("&scope=" + SCOPES);
output.write("&code=" + authorisationCode);
output.close();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
getAccountDetails(RequestListener handler)
{
assert accessToken != null;
String token = accessToken.get("access_token").value;
try
{
String s = "/api/v1/accounts/verify_credentials";
URL endpoint = new URL(instanceUrl + s);
HttpURLConnection conn = cast(endpoint.openConnection());
String s2 = "Bearer " + token;
conn.setRequestProperty("Authorization", s2);
conn.connect();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
getTimelinePage(
TimelineType type,
int count, String maxId, String minId,
String accountId, String listId,
RequestListener handler)
{
String token = accessToken.get("access_token").value;
assert !(accountId != null && listId != null);
String url = instanceUrl + "/api/v1";
if (accountId != null)
{
url += "/accounts/" + accountId + "/statuses";
}
else if (listId != null)
{
url += "/lists/" + listId;
}
else switch (type)
{
case FEDERATED:
case LOCAL: url += "/timelines/public"; break;
case HOME: url += "/timelines/home"; break;
default: assert false;
}
url += "?limit=" + count;
if (maxId != null) url += "&max_id=" + maxId;
if (minId != null) url += "&min_id=" + minId;
// This is a GET endpoint, it rejects receiving
// query params through the body.
try
{
URL endpoint = new URL(url);
HttpURLConnection conn = cast(endpoint.openConnection());
String s2 = "Bearer " + token;
conn.setRequestProperty("Authorization", s2);
conn.connect();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
setPostFavourited(
String postId, boolean favourited,
RequestListener handler)
{
String token = accessToken.get("access_token").value;
String s1 = "/api/v1/statuses/" + postId;
String s2 = favourited ? "/favourite" : "/unfavourite";
String url = instanceUrl + s1 + s2;
try
{
URL endpoint = new URL(url);
HttpURLConnection conn = cast(endpoint.openConnection());
String s3 = "Bearer " + token;
conn.setRequestProperty("Authorization", s3);
conn.setRequestMethod("POST");
conn.connect();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
setPostBoosted(
String postId, boolean boosted,
RequestListener handler)
{
String token = accessToken.get("access_token").value;
String s1 = "/api/v1/statuses/" + postId;
String s2 = boosted ? "/reblog" : "/unreblog";
String url = instanceUrl + s1 + s2;
try
{
URL endpoint = new URL(url);
HttpURLConnection conn = cast(endpoint.openConnection());
String s3 = "Bearer " + token;
conn.setRequestProperty("Authorization", s3);
conn.setRequestMethod("POST");
conn.connect();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
submit(
String text, PostVisibility visibility,
String replyTo, String contentWarning,
RequestListener handler)
{
String token = accessToken.get("access_token").value;
String visibilityParam = "direct";
switch (visibility)
{
case PUBLIC: visibilityParam = "public"; break;
case UNLISTED: visibilityParam = "unlisted"; break;
case FOLLOWERS: visibilityParam = "private"; break;
case MENTIONED: visibilityParam = "direct"; break;
default: assert false;
}
String url = instanceUrl + "/api/v1/statuses";
try
{
text = encode(text);
contentWarning = encode(contentWarning);
URL endpoint = new URL(url);
HttpURLConnection conn = cast(endpoint.openConnection());
String s1 = "Bearer " + token;
conn.setRequestProperty("Authorization", s1);
String s2 = Integer.toString(handler.hashCode());
conn.setRequestProperty("Idempotency-Key", s2);
conn.setDoOutput(true);
conn.setRequestMethod("POST");
conn.connect();
Writer output = owriter(conn.getOutputStream());
output.write("status=" + text);
output.write("&visibility=" + visibilityParam);
if (replyTo != null) {
output.write("&in_reply_to_id=" + replyTo);
}
if (contentWarning != null) {
output.write("&spoiler_text=" + contentWarning);
}
output.close();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
getNotifications(
int count, String maxId, String minId,
RequestListener handler)
{
String token = accessToken.get("access_token").value;
String url = instanceUrl + "/api/v1/notifications";
url += "?limit=" + count;
if (maxId != null) url += "&max_id=" + maxId;
if (minId != null) url += "&min_id=" + minId;
try
{
URL endpoint = new URL(url);
HttpURLConnection conn = cast(endpoint.openConnection());
String s1 = "Bearer " + token;
conn.setRequestProperty("Authorization", s1);
conn.connect();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
deletePost(String postId, RequestListener handler)
{
String token = accessToken.get("access_token").value;
String url = instanceUrl + "/api/v1/statuses/" + postId;
try
{
URL endpoint = new URL(url);
HttpURLConnection conn = cast(endpoint.openConnection());
String s1 = "Bearer " + token;
conn.setRequestProperty("Authorization", s1);
conn.setRequestMethod("DELETE");
conn.connect();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
getSpecificPost(String postId, RequestListener handler)
{
String token = accessToken.get("access_token").value;
String url = instanceUrl + "/api/v1/statuses/" + postId;
try
{
URL endpoint = new URL(url);
HttpURLConnection conn = cast(endpoint.openConnection());
String s1 = "Bearer " + token;
conn.setRequestProperty("Authorization", s1);
conn.connect();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
getPostContext(String postId, RequestListener handler)
{
String token = accessToken.get("access_token").value;
String s1 = instanceUrl + "/api/v1/statuses/";
String s2 = postId + "/context";
String url = s1 + s2;
try
{
URL endpoint = new URL(url);
HttpURLConnection conn = cast(endpoint.openConnection());
String s3 = "Bearer " + token;
conn.setRequestProperty("Authorization", s3);
conn.connect();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
getAccounts(String query, RequestListener handler)
{
assert query != null;
String token = accessToken.get("access_token").value;
String url = instanceUrl + "/api/v1/accounts/search";
url += "?q=" + encode(query);
try
{
URL endpoint = new URL(url);
HttpURLConnection conn = cast(endpoint.openConnection());
String s1 = "Bearer " + token;
conn.setRequestProperty("Authorization", s1);
conn.connect();
doStandardJsonReturn(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
uploadFile(File file, RequestListener handler)
{
assert file != null;
assert file.canRead();
String bct =
"multipart/form-data; "
+ "boundary=\"JKomastoFileUpload\"";
String fsb = "--JKomastoFileUpload\r\n";
String feb = "\r\n--JKomastoFileUpload--\r\n";
String fcd =
"Content-Disposition: form-data; "
+ "name=\"file\"; "
+ "filename=\"" + file.getName() + "\"\r\n";
String fct = "Content-Type: image/png\r\n\r\n";
int contentLength = 0;
contentLength += fsb.length();
contentLength += feb.length();
contentLength += fcd.length();
contentLength += fct.length();
contentLength += file.length();
/*
* (知) This was an absurdity to debug. Contrary to
* cURL, Java sets default values for some headers,
* some of which are restricted, meaning you can't
* arbitrarily change them. Content-Length is one
* of them, set to 2^14-1 bytes. I'm pretty sure
* the file I was uploading was under this, but
* anyways one of the two parties was stopping me
* from finishing transferring my form data.
*
* They didn't mention this in the Javadocs.
* I noticed HttpURLConnection#setChunkedStreamingMode
* and #setFixedLengthStreamingMode by accident.
* Turns out, the latter is how I do what cURL and
* Firefox are doing - precalculate the exact size
* of the body and set the content length to it.
* Unfortunately, this is not flexible, we have to
* be exact. Thankfully, my answers pass..
*
* On the other side, Mastodon is obtuse as usual.
* They had code that basically throws a generic 500
* upon any sort of error from their library[1]. What
* problem the library had with my requests, I could
* never know. There is an undocumented requirement
* that you must put a filename in the content
* disposition. That one I found by guessing.
*
* I solved this with the help of -Djavax.net.debug,
* which revealed to me how my headers and body
* differed from cURL and Firefox. If this issue
* happens again, I advise giving up.
*
* [1] app/controllers/api/v1/media_controller.rb
* #create. 3 March 2022
*/
String token = accessToken.get("access_token").value;
String url = instanceUrl + "/api/v1/media/";
try
{
URL endpoint = new URL(url);
HttpURLConnection conn = cast(endpoint.openConnection());
String s1 = "Bearer " + token;
conn.setRequestProperty("Authorization", s1);
conn.setDoOutput(true);
conn.setRequestMethod("POST");
conn.setFixedLengthStreamingMode(contentLength);
conn.setRequestProperty("Content-Type", bct);
conn.setRequestProperty("Accept", "*/*");
conn.connect();
OutputStream ostream = conn.getOutputStream();
InputStream istream = new FileInputStream(file);
ostream.write(fsb.getBytes());
ostream.write(fcd.getBytes());
ostream.write(fct.getBytes());
int c; while ((c = istream.read()) != -1)
ostream.write(c);
ostream.write(feb.getBytes());
istream.close();
ostream.close();
wrapResponseInTree(conn, handler);
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
public void
monitorTimeline(
TimelineType type, ServerSideEventsListener handler)
{
String token = accessToken.get("access_token").value;
String url = instanceUrl + "/api/v1/streaming";
switch (type)
{
case FEDERATED: url += "/public"; break;
case LOCAL: url += "/public/local"; break;
case HOME: url += "/user"; break;
default: assert false;
}
try
{
URL endpoint = new URL(url);
HttpURLConnection conn = cast(endpoint.openConnection());
String s = "Bearer " + token;
conn.setRequestProperty("Authorization", s);
conn.connect();
int code = conn.getResponseCode();
if (code >= 300)
{
Reader input = ireader(conn.getErrorStream());
Tree<String> response = JsonConverter.convert(input);
input.close();
handler.requestFailed(code, response);
return;
}
conn.setReadTimeout(500);
Reader input = ireader(conn.getInputStream());
BufferedReader br = new BufferedReader(input);
Thread thread = Thread.currentThread();
while (true) try
{
String line = br.readLine();
if (line != null) handler.lineReceived(line);
}
catch (SocketTimeoutException eSt)
{
if (thread.interrupted()) break;
}
}
catch (IOException eIo) { handler.connectionFailed(eIo); }
}
// - -%- -
private void
doStandardJsonReturn(
HttpURLConnection conn, RequestListener handler)
throws IOException
{
int code = conn.getResponseCode();
if (code >= 300)
{
Reader input = ireader(conn.getErrorStream());
Tree<String> response = JsonConverter.convert(input);
input.close();
handler.requestFailed(code, response);
return;
}
Reader input = ireader(conn.getInputStream());
Tree<String> response = JsonConverter.convert(input);
input.close();
handler.requestSucceeded(response);
}
private void
wrapResponseInTree(
HttpURLConnection conn, RequestListener handler)
throws IOException
{
int code = conn.getResponseCode();
if (code >= 300)
{
Reader input = ireader(conn.getErrorStream());
Tree<String> response = fromPlain(input);
input.close();
handler.requestFailed(code, response);
return;
}
Reader input = ireader(conn.getInputStream());
Tree<String> response = fromPlain(input);
input.close();
handler.requestSucceeded(response);
}
// - -%- -
public static void
debugPrint(Tree<String> tree)
{
debugPrint(tree, "");
}
public static void
debugPrint(Tree<String> tree, String prefix)
{
System.err.print(prefix);
System.err.print(deescape(tree.key));
System.err.print(": ");
System.err.println(deescape(tree.value));
for (Tree<String> child: tree)
debugPrint(child, prefix + " ");
}
// - -%- -
private static String
deescape(String string)
{
if (string == null) return string;
string = string.replaceAll("\n", "\\\\n");
return string;
}
private static Tree<String>
fromPlain(Reader r)
throws IOException
{
StringBuilder b = new StringBuilder();
int c; while ((c = r.read()) != -1) b.append((char)c);
Tree<String> leaf = new Tree<String>();
leaf.key = "body";
leaf.value = b.toString();
Tree<String> doc = new Tree<String>();
doc.add(leaf);
return doc;
}
private static String
encode(String s)
{
try {
if (s == null) return null;
return URLEncoder.encode(s, "UTF-8");
}
catch (UnsupportedEncodingException eUe) {
assert false;
return null;
}
}
private static HttpURLConnection
cast(URLConnection conn)
{
return (HttpURLConnection)conn;
}
private static InputStreamReader
ireader(InputStream is)
throws IOException
{
assert is != null;
return new InputStreamReader(is);
}
private static OutputStreamWriter
owriter(OutputStream os)
throws IOException
{
assert os != null;
return new OutputStreamWriter(os);
}
// ---%-@-%---
public void
loadCache()
throws IOException
{
FileReader r = new FileReader(getCachePath());
DSVTokeniser.Options o = new DSVTokeniser.Options();
Tree<String> row1 = DSVTokeniser.tokenise(r, o);
Tree<String> row2 = DSVTokeniser.tokenise(r, o);
Tree<String> row3 = DSVTokeniser.tokenise(r, o);
assert !row1.get(0).value.equals(o.endOfStreamValue);
assert !row2.get(0).value.equals(o.endOfStreamValue);
assert !row3.get(0).value.equals(o.endOfStreamValue);
r.close();
// Prepare to bark like mad.
boolean yes10 = !row1.get(0).value.equals(o.endOfStreamValue);
boolean yes20 = !row2.get(0).value.equals(o.endOfStreamValue);
boolean yes30 = !row3.get(0).value.equals(o.endOfStreamValue);
boolean yes11 = row1.size() == 1;
boolean yes21 = row2.size() == 2;
boolean yes31 = row3.size() == 1;
boolean all = yes10 & yes20 & yes30 & yes11 & yes21 & yes31;
if (!all) {
throw new IOException("Cache has invalid format!");
}
setInstanceUrl(row1.get(0).value);
appCredentials = new Tree<String>();
appCredentials.add(new Tree<String>());
appCredentials.add(new Tree<String>());
appCredentials.get(0).key = "client_id";
appCredentials.get(0).value = row2.get(0).value;
appCredentials.get(1).key = "client_secret";
appCredentials.get(1).value = row2.get(1).value;
accessToken = new Tree<String>();
accessToken.add(new Tree<String>());
accessToken.get(0).key = "access_token";
accessToken.get(0).value = row3.get(0).value;
}
public void
saveToCache()
throws IOException
{
String f10 = instanceUrl;
String f20 = appCredentials.get("client_id").value;
String f21 = appCredentials.get("client_secret").value;
String f30 = accessToken.get("access_token").value;
f10 = f10.replaceAll(":", "\\\\:") + "\n";
f20 = f20.replaceAll(":", "\\\\:") + ":";
f21 = f21.replaceAll(":", "\\\\:") + "\n";
f30 = f30.replaceAll(":", "\\\\:") + "\n";
FileWriter w = new FileWriter(getCachePath());
w.write(f10);
w.write(f20);
w.write(f21);
w.write(f30);
w.close();
}
// - -%- -
private static String
getCachePath()
{
String userHome = System.getProperty("user.home");
String osName = System.getProperty("os.name");
boolean isWindows = osName.contains("Windows");
boolean isUnix = !isWindows;
// We assume. If you're running JKomasto in classic Mac OS
// for some reason, you should probably edit the code..
String configDir = isWindows ? "AppData/Local" : ".config";
String filename = "jkomasto.cache.dsv";
String path = userHome + "/" + configDir + "/" + filename;
return path;
}
}