biskuteri-cafe-JKomasto2/MastodonApi.java

806 lines
25 KiB
Java

/* copyright
This file is part of JKomasto2.
Written in 2022 by Usawashi <usawashi16@yahoo.co.jp>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
copyright */
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,
String[] mediaIDs,
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);
}
for (String mediaID: mediaIDs) {
output.write("&media_ids[]=" + mediaID);
}
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, String alt, RequestListener handler)
{
assert file != null;
assert alt != 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
{
String s1 = "?description=" + encode(alt);
URL endpoint = new URL(url + s1);
HttpURLConnection conn = cast(endpoint.openConnection());
String s2 = "Bearer " + token;
conn.setRequestProperty("Authorization", s2);
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();
doStandardJsonReturn(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;
}
}