/*
* 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.dvach;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import cz.msebera.android.httpclient.NameValuePair;
import cz.msebera.android.httpclient.client.entity.UrlEncodedFormEntity;
import cz.msebera.android.httpclient.message.BasicNameValuePair;
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.EditTextPreference;
import android.preference.PreferenceGroup;
import android.support.v4.content.res.ResourcesCompat;
import android.text.InputType;
import android.text.TextUtils;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import nya.miku.wishmaster.R;
import nya.miku.wishmaster.api.AbstractWakabaModule;
import nya.miku.wishmaster.api.interfaces.CancellableTask;
import nya.miku.wishmaster.api.interfaces.ProgressListener;
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.UrlPageModel;
import nya.miku.wishmaster.api.util.WakabaReader;
import nya.miku.wishmaster.api.util.LazyPreferences;
import nya.miku.wishmaster.api.util.UrlPathUtils;
import nya.miku.wishmaster.api.util.WakabaUtils;
import nya.miku.wishmaster.common.Async;
import nya.miku.wishmaster.common.IOUtils;
import nya.miku.wishmaster.common.Logger;
import nya.miku.wishmaster.common.MainApplication;
import nya.miku.wishmaster.http.ExtendedMultipartBuilder;
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.http.streamer.HttpWrongStatusCodeException;
public class DvachModule extends AbstractWakabaModule {
private static final String TAG = "DvachModule";
static final String CHAN_NAME = "2-chru.net";
private static final String DEFAULT_DOMAIN = "2-chru.net";
private static final String ONION_DOMAIN = "dmirrgetyojz735v.onion";
private static final String DOMAINS_HINT = "2-chru.net, mirror.2-chru.net, bypass.2-chru.net";
private static final String[] DOMAINS = new String[] { DEFAULT_DOMAIN, ONION_DOMAIN, "mirror.2-chru.net", "bypass.2-chru.net", "2chru.net",
"2chru.cafe", "2-chru.cafe" };
private static final String[] FORMATS = new String[] { "jpg", "jpeg", "png", "gif", "webm", "mp4", "ogv", "mp3", "ogg" };
private static final String PREF_KEY_USE_ONION = "PREF_KEY_USE_ONION";
private static final String PREF_KEY_DOMAIN = "PREF_KEY_DOMAIN";
private static final Pattern ERROR_PATTERN = Pattern.compile("<h2>(.*?)</h2>", Pattern.DOTALL);
private static final Pattern REDIRECT_PATTERN = Pattern.compile("url=res/(\\d+)\\.html");
public DvachModule(SharedPreferences preferences, Resources resources) {
super(preferences, resources);
}
@Override
public String getChanName() {
return CHAN_NAME;
}
@Override
public String getDisplayingName() {
return "Два.ч (2-chru.net)";
}
@Override
public Drawable getChanFavicon() {
return ResourcesCompat.getDrawable(resources, R.drawable.favicon_dvach, null);
}
@Override
protected String getUsingDomain() {
if (preferences.getBoolean(getSharedKey(PREF_KEY_USE_ONION), false)) return ONION_DOMAIN;
String domain = preferences.getString(getSharedKey(PREF_KEY_DOMAIN), DEFAULT_DOMAIN);
return TextUtils.isEmpty(domain) ? DEFAULT_DOMAIN : domain;
}
@Override
protected String[] getAllDomains() {
String domain = getUsingDomain();
for (String d : DOMAINS) if (domain.equals(d)) return DOMAINS;
String[] domains = new String[DOMAINS.length + 1];
for (int i=0; i<DOMAINS.length; ++i) domains[i] = DOMAINS[i];
domains[DOMAINS.length] = domain;
return domains;
}
@Override
protected boolean useHttps() {
return !preferences.getBoolean(getSharedKey(PREF_KEY_USE_ONION), false);
}
@Override
protected boolean canCloudflare() {
return true;
}
@Override
public void addPreferencesOnScreen(PreferenceGroup preferenceGroup) {
Context context = preferenceGroup.getContext();
addPasswordPreference(preferenceGroup);
CheckBoxPreference onionPref = new LazyPreferences.CheckBoxPreference(context);
onionPref.setTitle(R.string.pref_use_onion);
onionPref.setSummary(R.string.pref_use_onion_summary);
onionPref.setKey(getSharedKey(PREF_KEY_USE_ONION));
onionPref.setDefaultValue(false);
onionPref.setDisableDependentsState(true);
preferenceGroup.addPreference(onionPref);
EditTextPreference domainPref = new EditTextPreference(context);
domainPref.setTitle(R.string.pref_domain);
domainPref.setDialogTitle(R.string.pref_domain);
domainPref.setSummary(resources.getString(R.string.pref_domain_summary, DOMAINS_HINT));
domainPref.setKey(getSharedKey(PREF_KEY_DOMAIN));
domainPref.getEditText().setHint(DEFAULT_DOMAIN);
domainPref.getEditText().setSingleLine();
domainPref.getEditText().setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
preferenceGroup.addPreference(domainPref);
domainPref.setDependency(getSharedKey(PREF_KEY_USE_ONION));
addProxyPreferences(preferenceGroup);
}
@Override
public SimpleBoardModel[] getBoardsList(ProgressListener listener, CancellableTask task, SimpleBoardModel[] oldBoardsList) throws Exception {
String url = getUsingUrl() + "menu.html";
HttpResponseModel responseModel = null;
DvachBoardsListReader in = null;
HttpRequestModel rqModel = HttpRequestModel.builder().setGET().setCheckIfModified(oldBoardsList != null).build();
try {
responseModel = HttpStreamer.getInstance().getFromUrl(url, rqModel, httpClient, listener, task);
if (responseModel.statusCode == 200) {
in = new DvachBoardsListReader(responseModel.stream);
if (task != null && task.isCancelled()) throw new Exception("interrupted");
return in.readBoardsList();
} else {
if (responseModel.notModified()) return null;
throw new HttpWrongStatusCodeException(responseModel.statusCode, responseModel.statusCode + " - " + responseModel.statusReason);
}
} catch (Exception e) {
if (responseModel != null) HttpStreamer.getInstance().removeFromModifiedMap(url);
throw e;
} finally {
IOUtils.closeQuietly(in);
if (responseModel != null) responseModel.release();
}
}
@Override
protected Map<String, SimpleBoardModel> getBoardsMap(ProgressListener listener, CancellableTask task) throws Exception {
try {
return super.getBoardsMap(listener, task);
} catch (Exception e) {
Logger.e(TAG, e);
return Collections.emptyMap();
}
}
@Override
public BoardModel getBoard(String shortName, ProgressListener listener, CancellableTask task) throws Exception {
BoardModel board = super.getBoard(shortName, listener, task);
board.defaultUserName = "Аноним";
board.timeZoneId = "GMT+3";
board.searchAllowed = true;
board.readonlyBoard = false;
board.requiredFileForNewThread = !shortName.equals("d");
board.allowDeletePosts = true;
board.allowDeleteFiles = false;
board.allowNames = !shortName.equals("b");
board.allowSubjects = true;
board.allowSage = false;
board.allowEmails = true;
board.allowCustomMark = false;
board.allowRandomHash = true;
board.allowIcons = false;
board.attachmentsMaxCount = shortName.equals("d") ? 0 : 1;
board.attachmentsFormatFilters = FORMATS;
board.markType = BoardModel.MARK_WAKABAMARK;
return board;
}
@Override
protected WakabaReader getWakabaReader(InputStream stream, UrlPageModel urlModel) {
return new DvachReader(stream);
}
@Override
public PostModel[] search(String boardName, String searchRequest, ProgressListener listener, CancellableTask task) throws Exception {
String url = getUsingUrl() + boardName + "/search?q=" + URLEncoder.encode(searchRequest, "UTF-8");
HttpResponseModel responseModel = null;
DvachSearchReader in = null;
HttpRequestModel rqModel = HttpRequestModel.DEFAULT_GET;
try {
responseModel = HttpStreamer.getInstance().getFromUrl(url, rqModel, httpClient, listener, task);
if (responseModel.statusCode == 200) {
in = new DvachSearchReader(responseModel.stream, this);
if (task != null && task.isCancelled()) throw new Exception("interrupted");
return in.readSerachPage();
} else {
throw new HttpWrongStatusCodeException(responseModel.statusCode, responseModel.statusCode + " - " + responseModel.statusReason);
}
} finally {
IOUtils.closeQuietly(in);
if (responseModel != null) responseModel.release();
}
}
@Override
public CaptchaModel getNewCaptcha(String boardName, String threadNumber, ProgressListener listener, CancellableTask task) throws Exception {
try {
String checkUrl = getUsingUrl() + boardName + "/api/requires-captcha";
if (HttpStreamer.getInstance().
getJSONObjectFromUrl(checkUrl, HttpRequestModel.DEFAULT_GET, httpClient, listener, task, false).
getString("requires-captcha").equals("0")) return null;
} catch (Exception e) {
Logger.e(TAG, "captcha", e);
}
String captchaUrl = getUsingUrl() + boardName + "/captcha?" + String.valueOf(Math.floor(Math.random() * 10000000));
return downloadCaptcha(captchaUrl, listener, task);
}
@Override
public String sendPost(SendPostModel model, ProgressListener listener, CancellableTask task) throws Exception {
String url = getUsingUrl() + model.boardName + "/post";
ExtendedMultipartBuilder postEntityBuilder = ExtendedMultipartBuilder.create().setDelegates(listener, task).
addString("parent", model.threadNumber != null ? model.threadNumber : "0").
addString("name", model.name).
addString("email", model.email).
addString("subject", model.subject).
addString("message", model.comment).
addString("captcha", TextUtils.isEmpty(model.captchaAnswer) ? "" : model.captchaAnswer).
addString("password", model.password);
if (model.threadNumber != null) postEntityBuilder.addString("noko", "on");
if (model.attachments != null && model.attachments.length > 0)
postEntityBuilder.addFile("file", model.attachments[0], model.randomHash);
try {
cssTest(model.boardName, task);
} catch (Exception e) {
Logger.e(TAG, "csstest failed", e);
}
if (task != null && task.isCancelled()) throw new InterruptedException("interrupted");
HttpRequestModel request = HttpRequestModel.builder().setPOST(postEntityBuilder.build()).setNoRedirect(true).build();
HttpResponseModel response = null;
try {
response = HttpStreamer.getInstance().getFromUrl(url, request, httpClient, null, task);
if (response.statusCode == 200) {
ByteArrayOutputStream output = new ByteArrayOutputStream(1024);
IOUtils.copyStream(response.stream, output);
String htmlResponse = output.toString("UTF-8");
if (htmlResponse.contains("ОБНОВЛ")) {
if (model.threadNumber == null) {
Matcher redirectMatcher = REDIRECT_PATTERN.matcher(htmlResponse);
if (redirectMatcher.find()) {
UrlPageModel redirModel = new UrlPageModel();
redirModel.chanName = CHAN_NAME;
redirModel.type = UrlPageModel.TYPE_THREADPAGE;
redirModel.boardName = model.boardName;
redirModel.threadNumber = redirectMatcher.group(1);
return buildUrl(redirModel);
}
}
return null;
}
Matcher errorMatcher = ERROR_PATTERN.matcher(htmlResponse);
if (errorMatcher.find()) {
throw new Exception(errorMatcher.group(1));
}
} else throw new Exception(response.statusCode + " - " + response.statusReason);
} finally {
if (response != null) response.release();
}
return null;
}
@Override
public String deletePost(DeletePostModel model, ProgressListener listener, CancellableTask task) throws Exception {
String url = getUsingUrl() + model.boardName + "/delete";
List<NameValuePair> pairs = new ArrayList<NameValuePair>();
pairs.add(new BasicNameValuePair("posts[]", model.postNumber));
pairs.add(new BasicNameValuePair("password", model.password));
pairs.add(new BasicNameValuePair("deletepost", "Удалить"));
HttpRequestModel request = HttpRequestModel.builder().setPOST(new UrlEncodedFormEntity(pairs, "UTF-8")).setNoRedirect(true).build();
String result = HttpStreamer.getInstance().getStringFromUrl(url, request, httpClient, listener, task, false);
if (result.contains("Неверный пароль")) throw new Exception("Неверный пароль");
return null;
}
private void cssTest(String boardName, final CancellableTask task) throws Exception { /* =*.*= */
class CSSCodeHolder {
private volatile String cssCode = null;
public synchronized void setCode(String code) {
Logger.d(TAG, "set CSS code: " + code);
if (cssCode == null) cssCode = code;
}
public boolean isSet() {
return cssCode != null;
}
public String getCode() {
return cssCode;
}
}
class WebViewHolder {
private WebView webView = null;
}
final CSSCodeHolder holder = new CSSCodeHolder();
final WebViewHolder wv = new WebViewHolder();
final String cssTest = HttpStreamer.getInstance().getStringFromUrl(getUsingUrl() + boardName + "/csstest.foo", HttpRequestModel.DEFAULT_GET,
httpClient, null, task, false);
long startTime = System.currentTimeMillis();
Async.runOnUiThread(new Runnable() {
@Override
public void run() {
wv.webView = new WebView(MainApplication.getInstance());
wv.webView.setWebViewClient(new WebViewClient(){
@Override
public void onLoadResource(WebView view, String url) {
if (url.contains("?code=") && !task.isCancelled()) {
holder.setCode(url.substring(url.indexOf("?code=") + 6));
}
}
});
wv.webView.loadDataWithBaseURL("http://127.0.0.1/csstest.foo", cssTest, "text/html", "UTF-8", "");
}
});
while (!holder.isSet()) {
long time = System.currentTimeMillis() - startTime;
if ((task != null && task.isCancelled()) || time > 5000) break;
Thread.yield();
}
Async.runOnUiThread(new Runnable() {
@Override
public void run() {
try {
wv.webView.stopLoading();
wv.webView.clearCache(true);
wv.webView.destroy();
} catch (Exception e) {
Logger.e(TAG, e);
}
}
});
if (task != null && task.isCancelled()) throw new InterruptedException("interrupted");
String cssCode = holder.getCode();
if (cssCode != null) {
HttpStreamer.getInstance().getBytesFromUrl(getUsingUrl() + boardName + "/csstest.foo?code=" + cssCode, HttpRequestModel.DEFAULT_GET,
httpClient, null, task, false);
}
}
@Override
public String buildUrl(UrlPageModel model) throws IllegalArgumentException {
if (!model.chanName.equals(getChanName())) throw new IllegalArgumentException("wrong chan");
try {
if (model.type == UrlPageModel.TYPE_SEARCHPAGE)
return getUsingUrl() + model.boardName + "/search?q=" + URLEncoder.encode(model.searchRequest, "UTF-8");
} catch (Exception e) {}
return WakabaUtils.buildUrl(model, getUsingUrl());
}
@Override
public UrlPageModel parseUrl(String url) throws IllegalArgumentException {
String urlPath = UrlPathUtils.getUrlPath(url, getAllDomains());
if (urlPath == null) throw new IllegalArgumentException("wrong domain");
if (url.contains("/search?q=")) {
try {
int index = url.indexOf("/search?q=");
String left = url.substring(0, index);
UrlPageModel model = new UrlPageModel();
model.chanName = CHAN_NAME;
model.type = UrlPageModel.TYPE_SEARCHPAGE;
model.boardName = left.substring(left.lastIndexOf('/') + 1);
model.searchRequest = url.substring(index + 10);
model.searchRequest = URLDecoder.decode(model.searchRequest, "UTF-8");
return model;
} catch (Exception e) {}
}
return WakabaUtils.parseUrlPath(urlPath, getChanName());
}
}