/*
* Overchan Android (Meta Imageboard Client)
* Copyright (C) 2014-2016 miku-nyan <https://github.com/miku-nyan>
*
* 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 <http://www.gnu.org/licenses/>.
*/
package nya.miku.wishmaster.chans.horochan;
import java.io.BufferedReader;
import java.io.Closeable;
import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import cz.msebera.android.httpclient.HttpEntity;
import cz.msebera.android.httpclient.HttpResponse;
import cz.msebera.android.httpclient.StatusLine;
import cz.msebera.android.httpclient.client.methods.HttpUriRequest;
import cz.msebera.android.httpclient.client.methods.RequestBuilder;
import cz.msebera.android.httpclient.util.EntityUtils;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.graphics.drawable.Drawable;
import android.preference.CheckBoxPreference;
import android.preference.PreferenceGroup;
import android.support.v4.content.res.ResourcesCompat;
import nya.miku.wishmaster.R;
import nya.miku.wishmaster.api.CloudflareChanModule;
import nya.miku.wishmaster.api.interfaces.CancellableTask;
import nya.miku.wishmaster.api.interfaces.ProgressListener;
import nya.miku.wishmaster.api.models.AttachmentModel;
import nya.miku.wishmaster.api.models.BoardModel;
import nya.miku.wishmaster.api.models.CaptchaModel;
import nya.miku.wishmaster.api.models.DeletePostModel;
import nya.miku.wishmaster.api.models.PostModel;
import nya.miku.wishmaster.api.models.SendPostModel;
import nya.miku.wishmaster.api.models.SimpleBoardModel;
import nya.miku.wishmaster.api.models.ThreadModel;
import nya.miku.wishmaster.api.models.UrlPageModel;
import nya.miku.wishmaster.api.util.ChanModels;
import nya.miku.wishmaster.api.util.LazyPreferences;
import nya.miku.wishmaster.api.util.RegexUtils;
import nya.miku.wishmaster.api.util.UrlPathUtils;
import nya.miku.wishmaster.common.IOUtils;
import nya.miku.wishmaster.http.ExtendedMultipartBuilder;
import nya.miku.wishmaster.http.recaptcha.Recaptcha2;
import nya.miku.wishmaster.http.recaptcha.Recaptcha2solved;
import nya.miku.wishmaster.http.streamer.HttpRequestModel;
import nya.miku.wishmaster.http.streamer.HttpResponseModel;
import nya.miku.wishmaster.http.streamer.HttpStreamer;
import nya.miku.wishmaster.lib.org_json.JSONArray;
import nya.miku.wishmaster.lib.org_json.JSONException;
import nya.miku.wishmaster.lib.org_json.JSONObject;
import nya.miku.wishmaster.lib.org_json.JSONTokener;
public class HorochanModule extends CloudflareChanModule {
private static final String CHAN_NAME = "horochan.ru";
private static final String DOMAIN = "horochan.ru";
private static final SimpleBoardModel[] BOARDS = new SimpleBoardModel[] {
ChanModels.obtainSimpleBoardModel(CHAN_NAME, "b", "Random", null, false)
};
private static final String RECAPTCHA_PUBLIC_KEY = "6LerWhMTAAAAABCXYL2CEv-YyPeM5WbUTx3CknKD";
private static final Pattern URL_PATH_BOARDPAGE_PATTERN = Pattern.compile("([^/]+)(?:/(\\d+)?)?");
private static final Pattern URL_PATH_THREADPAGE_PATTERN = Pattern.compile("([^/]+)/thread/(\\d+)(?:#(\\d+)?)?");
private static final Pattern COMMENT_LINK = Pattern.compile("<a href=\"/(\\d+)\">");
private static final String[] ATTACHMENT_FORMATS = new String[] { "gif", "jpg", "jpeg", "png", "bmp", "webm" };
private static final String PREF_KEY_RECAPTCHA_FALLBACK = "PREF_KEY_RECAPTCHA_FALLBACK";
private Map<String, String> boardNames = null;
private Map<String, Integer> boardPagesCount = null;
public HorochanModule(SharedPreferences preferences, Resources resources) {
super(preferences, resources);
}
@Override
public String getChanName() {
return CHAN_NAME;
}
@Override
public String getDisplayingName() {
return "Horochan";
}
@Override
public Drawable getChanFavicon() {
return ResourcesCompat.getDrawable(resources, R.drawable.favicon_horochan, null);
}
private boolean useHttps() {
return useHttps(true);
}
private String getUsingUrl() {
return getUsingUrl(false);
}
private String getUsingUrl(boolean api) {
return (useHttps() ? "https://" : "http://") + (api ? "api." : "") + DOMAIN + "/";
}
private String getStaticUrl() {
return (useHttps() ? "https://" : "http://") + "static." + DOMAIN + "/";
}
@Override
protected String getCloudflareCookieDomain() {
return DOMAIN;
}
@Override
public void addPreferencesOnScreen(PreferenceGroup preferenceGroup) {
Context context = preferenceGroup.getContext();
addOnlyNewPostsPreference(preferenceGroup, true);
CheckBoxPreference fallbackRecaptchaPref = new LazyPreferences.CheckBoxPreference(context); // recaptcha fallback
fallbackRecaptchaPref.setTitle(R.string.fourchan_prefs_new_recaptcha_fallback);
fallbackRecaptchaPref.setSummary(R.string.fourchan_prefs_new_recaptcha_fallback_summary);
fallbackRecaptchaPref.setKey(getSharedKey(PREF_KEY_RECAPTCHA_FALLBACK));
fallbackRecaptchaPref.setDefaultValue(false);
preferenceGroup.addPreference(fallbackRecaptchaPref);
addHttpsPreference(preferenceGroup, true); //https
addCloudflareRecaptchaFallbackPreference(preferenceGroup);
addProxyPreferences(preferenceGroup);
}
private boolean loadOnlyNewPosts() {
return loadOnlyNewPosts(true);
}
private boolean recaptchaFallback() {
return preferences.getBoolean(getSharedKey(PREF_KEY_RECAPTCHA_FALLBACK), false);
}
@Override
public SimpleBoardModel[] getBoardsList(ProgressListener listener, CancellableTask task, SimpleBoardModel[] oldBoardsList) {
return BOARDS;
}
@Override
public BoardModel getBoard(String shortName, ProgressListener listener, CancellableTask task) throws Exception {
if (boardNames == null || boardNames.isEmpty()) {
if (boardNames == null) boardNames = new HashMap<>();
for (SimpleBoardModel board : BOARDS) boardNames.put(board.boardName, board.boardDescription);
}
BoardModel board = new BoardModel();
board.chan = CHAN_NAME;
board.boardName = shortName;
board.boardDescription = boardNames != null ? boardNames.get(shortName) : shortName;
if (board.boardDescription == null) board.boardDescription = shortName;
board.boardCategory = "";
board.nsfw = false;
board.uniqueAttachmentNames = true;
board.timeZoneId = "GMT+3";
board.defaultUserName = "Anonymous";
board.bumpLimit = 250;
board.readonlyBoard = false;
board.requiredFileForNewThread = false;
board.allowDeletePosts = true;
board.allowDeleteFiles = false;
board.allowReport = BoardModel.REPORT_NOT_ALLOWED;
board.allowNames = false;
board.allowSubjects = true;
board.allowSage = false;
board.allowEmails = false;
board.allowCustomMark = false;
board.allowRandomHash = true;
board.allowIcons = false;
board.attachmentsMaxCount = 4;
board.attachmentsFormatFilters = ATTACHMENT_FORMATS;
board.markType = BoardModel.MARK_WAKABAMARK;
board.firstPage = 1;
board.lastPage = (boardPagesCount != null && boardPagesCount.containsKey(shortName)) ?
boardPagesCount.get(shortName) : BoardModel.LAST_PAGE_UNDEFINED;
board.searchAllowed = false;
board.catalogAllowed = false;
return board;
}
private PostModel mapPostModel(JSONObject json) {
PostModel model = new PostModel();
model.number = Long.toString(json.getLong("id"));
model.name = "Anonymous";
model.comment = json.optString("message");
model.comment = RegexUtils.replaceAll(model.comment, COMMENT_LINK, "<a href=\"#$1\">");
model.timestamp = json.optLong("timestamp") * 1000;
model.parentThread = Long.toString(json.optLong("parent", 0));
// Is OP
if (model.parentThread.equals("0")) {
model.subject = json.optString("subject");
model.parentThread = model.number;
}
JSONArray files = json.optJSONArray("files");
if (files != null) {
model.attachments = new AttachmentModel[files.length()];
for (int i=0; i<files.length(); ++i) {
JSONObject file = files.getJSONObject(i);
String name = file.optString("name");
String ext = file.optString("ext");
model.attachments[i] = new AttachmentModel();
model.attachments[i].path = getStaticUrl() + "src/" + name + "." + ext;
model.attachments[i].thumbnail = getStaticUrl() + "thumb/t" + name + ".jpeg";
model.attachments[i].size = file.optInt("size", -1);
if (model.attachments[i].size > 0) model.attachments[i].size = Math.round(model.attachments[i].size / 1024f);
model.attachments[i].width = file.optInt("width", -1);
model.attachments[i].height = file.optInt("height", -1);
if (ext.equalsIgnoreCase("gif")) {
model.attachments[i].type = AttachmentModel.TYPE_IMAGE_GIF;
}
else if (ext.equalsIgnoreCase("webm")) {
model.attachments[i].type = AttachmentModel.TYPE_VIDEO;
}
else {
model.attachments[i].type = AttachmentModel.TYPE_IMAGE_STATIC;
}
}
}
String embed = json.optString("embed");
if (embed != null && embed.length() > 0) {
AttachmentModel[] attachments = new AttachmentModel[1 + (model.attachments == null ? 0 : model.attachments.length)];
for (int i=0; i<attachments.length-1; ++i) attachments[i] = model.attachments[i];
AttachmentModel embedded = new AttachmentModel();
embedded.type = AttachmentModel.TYPE_OTHER_NOTFILE;
embedded.path = "http://youtube.com/watch?v=" + embed;
embedded.thumbnail = "http://img.youtube.com/vi/" + embed + "/default.jpg";
attachments[attachments.length - 1] = embedded;
model.attachments = attachments;
}
return model;
}
@Override
public ThreadModel[] getThreadsList(String boardName, int page, ProgressListener listener, CancellableTask task, ThreadModel[] oldList)
throws Exception {
String url = getUsingUrl(true) + "v1/boards/" + Integer.toString(page);
JSONObject json = downloadJSONObject(url, oldList != null, listener, task);
if (json == null) return oldList;
int totalPages = json.optInt("totalPages", -1);
if (totalPages != -1) {
if (boardPagesCount == null) boardPagesCount = new HashMap<>();
boardPagesCount.put(boardName, totalPages);
}
try {
JSONArray data = json.getJSONArray("data");
ThreadModel[] threads = new ThreadModel[data.length()];
for (int i=0; i<data.length(); ++i) {
JSONObject current = data.getJSONObject(i);
JSONArray replies = current.optJSONArray("replies");
threads[i] = new ThreadModel();
threads[i].posts = new PostModel[1 + (replies != null ? replies.length() : 0)];
threads[i].posts[0] = mapPostModel(current);
threads[i].threadNumber = threads[i].posts[0].number;
threads[i].postsCount = current.optInt("replies_count", -2) + 1;
for (int j=1; j<threads[i].posts.length; ++j) threads[i].posts[j] = mapPostModel(replies.getJSONObject(j - 1));
}
return threads;
} catch (JSONException e) {
if (json.has("message")) throw new Exception(json.getString("message"));
throw e;
}
}
@Override
public PostModel[] getPostsList(String boardName, String threadNumber, ProgressListener listener, CancellableTask task, PostModel[] oldList)
throws Exception {
String url = getUsingUrl(true) + "v1/threads/" + threadNumber;
boolean onlyNewPosts = loadOnlyNewPosts() && oldList != null && oldList.length > 0;
if (onlyNewPosts) url += "/after/" + oldList[oldList.length - 1].number;
JSONObject json = downloadJSONObject(url, oldList != null, listener, task);
if (json == null) return oldList;
try {
if (onlyNewPosts) {
JSONArray data = json.getJSONArray("data");
if (data.length() == 0) return oldList;
PostModel[] result = new PostModel[oldList.length + data.length()];
for (int i=0; i<oldList.length; ++i) result[i] = oldList[i];
for (int i=0; i<data.length(); ++i) result[i + oldList.length] = mapPostModel(data.getJSONObject(i));
return result;
} else {
JSONObject data = json.getJSONObject("data");
JSONArray replies = data.optJSONArray("replies");
PostModel[] posts = new PostModel[1 + (replies != null ? replies.length() : 0)];
posts[0] = mapPostModel(data);
for (int i=1; i<posts.length; ++i) posts[i] = mapPostModel(replies.getJSONObject(i - 1));
if (oldList == null) return posts;
return ChanModels.mergePostsLists(Arrays.asList(oldList), Arrays.asList(posts));
}
} catch (JSONException e) {
if (json.has("message")) throw new Exception(json.getString("message"));
throw e;
}
}
@Override
public CaptchaModel getNewCaptcha(String boardName, String threadNumber, ProgressListener listener, CancellableTask task) throws Exception {
return null;
}
@Override
public String sendPost(SendPostModel model, ProgressListener listener, CancellableTask task) throws Exception {
boolean isThread = model.threadNumber == null;
String url = getUsingUrl(true) + (isThread ? "v1/threads" : "v1/posts");
ExtendedMultipartBuilder postEntityBuilder = ExtendedMultipartBuilder.create().setDelegates(listener, task);
if (isThread) {
postEntityBuilder.addString("subject", model.subject);
} else {
postEntityBuilder.addString("parent", model.threadNumber);
}
postEntityBuilder.
addString("message", model.comment).
addString("password", model.password);
if (model.attachments != null && model.attachments.length > 0) {
for (File attachment : model.attachments) {
postEntityBuilder.addFile("files[]", attachment, model.randomHash);
}
}
String recaptchaResponse = Recaptcha2solved.pop(RECAPTCHA_PUBLIC_KEY);
if (recaptchaResponse == null) throw Recaptcha2.obtain(getUsingUrl(), RECAPTCHA_PUBLIC_KEY, null, CHAN_NAME, recaptchaFallback());
postEntityBuilder.addString("g-recaptcha-response", recaptchaResponse);
HttpRequestModel request = HttpRequestModel.builder().setPOST(postEntityBuilder.build()).build();
HttpResponseModel response = null;
BufferedReader in = null;
try {
response = HttpStreamer.getInstance().getFromUrl(url, request, httpClient, null, task);
in = new BufferedReader(new InputStreamReader(response.stream));
JSONObject json = new JSONObject(new JSONTokener(in));
if (response.statusCode == 200) return null;
if (response.statusCode == 400) {
String error;
try {
JSONArray errors = json.getJSONArray("message");
error = errors.getJSONObject(0).getJSONArray("errors").getString(0);
} catch (Exception e) {
error = json.getString("message");
}
throw new Exception(error);
}
throw new Exception(response.statusCode + " - " + response.statusReason);
} finally {
IOUtils.closeQuietly(in);
if (response != null) response.release();
}
}
@Override
public String deletePost(DeletePostModel model, ProgressListener listener, CancellableTask task) throws Exception {
String url = getUsingUrl(true) + "v1/posts/" + model.postNumber;
HttpEntity entity = ExtendedMultipartBuilder.create().setDelegates(listener, task).addString("password", model.password).build();
HttpUriRequest request = null;
HttpResponse response = null;
HttpEntity responseEntity = null;
try {
request = RequestBuilder.delete().setUri(url).setEntity(entity).build();
response = httpClient.execute(request);
StatusLine status = response.getStatusLine();
switch (status.getStatusCode()) {
case 200:
return null;
case 400:
responseEntity = response.getEntity();
InputStream stream = IOUtils.modifyInputStream(responseEntity.getContent(), null, task);
JSONObject json = new JSONObject(new JSONTokener(new BufferedReader(new InputStreamReader(stream))));
throw new Exception(json.getString("message"));
default:
throw new Exception(status.getStatusCode() + " - " + status.getReasonPhrase());
}
} finally {
try { if (request != null) request.abort(); } catch (Exception e) {}
EntityUtils.consumeQuietly(responseEntity);
if (response != null && response instanceof Closeable) IOUtils.closeQuietly((Closeable) response);
}
}
@Override
public String buildUrl(UrlPageModel model) throws IllegalArgumentException {
if (!model.chanName.equals(CHAN_NAME)) throw new IllegalArgumentException("wrong chan");
if (model.boardName != null && !model.boardName.matches("\\w+")) throw new IllegalArgumentException("wrong board name");
StringBuilder url = new StringBuilder(getUsingUrl());
try {
switch (model.type) {
case UrlPageModel.TYPE_INDEXPAGE:
return url.toString();
case UrlPageModel.TYPE_BOARDPAGE:
if (model.boardPage == UrlPageModel.DEFAULT_FIRST_PAGE || model.boardPage == 1)
return url.append(model.boardName).append('/').toString();
return url.append(model.boardName).append('/').append(model.boardPage).toString();
case UrlPageModel.TYPE_THREADPAGE:
return url.append(model.boardName).append("/thread/").append(model.threadNumber).
append(model.postNumber == null || model.postNumber.length() == 0 ? "" : ("/#" + model.postNumber)).toString();
case UrlPageModel.TYPE_OTHERPAGE:
return url.append(model.otherPath.startsWith("/") ? model.otherPath.substring(1) : model.otherPath).toString();
}
} catch (Exception e) {}
throw new IllegalArgumentException("wrong page type");
}
@Override
public UrlPageModel parseUrl(String url) throws IllegalArgumentException {
String path = UrlPathUtils.getUrlPath(url, DOMAIN);
if (path == null) throw new IllegalArgumentException("wrong domain");
path = path.toLowerCase(Locale.US);
UrlPageModel model = new UrlPageModel();
model.chanName = CHAN_NAME;
if (path.length() == 0 || path.equals("index.html")) {
model.type = UrlPageModel.TYPE_INDEXPAGE;
return model;
}
Matcher threadPage = URL_PATH_THREADPAGE_PATTERN.matcher(path);
if (threadPage.find()) {
model.type = UrlPageModel.TYPE_THREADPAGE;
model.boardName = threadPage.group(1);
model.threadNumber = threadPage.group(2);
model.postNumber = threadPage.group(3);
return model;
}
Matcher boardPage = URL_PATH_BOARDPAGE_PATTERN.matcher(path);
if (boardPage.find()) {
model.type = UrlPageModel.TYPE_BOARDPAGE;
model.boardName = boardPage.group(1);
String page = boardPage.group(2);
model.boardPage = page == null ? 1 : Integer.parseInt(page);
return model;
}
throw new IllegalArgumentException("fail to parse");
}
}