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.FileReader; import java.io.FileWriter; import java.io.BufferedReader; import java.io.IOException; import java.io.UnsupportedEncodingException; class MastodonApi { private String instanceUrl; private Tree appCredentials, accessToken, accountDetails; // - -%- - private static final String SCOPES = "read+write"; // ---%-@-%--- public String getInstanceUrl() { return instanceUrl; } public Tree getAppCredentials() { return appCredentials; } public Tree getAccessToken() { return accessToken; } public Tree getAccountDetails() { return accountDetails; } public void setInstanceUrl(String a) { instanceUrl = a; } public void setAppCredentials(Tree a) { appCredentials = a; } public void setAccessToken(Tree a) { accessToken = a; } public void setAccountDetails(Tree 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(text.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 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.setReadTimeout(500); conn.connect(); int code = conn.getResponseCode(); if (code >= 300) { Reader input = ireader(conn.getErrorStream()); Tree response = JsonConverter.convert(input); input.close(); handler.requestFailed(code, response); return; } 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 response = JsonConverter.convert(input); input.close(); handler.requestFailed(code, response); return; } Reader input = ireader(conn.getInputStream()); Tree 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 response = fromPlain(input); input.close(); handler.requestFailed(code, response); return; } Reader input = ireader(conn.getInputStream()); Tree response = fromPlain(input); input.close(); handler.requestSucceeded(response); } // - -%- - public static void debugPrint(Tree tree) { debugPrint(tree, ""); } public static void debugPrint(Tree tree, String prefix) { System.err.print(prefix); System.err.print(tree.key); System.err.print(": "); System.err.println(tree.value); for (Tree child: tree) debugPrint(child, prefix + " "); } // - -%- - private static Tree fromPlain(Reader r) throws IOException { StringBuilder b = new StringBuilder(); int c; while ((c = r.read()) != -1) b.append((char)c); Tree leaf = new Tree(); leaf.key = "body"; leaf.value = b.toString(); Tree doc = new Tree(); 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 { return new InputStreamReader(is); } private static OutputStreamWriter owriter(OutputStream os) throws IOException { return new OutputStreamWriter(os); } // ---%-@-%--- public void loadCache() throws IOException { FileReader r = new FileReader(getCachePath()); DSVTokeniser.Options o = new DSVTokeniser.Options(); Tree row1 = DSVTokeniser.tokenise(r, o); Tree row2 = DSVTokeniser.tokenise(r, o); Tree 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(); appCredentials.add(new Tree()); appCredentials.add(new Tree()); 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(); accessToken.add(new Tree()); 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; } }