Compare commits

...

3 Commits

Author SHA1 Message Date
zzz
95bcf2977a Handle 429 responses
Some checks failed
Java CI / build (push) Has been cancelled
Java CI / javadoc-latest (push) Has been cancelled
Java CI / build-java7 (push) Has been cancelled
Java with IzPack Snapshot Setup / setup (push) Has been cancelled
Sync Primary Repository to GitHub Mirror / sync (push) Has been cancelled
untested
2025-06-04 16:06:31 -04:00
zzz
4f5a236fdc cleanup a line from a different PR
Some checks failed
Java CI / build (push) Has been cancelled
Java CI / javadoc-latest (push) Has been cancelled
Java CI / build-java7 (push) Has been cancelled
Java with IzPack Snapshot Setup / setup (push) Has been cancelled
Sync Primary Repository to GitHub Mirror / sync (push) Has been cancelled
2025-06-03 12:39:21 -04:00
zzz
688638ad69 i2psnark: Remote API search
Some checks failed
Java CI / build (push) Has been cancelled
Java CI / javadoc-latest (push) Has been cancelled
Java CI / build-java7 (push) Has been cancelled
Java with IzPack Snapshot Setup / setup (push) Has been cancelled
Sync Primary Repository to GitHub Mirror / sync (push) Has been cancelled
2025-06-03 11:58:13 -04:00
7 changed files with 1074 additions and 20 deletions

View File

@ -89,6 +89,7 @@ public class I2PSnarkUtil implements DisconnectListener {
private long _startedTime;
private final DisconnectListener _discon;
private int _maxFilesPerTorrent = SnarkManager.DEFAULT_MAX_FILES_PER_TORRENT;
private String _apiTarget, _apiKey;
private static final int EEPGET_CONNECT_TIMEOUT = 45*1000;
private static final int EEPGET_CONNECT_TIMEOUT_SHORT = 5*1000;
@ -259,6 +260,21 @@ public class I2PSnarkUtil implements DisconnectListener {
/** @since 0.9.58 */
public void setMaxFilesPerTorrent(int max) { _maxFilesPerTorrent = Math.max(max, 1); }
/** @since 0.9.67 */
public String getAPITarget() { return _apiTarget; };
/** @since 0.9.67 */
public String getAPIKey() { return _apiKey; };
/** @since 0.9.67 */
public void setAPI(String target, String key) {
_apiTarget = target;
_apiKey = key;
}
/** @since 0.9.67 */
public boolean hasAPIKey() {
return _apiTarget != null && _apiTarget.length() > 0 &&
_apiKey != null && _apiKey.length() > 0;
}
/**
* Connect to the router, if we aren't already
*/

View File

@ -170,6 +170,8 @@ public class SnarkManager implements CompleteListener, ClientApp, DisconnectList
private static final String PROP_COMMENTS_NAME = "i2psnark.commentsName";
/** @since 0.9.58 */
public static final String PROP_MAX_FILES_PER_TORRENT = "i2psnark.maxFilesPerTorrent";
/** @since 0.9.67 */
private static final String PROP_API_PREFIX = "i2psnark.apikey.";
public static final int MIN_UP_BW = 5;
public static final int MIN_DOWN_BW = 2 * MIN_UP_BW;
@ -1055,6 +1057,15 @@ public class SnarkManager implements CompleteListener, ClientApp, DisconnectList
_util.setCommentsName(_config.getProperty(PROP_COMMENTS_NAME, ""));
_util.setCollapsePanels(Boolean.parseBoolean(_config.getProperty(PROP_COLLAPSE_PANELS,
Boolean.toString(I2PSnarkUtil.DEFAULT_COLLAPSE_PANELS))));
for (String c : _config.stringPropertyNames()) {
if (c.startsWith(PROP_API_PREFIX)) {
String tgt = c.substring(PROP_API_PREFIX.length());
String key = _config.getProperty(c);
// we only support one for now
_util.setAPI(tgt, key);
break;
}
}
File dd = getDataDir();
if (dd.isDirectory()) {
if (!dd.canWrite())
@ -1084,13 +1095,15 @@ public class SnarkManager implements CompleteListener, ClientApp, DisconnectList
String startDelay, String pageSize, String seedPct, String eepHost,
String eepPort, String i2cpHost, String i2cpPort, String i2cpOpts,
String upLimit, String upBW, String downBW, boolean useOpenTrackers, boolean useDHT, String theme,
String lang, boolean enableRatings, boolean enableComments, String commentName, boolean collapsePanels) {
String lang, boolean enableRatings, boolean enableComments, String commentName, boolean collapsePanels,
String apiTarget, String apiKey) {
synchronized(_configLock) {
locked_updateConfig(dataDir, filesPublic, autoStart, smartSort, refreshDelay,
startDelay, pageSize, seedPct, eepHost,
eepPort, i2cpHost, i2cpPort, i2cpOpts,
upLimit, upBW, downBW, useOpenTrackers, useDHT, theme,
lang, enableRatings, enableComments, commentName, collapsePanels);
lang, enableRatings, enableComments, commentName, collapsePanels,
apiTarget, apiKey);
}
}
@ -1098,7 +1111,8 @@ public class SnarkManager implements CompleteListener, ClientApp, DisconnectList
String startDelay, String pageSize, String seedPct, String eepHost,
String eepPort, String i2cpHost, String i2cpPort, String i2cpOpts,
String upLimit, String upBW, String downBW, boolean useOpenTrackers, boolean useDHT, String theme,
String lang, boolean enableRatings, boolean enableComments, String commentName, boolean collapsePanels) {
String lang, boolean enableRatings, boolean enableComments, String commentName, boolean collapsePanels,
String apiTarget, String apiKey) {
boolean changed = false;
boolean interruptMonitor = false;
//if (eepHost != null) {
@ -1449,6 +1463,21 @@ public class SnarkManager implements CompleteListener, ClientApp, DisconnectList
_util.setCollapsePanels(collapsePanels);
changed = true;
}
if (apiKey != null && apiKey.length() > 0 &&
apiTarget != null && apiTarget.length() > 0) {
apiKey = DataHelper.stripHTML(apiKey.trim());
apiTarget = DataHelper.stripHTML(apiTarget.trim());
String oldk = _util.getAPIKey();
String oldt = _util.getAPITarget();
if (!apiKey.equals(oldk) || !apiTarget.equals(oldt)) {
_config.setProperty(PROP_API_PREFIX + apiTarget, apiKey);
_util.setAPI(apiTarget, apiKey);
addMessage(_t("API key updated."));
changed = true;
}
}
if (changed) {
saveConfig();
if (interruptMonitor)

View File

@ -85,8 +85,8 @@ public class I2PSnarkServlet extends BasicServlet {
private static final String DEFAULT_NAME = "i2psnark";
public static final String PROP_CONFIG_FILE = "i2psnark.configFile";
private static final String WARBASE = "/.resources/";
private static final char HELLIP = '\u2026';
public static final String WARBASE = "/.resources/";
static final char HELLIP = '\u2026';
public I2PSnarkServlet() {
super();
@ -227,13 +227,38 @@ public class I2PSnarkServlet extends BasicServlet {
PrintWriter out = resp.getWriter();
//if (_log.shouldLog(Log.DEBUG))
// _manager.addMessage((_context.clock().now() / 1000) + " xhr1 p=" + req.getParameter("p"));
writeMessages(out, false, peerString);
String search = req.getParameter("nf_s");
boolean isSearch = search != null && search.length() > 0;
// hide message box if we're searching
if (!isSearch)
writeMessages(out, false, peerString);
boolean canWrite;
synchronized(this) {
canWrite = _resourceBase.canWrite();
}
writeTorrents(out, req, canWrite);
return;
} else if ("/.ajax/xhr2.html".equals(path)) {
// AJAX for remote lookup
setHTMLHeaders(resp, cspNonce, false);
PrintWriter out = resp.getWriter();
String search = req.getParameter("nf_s");
boolean isValidRemote = search != null && search.length() >= RemoteSearch.MIN_LEN;
if (!isValidRemote) {
resp.sendError(404);
return;
}
RemoteSearch rs = new RemoteSearch(_context, _manager);
search = decodePath(search);
String results = rs.search(search, true);
if (results != null) {
if (!results.equals(RemoteSearch.OLDER))
out.write(results);
} else {
out.write("<tr class=\"snarkTorrentNoneLoaded\"><td colspan=\"8\">");
out.write(_t("No torrents found."));
}
return;
}
boolean isConfigure = "/configure".equals(path);
@ -398,8 +423,20 @@ public class I2PSnarkServlet extends BasicServlet {
if (!isConfigure) {
String search = req.getParameter("nf_s");
if (_manager.getTorrents().size() > 1 || (search != null && search.length() > 0)) {
out.write("<form class=\"search\" id = \"search\" action=\"" + _contextPath + "\" method=\"GET\">" +
"<input type=\"text\" name=\"nf_s\" size=\"20\" class=\"search\" id=\"searchbox\"");
out.write("<form class=\"search\" id = \"search\" action=\"" + _contextPath + "\" method=\"GET\">");
if (_manager.util().hasAPIKey() && _manager.util().connected()) {
String stype = req.getParameter("searchopt");
out.write("\n<input type=\"radio\" name=\"searchopt\" class=\"search\" value=\"1\"" +
((stype == null || stype.equals("") || stype.equals("1")) ? " checked" : "") + '>' +
_t("Local") +
"\n<input type=\"radio\" name=\"searchopt\" class=\"search\" value=\"2\"" +
("2".equals(stype) ? " checked" : "") + '>' +
_t("Postman") +
"\n<input type=\"radio\" name=\"searchopt\" class=\"search\" value=\"3\"" +
("3".equals(stype) ? " checked" : "") + '>' +
_t("Both"));
}
out.write("\n<input type=\"text\" name=\"nf_s\" size=\"20\" class=\"search\" id=\"searchbox\"");
if (search != null)
out.write(" value=\"" + DataHelper.escapeHTML(search) + '"');
out.write(">" +
@ -411,9 +448,71 @@ public class I2PSnarkServlet extends BasicServlet {
String newURL = req.getParameter("newURL");
if (newURL != null && newURL.trim().length() > 0 && req.getMethod().equals("GET"))
_manager.addMessage(_t("Click \"Add torrent\" button to fetch torrent"));
// remote search results
String search = req.getParameter("nf_s");
boolean isSearch = search != null && search.length() > 0;
String stype = req.getParameter("searchopt");
boolean isValidRemote = isSearch && search.length() >= RemoteSearch.MIN_LEN &&
stype != null && (stype.equals("2") || stype.equals("3"));
String nojs = req.getParameter("nojs");
// if we have js, we need to include this all the time, so the js can
// unhide it when a search starts
boolean renderRemote = !isConfigure &&
_manager.util().hasAPIKey() && _manager.util().connected() &&
(nojs == null || isValidRemote);
if (renderRemote) {
out.write("<div id=\"remoteresults\"><br><br>\n");
// out.write("<form ... for add buttons
out.write("<table border=\"0\" id=\"remoteresulttable\" class=\"snarkTorrents");
if (!isValidRemote)
out.write(" disabled");
out.write("\" width=\"100%\"><thead>\n");
out.write("<tr><th class=\"snarkGraphicStatus\">");
String sl = _t("Seed / Leech");
out.write(toThemeImg("status", sl, sl));
out.write("<th class=\"snarkTorrentStatus\">");
out.write("S&thinsp;/&thinsp;L");
//out.write("<th class=\"snarkTorrentDetails\">");
out.write("<th colspan=\"2\">");
out.write(toThemeImg("torrent"));
//out.write("<th class=\"snarkTorrentName\">");
String tgt = _manager.util().getAPITarget();
out.write("Search results from <a href=\"http://" + tgt + "\">" + tgt + "</a>");
out.write("<th class=\"snarkTorrentDownloaded\">");
out.write(toThemeImg("head_rx"));
//out.write(_t("Size"));
out.write("<th class=\"snarkTorrentUploaded\">");
out.write(toThemeImg("head_tx"));
//out.write(_t("Added"));
out.write("<th class=\"snarkTorrentUploaded\">");
out.write(toThemeImg("head_tx"));
//out.write(_t("Uploader"));
out.write("<th class=\"snarkTorrentAction\">");
out.write(toThemeImg("add"));
//out.write(_t("Add"));
// this is what XHR2 replaces
out.write("</thead><tbody id=\"remoteresulttablebody\">\n");
if (isValidRemote) {
RemoteSearch rs = new RemoteSearch(_context, _manager);
search = decodePath(search);
String results = rs.search(search, false);
if (results != null) {
out.write(results);
} else {
out.write("<tr class=\"snarkTorrentNoneLoaded\"><td colspan=\"8\">");
out.write(_t("No torrents found."));
}
}
// out.write("</form ...
out.write("</tbody></table></div>\n");
}
out.write("<div id=\"page\" class=\"page\"><div id=\"mainsection\" class=\"mainsection\">");
writeMessages(out, isConfigure, peerString);
// hide message box if we're searching
if (!isSearch)
writeMessages(out, isConfigure, peerString);
if (isConfigure) {
// end of mainsection div
@ -425,7 +524,14 @@ public class I2PSnarkServlet extends BasicServlet {
synchronized(this) {
canWrite = _resourceBase.canWrite();
}
boolean pageOne = writeTorrents(out, req, canWrite);
boolean pageOne;
if (!isSearch || !"2".equals(stype)) {
// hide for remote-only search
pageOne = writeTorrents(out, req, canWrite);
} else {
// don't show forms for remote-only search
pageOne = false;
}
// end of mainsection div
if (pageOne) {
out.write("</div><div id=\"lowersection\">\n");
@ -464,7 +570,7 @@ public class I2PSnarkServlet extends BasicServlet {
private void writeMessages(PrintWriter out, boolean isConfigure, String peerString) throws IOException {
List<UIMessages.Message> msgs = _manager.getMessages();
if (!msgs.isEmpty()) {
out.write("\n<div class=\"snarkMessages\" tabindex=\"0\">" +
out.write("\n<div class=\"snarkMessages\" id=\"snarkMessages\" tabindex=\"0\">" +
"<a id=\"closeLog\" href=\"" + _contextPath + '/');
if (isConfigure)
out.write("configure");
@ -500,6 +606,7 @@ public class I2PSnarkServlet extends BasicServlet {
String stParam = req.getParameter("st");
List<Snark> snarks = getSortedSnarks(req);
boolean isForm = _manager.util().connected() || !snarks.isEmpty();
if (isForm) {
out.write("<form action=\"_post\" method=\"POST\">\n");
@ -517,10 +624,14 @@ public class I2PSnarkServlet extends BasicServlet {
boolean isSearch = false;
String search = req.getParameter("nf_s");
if (search != null && search.length() > 0) {
List<Snark> matches = search(search, snarks);
if (matches != null) {
snarks = matches;
isSearch = true;
String stype = req.getParameter("searchopt");
boolean local = stype == null || stype.equals("1") || stype.equals("3");
if (local) {
List<Snark> matches = search(search, snarks);
if (matches != null) {
snarks = matches;
isSearch = true;
}
}
}
@ -952,6 +1063,7 @@ public class I2PSnarkServlet extends BasicServlet {
.append(DataHelper.escapeHTML(sParam)).append("\" >\n");
}
}
buf.append("<noscript><input type=\"hidden\" name=\"nojs\" value=\"1\"></noscript>\n");
}
/**
@ -1478,10 +1590,13 @@ public class I2PSnarkServlet extends BasicServlet {
// commentsName is filtered in SnarkManager.updateConfig()
String commentsName = req.getParameter("nofilter_commentsName");
boolean collapsePanels = req.getParameter("collapsePanels") != null;
String apiTarget = req.getParameter("apiTarget");
String apiKey = req.getParameter("apiKey");
_manager.updateConfig(dataDir, filesPublic, autoStart, smartSort, refreshDel, startupDel, pageSize,
seedPct, eepHost, eepPort, i2cpHost, i2cpPort, i2cpOpts,
upLimit, upBW, downBW, useOpenTrackers, useDHT, theme,
lang, ratings, comments, commentsName, collapsePanels);
lang, ratings, comments, commentsName, collapsePanels,
apiTarget, apiKey);
// update servlet
try {
setResourceBase(_manager.getDataDir());
@ -1801,7 +1916,7 @@ public class I2PSnarkServlet extends BasicServlet {
return rv;
}
private static final int MAX_DISPLAYED_FILENAME_LENGTH = 50;
static final int MAX_DISPLAYED_FILENAME_LENGTH = 50;
private static final int MAX_DISPLAYED_ERROR_LENGTH = 43;
/**
@ -2957,8 +3072,39 @@ public class I2PSnarkServlet extends BasicServlet {
out.write("<tr><td>");
out.write(_t("I2CP options"));
out.write(":<td colspan=\"2\"><textarea name=\"i2cpOpts\" cols=\"60\" rows=\"1\" wrap=\"off\" spellcheck=\"false\" >"
+ opts.toString() + "</textarea>\n" +
"<tr class=\"spacer\"><td colspan=\"3\">&nbsp;\n" + // spacer
+ opts.toString() + "</textarea>\n");
out.write("<tr><td>");
out.write(_t("Tracker Search API Key"));
String target = _manager.util().getAPITarget();
if (target == null)
target = "tracker2.postman.i2p";
String key = _manager.util().getAPIKey();
if (key != null)
key = DataHelper.escapeHTML(key);
else
key = "";
out.write(":<td>" +
"<input type=\"text\" name=\"apiTarget\" value=\"" + target + "\" readonly=\"readonly\">\n" +
"<input type=\"password\" name=\"apiKey\" size=\"24\" value=\"" + key + "\">\n" +
"<td id=\"bwHelp\"><i>" +
_t("Required for tracker search") +
"</i> <a href=\"http://tracker2.postman.i2p/index.php?view=MyAccount\" target=\"blank\">[" +
_t("Get API Key") +
"]</a>\n");
out.write("<tr><td>");
out.write(_t("Tracker Search All Categories"));
boolean allcats = false;
out.write(":<td><input type=\"checkbox\" class=\"optbox\" name=\"allcats\" id=\"allcats\" value=\"true\" " +
(allcats ? "checked " : "") +
"</td><td id=\"bwHelp\"><i>" +
_t("If unchecked, use account default") +
"</i> <a href=\"http://tracker2.postman.i2p/index.php?view=MyAccount\" target=\"blank\">[" +
_t("Settings") +
"]</a>\n");
out.write("<tr class=\"spacer\"><td colspan=\"3\">&nbsp;\n" + // spacer
"<tr><td colspan=\"3\"><input type=\"submit\" class=\"accept\" value=\"");
out.write(_t("Save configuration"));
out.write("\" name=\"foo\" >\n" +

View File

@ -0,0 +1,640 @@
package org.klomp.snark.web;
/*
* Released into the public domain
* with no warranty of any kind, either expressed or implied.
*/
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.OutputStream;
import java.io.Reader;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import net.i2p.CoreVersion;
import net.i2p.I2PAppContext;
import net.i2p.client.streaming.I2PSocketEepGet;
import net.i2p.client.streaming.I2PSocketManager;
import net.i2p.data.DataHelper;
import net.i2p.servlet.util.ServletUtil;
import net.i2p.util.ByteArrayStream;
import net.i2p.util.EepGet;
import net.i2p.util.LHMCache;
import net.i2p.util.Log;
import org.json.simple.DeserializationException;
import org.json.simple.Jsoner;
import org.json.simple.JsonArray;
import org.json.simple.JsonKey;
import org.json.simple.JsonObject;
import org.klomp.snark.I2PSnarkUtil;
import org.klomp.snark.Snark;
import org.klomp.snark.SnarkManager;
import org.klomp.snark.URIUtil;
/**
* Search a remote site using its API. Only one site supported for now.
*
* Ref: http://tracker2.postman.i2p/api.md
*
* @since 0.9.67
*/
class RemoteSearch {
// statics for caching, dup checks, ordering checks, ...
// TODO this may behave strangely if two search tabs open at once
private static final AtomicInteger _lastSent = new AtomicInteger();
private static final AtomicInteger _lastRcvd = new AtomicInteger();
private static final AtomicReference<String> _currentSearch = new AtomicReference<String>();
private static final AtomicLong _bannedUntil = new AtomicLong();
// positive/negative cache, Result.result may be null
private static final Map<String, Result> _cache = new LHMCache<String, Result>(16);
//private static final List<Thread> _waiting = new ArrayList<Thread>(16);
private static final Object _waitLock = new Object();
private static Thread _waiting;
private final I2PAppContext _ctx;
private final Log _log;
private final SnarkManager _manager;
private final int _id;
private final int _rcvd;
/** fixme for mulitple installs */
private static final String _contextPath = "/i2psnark";
private static final String _themePathPfx = _contextPath + I2PSnarkServlet.WARBASE + "themes/";
private final String _themePath;
private final String _imgPath;
private static final String SUPPORTED_TARGET = "tracker2.postman.i2p";
private static final String API_TARGET = "http://" + SUPPORTED_TARGET + "/api/torrents";
private static final String DETAILS_URL = "http://" + SUPPORTED_TARGET + "/index.php?view=TorrentDetail&amp;id=";
private static final String API_HEADER = "X-Postman-Token";
private static final String WEB_URL = "http://" + SUPPORTED_TARGET + "/?view=Main&amp;lastactive=-1&amp;category=-1&amp;orderby=-1&amp;lang=-1&amp;search=";
public static final int MIN_LEN = 3;
private static final long CACHE_LIFETIME = 10*60*1000;
private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US);
private static final long HEADER_TIMEOUT = 20*1000;
private static final long TOTAL_TIMEOUT = 30*1000;
private static final long INACTIVITY_TIMEOUT = -1;
private static final long DATE_FORMAT_CUTOFF = 3*24*60*60*1000L;
private static final long DELAY = 650;
// special return value if older
public static final String OLDER = "!";
/**
*
*/
public RemoteSearch(I2PAppContext ctx, SnarkManager mgr) {
_ctx = ctx;
_log = ctx.logManager().getLog(RemoteSearch.class);
_manager = mgr;
_id = _lastSent.incrementAndGet();
_rcvd = _lastRcvd.get();
_themePath = _themePathPfx + _manager.getTheme() + '/';
_imgPath = _themePath + "images/";
}
/**
* This returns table rows only, to be inserted into the remote results table.
* Blocking.
*
* @param s URI-decoded
* @return null on failure or no results, OLDER if the result should be discarded.
*/
public String search(String s, boolean isXHR) {
// don't kick off a new search after a space was typed,
// trim it and pull from cache
s = s.trim();
if (s.length() < MIN_LEN)
return null;
I2PSnarkUtil util = _manager.util();
boolean connected = util.connected();
synchronized(_cache) {
Result result = _cache.get(s);
if (result != null) {
if (!connected)
return result.result;
long now = _ctx.clock().now();
if (result.time + CACHE_LIFETIME >= now) {
if (_log.shouldDebug())
_log.debug("Cached: " + s);
return result.result;
} else {
_cache.remove(s);
}
}
// while we have the lock
if ((_id & 0x08) == 0)
expireCache();
}
if (!connected)
return null;
I2PSocketManager smanager = util.getSocketManager();
if (smanager == null)
return null;
String tgt = util.getAPITarget();
if (!SUPPORTED_TARGET.equals(tgt))
return null;
String key = util.getAPIKey();
if (key == null)
return null;
// dup check
// for example, if user types and then hits enter, we'll get
// an XHR and then a non-XHR for the same string
String prev = _currentSearch.getAndSet(s);
if (s.equals(prev)) {
if (_log.shouldDebug())
_log.debug("Dup search for " + s + " waiting for result");
for (int i = 0; i < 60; i++) {
try {
Thread.sleep(500);
} catch (InterruptedException ie) {
return OLDER;
}
synchronized(_cache) {
Result result = _cache.get(s);
if (result != null) {
if (_log.shouldDebug())
_log.debug("Got result from dup lookup " + s);
return result.result;
}
}
}
return null;
}
long currentBan = _bannedUntil.get();
if (currentBan > 0) {
long now = _ctx.clock().now();
if (currentBan > now) {
long retry = currentBan - now;
if (retry <= 10*60*1000)
return _t("Rate limited for {0}", DataHelper.formatDuration(retry));
else
return _t("Rate limited until {0}", DataHelper.formatTime(currentBan));
}
_bannedUntil.compareAndSet(currentBan, 0);
}
// if a new search comes in within 650 ms, interrupt the old one
if (isXHR) {
Thread us = Thread.currentThread();
synchronized(_waitLock) {
if (_waiting != null) {
if (_log.shouldDebug())
_log.debug("Search for " + s + " interrupting 1 pending lookup");
_waiting.interrupt();
}
_waiting = us;
try {
_waitLock.wait(DELAY);
} catch (InterruptedException ie) {
if (_log.shouldDebug())
_log.debug("interrupted, aborting: " + s);
return OLDER;
}
_waiting = null;
}
}
if (_log.shouldDebug())
_log.debug("starting fetch: " + s);
String encoded = URIUtil.encodePath(s);
int limit = Math.min(100, Math.max(5, _manager.getPageSize()));
// includes description search
//String url = API_TARGET + "?search=" + encoded + "&entries=" + limit;
// torrent name only
//String url = API_TARGET + "?name=" + encoded + "&entries=" + limit;
// include inactive and all categories
// set defaults at http://tracker2.postman.i2p/?view=MyAccount
// TODO config for catfilter
String url = API_TARGET + "?name=" + encoded + "&entries=" + limit + "&filter_id=-1&catfilter_id=-1";
int max_size = limit * 2048;
ByteArrayStream out = new ByteArrayStream(16384);
EepGet get = new I2PSocketEepGet(_ctx, smanager, 0, 0, max_size, null, out, url);
get.addHeader("User-Agent", I2PSnarkUtil.EEPGET_USER_AGENT);
get.addHeader("Accept", "application/json");
get.addHeader(API_HEADER, key);
boolean ok = get.fetch(HEADER_TIMEOUT, TOTAL_TIMEOUT, INACTIVITY_TIMEOUT);
_currentSearch.compareAndSet(s, null);
if (ok) {
if (_log.shouldDebug())
_log.debug("Fetch successful [" + url + "]: size=" + out.size());
String rv = processResponse(out, s, encoded);
long now = _ctx.clock().now();
// positive or negative cache
synchronized(_cache) {
_cache.put(s, new Result(rv, now));
}
if (isXHR && isOlder()) {
if (_log.shouldWarn())
_log.warn("got old response");
return OLDER;
}
return rv;
} else {
StringBuilder buf = new StringBuilder(128);
buf.append("<tr class=\"snarkTorrentNoneLoaded\"><td colspan=\"8\">");
int code = get.getStatusCode();
if (code == 200) // got header but not body
code = -1;
if (code > 0) {
buf.append(code);
String text;
if (code == 429)
text = process429(out);
else
text = get.getStatusText();
if (text != null)
buf.append(' ').append(DataHelper.escapeHTML(text));
} else {
buf.append(_t("Connection to {0} failed", tgt));
}
if (_log.shouldInfo())
_log.info("Fetch failed [" + url + "] " + buf);
return buf.toString();
}
}
/**
* Ensure an old response doesn't trump a newer one
* when received out-of-order
*/
private boolean isOlder() {
int rcvd = _rcvd;
while(!_lastRcvd.compareAndSet(rcvd, _id)) {
rcvd = _lastRcvd.get();
if (rcvd >= _id)
return true;
}
return false;
}
private String processResponse(ByteArrayStream out, String search, String encoded) {
try {
return x_processResponse(out, search, encoded);
} catch (Exception e) {
if (_log.shouldWarn())
_log.warn("json error input=\"" + DataHelper.getUTF8(out.toByteArray()), e);
return null;
}
}
private String x_processResponse(ByteArrayStream out, String search, String encoded) throws Exception {
if (out.size() == 0)
return null;
Reader in = new InputStreamReader(out.asInputStream(), "UTF-8");
JsonObject map = (JsonObject) Jsoner.deserialize(in);
JsonArray torrents = map.getCollection(TORRENTS);
if (torrents == null || torrents.isEmpty()) {
if (_log.shouldWarn())
_log.warn("no results");
return null;
}
StringBuilder buf = new StringBuilder(torrents.size() * 256);
long cutoff = _ctx.clock().now() - DATE_FORMAT_CUTOFF;
for (Object o : torrents) {
JsonObject torrent = (JsonObject) o;
render(buf, torrent, cutoff);
}
int found = map.getIntegerOrDefault(FOUND);
int sz = torrents.size();
int more = found - sz;
buf.append("\n<tr id=\"remoteresultfooter\" search=\"").append(DataHelper.escapeHTML(search))
.append("\"><td colspan=\"2\"> <td colspan=\"2\">");
if (more > 0)
buf.append(ngettext("{0} more result", "{0} more results", more)).append(" - ");
buf.append("<a href=\"").append(WEB_URL).append(encoded)
.append("\" target=\"blank\">").append(_t("View results on {0}", SUPPORTED_TARGET)).append("</a><td colspan=\"4\">\n");
return buf.toString();
}
private String process429(ByteArrayStream out) {
try {
Reader in = new InputStreamReader(out.asInputStream(), "UTF-8");
JsonObject map = (JsonObject) Jsoner.deserialize(in);
long retry = map.getIntegerOrDefault(RETRY) * 1000L;
long until = _ctx.clock().now() + retry;
long currentBan = _bannedUntil.get();
while (until > currentBan && !_bannedUntil.compareAndSet(currentBan, until)) {
currentBan = _bannedUntil.get();
}
if (retry <= 10*60*1000)
return _t("Rate limited for {0}", DataHelper.formatDuration(retry));
else
return _t("Rate limited until {0}", DataHelper.formatTime(until));
} catch (Exception e) {
if (_log.shouldWarn())
_log.warn("json error input=\"" + DataHelper.getUTF8(out.toByteArray()), e);
return null;
}
}
// keys with defaults
private static final JsonKey ADDED = Jsoner.mintJsonKey("added", "");
private static final JsonKey ALIVE = Jsoner.mintJsonKey("is_alive", Integer.valueOf(0));
private static final JsonKey CATEGORY = Jsoner.mintJsonKey("category_id", Integer.valueOf(0));
private static final JsonKey FILES = Jsoner.mintJsonKey("file_count", Integer.valueOf(1));
private static final JsonKey FOUND = Jsoner.mintJsonKey("available_entries", Integer.valueOf(0));
private static final JsonKey HASH = Jsoner.mintJsonKey("info_hash", "");
private static final JsonKey ID = Jsoner.mintJsonKey("torrent_id", "0");
private static final JsonKey LANG1 = Jsoner.mintJsonKey("languages", null);
private static final JsonKey LANG2 = Jsoner.mintJsonKey("main_language_ids", null);
private static final JsonKey LANG3 = Jsoner.mintJsonKey("secondary_language_ids", null);
private static final JsonKey LEECHES = Jsoner.mintJsonKey("leechers", Integer.valueOf(0));
private static final JsonKey LINK = Jsoner.mintJsonKey("download_link", "");
private static final JsonKey NAME = Jsoner.mintJsonKey("name", "");
private static final JsonKey OWNER = Jsoner.mintJsonKey("owner", "");
private static final JsonKey RETRY = Jsoner.mintJsonKey("retry_after", Integer.valueOf(60));
private static final JsonKey SEEDS = Jsoner.mintJsonKey("seeders", Integer.valueOf(0));
private static final JsonKey SIZE = Jsoner.mintJsonKey("size", Long.valueOf(0));
private static final JsonKey TORRENTS = Jsoner.mintJsonKey("torrents", null);
private void render(StringBuilder buf, JsonObject torrent, long cutoff) {
String added = torrent.getStringOrDefault(ADDED);
boolean alive = torrent.getIntegerOrDefault(ALIVE) != 0;
int cat = torrent.getIntegerOrDefault(CATEGORY);
int files = torrent.getIntegerOrDefault(FILES);
String hash = torrent.getStringOrDefault(HASH);
String id = torrent.getStringOrDefault(ID);
int leeches = torrent.getIntegerOrDefault(LEECHES);
String link = torrent.getStringOrDefault(LINK);
String name = torrent.getStringOrDefault(NAME);
String owner = torrent.getStringOrDefault(OWNER);
if (owner.equals("hidden"))
owner = "";
else if (owner.length() > 16)
owner = owner.substring(14) + "&hellip;";
int seeds = torrent.getIntegerOrDefault(SEEDS);
long size = torrent.getLongOrDefault(SIZE);
buf.append("\n<tr");
if (seeds <= 0)
buf.append(" class=\"dead\"");
buf.append("><td class=\"snarkGraphicStatus\">");
String img = seeds > 0 ? "seeding"
: (leeches > 0 ? "stalled" : "stopped");
toThemeImg(buf, img, "", "");
buf.append("<td class=\"snarkTorrentStatus\">");
buf.append(seeds).append("&thinsp;/&thinsp;").append(leeches)
.append("<td class=\"snarkTorrentDetails\">");
toImg(buf, cat);
buf.append("<td class=\"snarkTorrentName\"><a href=\"").append(DETAILS_URL).append(id)
.append("\" target=\"_blank\">").append(DataHelper.escapeHTML(truncate(name))).append("</a>")
.append("<td class=\"snarkTorrentDownloaded\">").append(DataHelper.formatSize2(size)).append('B')
.append(" / ").append(ngettext("{0} file", "{0} files", files))
.append("<td class=\"snarkTorrentUploaded\">");
long date = parseDate(added);
if (date > 0) {
if (date < cutoff)
buf.append(DataHelper.formatDate(date));
else
buf.append(DataHelper.formatTime(date));
}
buf.append("<td class=\"snarkTorrentUploaded\">").append(DataHelper.escapeHTML(owner));
buf.append("<td class=\"snarkTorrentAction\">");
byte[] ihash = getHash(hash);
if (ihash != null) {
Snark snark = _manager.getTorrentByInfoHash(ihash);
if (snark != null) {
String encodedBaseName = URIUtil.encodePath(snark.getBaseName());
buf.append("<a href=\"").append(encodedBaseName)
.append("/\" title=\"").append(_t("Torrent details"))
.append("\">")
.append(_t("Already added")).append("</a>");
} else {
// todo make button link
// buf.append(link);
String txt = _t("Add torrent");
toThemeImg(buf, "add", txt, txt);
}
}
}
private static class Result {
public final String result;
public final long time;
public Result(String r, long n) {
result = r;
time = n;
}
}
private void expireCache() {
cleanCache(_ctx.clock().now());
}
private void cleanCache(long now) {
long cutoff = now - CACHE_LIFETIME;
synchronized(_cache) {
for (Iterator<Result> iter = _cache.values().iterator(); iter.hasNext(); ) {
Result r = iter.next();
if (r.time < cutoff)
iter.remove();
}
}
}
public static byte[] getHash(String hash) {
if (hash.length() != 40)
return null;
// Like DataHelper.fromHexString() but ensures no loss of leading zero bytes
byte[] rv = new byte[20];
try {
for (int i = 0; i < 20; i++) {
rv[i] = (byte) (Integer.parseInt(hash.substring(i*2, (i*2) + 2), 16) & 0xff);
}
} catch (NumberFormatException nfe) {
rv = null;
}
return rv;
}
private static long parseDate(String time) {
try {
Date date = DATE_FORMAT.parse(time);
if (date != null)
return date.getTime();
} catch (ParseException pe) {}
return 0;
}
private static String truncate(String name) {
String rv = name;
if (name.length() > I2PSnarkServlet.MAX_DISPLAYED_FILENAME_LENGTH) {
String start = ServletUtil.truncate(name, I2PSnarkServlet.MAX_DISPLAYED_FILENAME_LENGTH);
if (start.indexOf(' ') < 0 && start.indexOf('-') < 0) {
// browser has nowhere to break it
rv = start + I2PSnarkServlet.HELLIP;
}
}
return rv;
}
/*
From home page source
<option value="1">Movies
<option value="2">Music
<option value="3">TV
<option value="4">Games
<option value="5">Apps
<option value="6">Misc.
<option value="8">Pictures
<option value="9">Anime
<option value="10">Comics
<option value="11">Books
<option value="13">Music Vid.
<option value="14">Pr0n (not in default)
<option value="15">Documentary
<option value="16">Leaked Documents
<option value="17">Audio Books
<option value="18">Conspiracy (not in default)
<option value="19">Religious Content (not in default)
<option value="20">E-Books
<option value="21">Course/Lesson
<option value="22">Essay/Op-Ed
<option value="23">Cad/3D Printing
<option value="24">Podcasts
*/
private static final String[] catToText = {
"", _x("Movie"), _x("Music"), _x("TV"), _x("Game"), _x("Application"), _x("Other"), "",
_x("Pictures"), _x("Anime"), _x("Comics"), _x("Book"), "", _x("Music Video"), _x("Porn"), _x("Documentary"),
_x("Leaked Documents"), _x("Audio Book"), _x("Conspiracy"), _x("Religious Content"), _x("E-Book"), _x("Education"), _x("Opinion"), _x("3D Printing"),
_x("Podcast")
};
private static final String[] catToIcon = {
"", "film", "music", "film", "application", "application", "page_white", "page_white",
"photo", "film", "ebook", "ebook", "page_white", "film", "cancel", "film",
"page_white_acrobat", "music", "page_white_acrobat", "page_white", "ebook", "page_white_acrobat", "page", "application",
"music"
};
private static String toText(int cat) {
if (cat <= 0 || cat > catToText.length - 1)
return "";
return catToText[cat];
}
private static String toIcon(int cat) {
String icon;
if (cat <= 0 || cat > catToIcon.length - 1)
icon = "page_white";
else
icon = catToIcon[cat];
return icon;
}
/**
* Icon file in the .war. Always 16x16.
*
* @param icon name without the ".png"
*/
private void toImg(StringBuilder buf, String icon, String altText) {
buf.append("<img alt=\"").append(altText).append("\" title=\"").append(altText)
.append("\" height=\"16\" width=\"16\" src=\"").append(_contextPath)
.append(I2PSnarkServlet.WARBASE).append("icons/").append(_manager.getThemeIconSet())
.append(icon).append(".png?").append(CoreVersion.VERSION).append("\">");
}
private void toImg(StringBuilder buf, int cat) {
toImg(buf, toIcon(cat), _t(toText(cat)));
}
private void toThemeImg(StringBuilder buf, String image, String altText, String titleText) {
buf.append("<img alt=\"").append(altText).append("\" src=\"").append(_imgPath).append(image).append(".png\"");
if (titleText.length() > 0)
buf.append(" title=\"").append(titleText).append('"');
buf.append('>');
}
/*
From home page source
value="1">english
value="2">german
value="3">french
value="4">spanish
value="5">portugese
value="6">dutch
value="7">russian
value="8">swedish
value="9">italian
value="10">chinese
value="11">finnish
value="12">japanese
value="13">turkish
value="14">korean
value="15">danish
value="16">norwegian
value="17">polish
value="18">hindi
*/
private static final String[] idToLang = {
"", "en", "de", "fr", "es", "pt", "nl", "ru",
"se", "it", "zh", "fi", "ja", "tr", "ko", "da",
"no", "pl", "hi"
};
private static String toLang(int id) {
if (id <= 0 || id > idToLang.length - 1)
return "en";
return idToLang[id];
}
/**
* @param lang two char lower case
*/
private static int fromLang(String lang) {
for (int i = 1; i < idToLang.length; i++) {
if (idToLang[i].equals(lang))
return i;
}
return 1;
}
/** tag */
private static String _x(String s) {
return s;
}
/** translate */
private String _t(String s) {
return _manager.util().getString(s);
}
/** translate */
private String _t(String s, Object o) {
return _manager.util().getString(s, o);
}
/** translate */
private String _t(String s, Object o, Object o2) {
return _manager.util().getString(s, o, o2);
}
/** translate (ngettext) */
private String ngettext(String s, String p, int n) {
return _manager.util().getString(n, s, p);
}
}

View File

@ -54,6 +54,45 @@ function requestAjax2(refreshtime) {
}
}
/**
* Local fetch, backend does remote query
*
* @since 0.9.67
*/
function requestAjaxRemote() {
var url = ".ajax/xhr2.html";
var query = window.location.search;
var box = document.getElementById("searchbox");
if (box != null) {
// put in, remove, or replace value from search form
var search = box.value;
if (search.length > 0) {
if (query == null) {
query = "";
}
var q = new URLSearchParams(query);
q.set("nf_s", encodeURIComponent(search));
query = "?" + q.toString();
} else {
if (query != null) {
var q = new URLSearchParams(query);
q.delete("nf_s");
var newq = q.toString();
if (newq != null && newq.length > 0) {
query = "?" + newq;
} else {
query = null;
}
}
}
}
if (query)
url += query;
// FIXME this will insert the 'router is down' message on failure
ajax(url, "remoteresulttablebody", -1);
}
function initAjax() {
if (ajaxDelay > 0) {
setTimeout(requestAjax1, ajaxDelay);

View File

@ -12,34 +12,214 @@ function initSearch()
if (sch != null) {
var box = document.getElementById("searchbox");
var cxl = document.getElementById("searchcancel");
var radio = document.querySelectorAll('input[name=searchopt]');
var remote = document.getElementById("remoteresults");
var msgs = document.getElementById("snarkMessages");
var main = document.getElementById("mainsection");
var lower = document.getElementById("lowersection");
// cancel listener
cxl.addEventListener("click", function(event) {
if (box.value !== "") {
box.value = "";
requestAjax2(-1);
}
cxl.classList.add("disabled");
if (remote != null)
remote.classList.add("disabled");
if (main != null)
main.classList.remove("disabled");
if (msgs != null)
msgs.classList.remove("disabled");
if (lower != null)
lower.classList.remove("disabled");
removeRemoteResults();
event.preventDefault();
});
// search listener
box.addEventListener("input", function(event) {
if (box.value !== "") {
cxl.classList.remove("disabled");
if (msgs != null)
msgs.classList.add("disabled");
if (lower != null)
lower.classList.add("disabled");
if (radio != null) {
var opt = document.querySelector('input[name=searchopt]:checked').value;
if (opt === "1") {
if (remote != null)
remote.classList.add("disabled");
if (main != null)
main.classList.remove("disabled");
requestAjax2(-1);
} else if (opt === "2") {
if (remote != null) {
if (box.value.length >= 3) {
removeUnrelatedResults(box.value);
remote.classList.remove("disabled");
requestAjaxRemote();
} else {
remote.classList.add("disabled");
}
}
if (main != null)
main.classList.add("disabled");
} else if (opt === "3") {
if (remote != null) {
if (box.value.length >= 3) {
removeUnrelatedResults(box.value);
remote.classList.remove("disabled");
requestAjaxRemote();
} else {
remote.classList.add("disabled");
}
}
if (main != null)
main.classList.remove("disabled");
requestAjax2(-1);
}
} else {
if (remote != null)
remote.classList.add("disabled");
if (main != null)
main.classList.remove("disabled");
requestAjax2(-1);
}
} else {
cxl.classList.add("disabled");
removeRemoteResults();
if (remote != null)
remote.classList.add("disabled");
if (main != null)
main.classList.remove("disabled");
if (msgs != null)
msgs.classList.remove("disabled");
if (lower != null)
lower.classList.remove("disabled");
requestAjax2(-1);
}
requestAjax2(-1);
});
// radio listener
if (radio != null) {
for (var index = 0; index < radio.length; index++)
{
var r = radio[index];
r.addEventListener("click", function(event) {
var opt = document.querySelector('input[name=searchopt]:checked').value;
if (opt === "1" || box.value === "") {
if (remote != null)
remote.classList.add("disabled");
if (main != null)
main.classList.remove("disabled");
//requestAjax2(-1);
} else if (opt === "2") {
if (remote != null) {
if (box.value.length >= 3) {
removeUnrelatedResults(box.value);
remote.classList.remove("disabled");
//requestAjaxRemote();
} else {
remote.classList.add("disabled");
}
}
if (main != null)
main.classList.add("disabled");
} else if (opt === "3") {
if (remote != null) {
if (box.value.length >= 3) {
removeUnrelatedResults(box.value);
remote.classList.remove("disabled");
//requestAjaxRemote();
} else {
remote.classList.add("disabled");
}
}
if (main != null)
main.classList.remove("disabled");
//requestAjax2(-1);
}
});
}
}
// initial setup
if (box.value !== "") {
cxl.classList.remove("disabled");
if (msgs != null)
msgs.classList.add("disabled");
if (lower != null)
lower.classList.add("disabled");
if (radio != null) {
var opt = document.querySelector('input[name=searchopt]:checked').value;
if (opt === "1") {
if (remote != null)
remote.classList.add("disabled");
if (main != null)
main.classList.remove("disabled");
} else if (opt === "2") {
if (remote != null) {
if (box.value.length >= 3) {
remote.classList.remove("disabled");
} else {
remote.classList.add("disabled");
}
}
if (main != null)
main.classList.add("disabled");
} else if (opt === "3") {
if (remote != null) {
if (box.value.length >= 3) {
remote.classList.remove("disabled");
} else {
remote.classList.add("disabled");
}
}
if (main != null)
main.classList.remove("disabled");
}
} else {
if (remote != null)
remote.classList.add("disabled");
if (main != null)
main.classList.remove("disabled");
}
} else {
cxl.classList.add("disabled");
if (remote != null)
remote.classList.add("disabled");
if (main != null)
main.classList.remove("disabled");
if (msgs != null)
msgs.classList.remove("disabled");
if (lower != null)
lower.classList.remove("disabled");
}
// so we don't get the link popup
cxl.removeAttribute("href");
}
}
function removeUnrelatedResults(cur) {
var footer = document.getElementById("remoteresultfooter");
if (footer != null) {
var prev = footer.getAttribute("search");
if (prev != null) {
if (!prev.startsWith(cur) && !cur.startsWith(prev)) {
// unrelated search, clear previous results
removeRemoteResults();
}
}
}
}
function removeRemoteResults() {
var results = document.getElementById("removeresulttablebody");
if (results != null)
results.innerHTML = "";
}
document.addEventListener("DOMContentLoaded", function() {
initSearch();
}, true);

View File

@ -265,6 +265,10 @@ _:-ms-lang(x), .snarkNav:last-child[href="/i2psnark/"] {
display: none;
}
#remoteresults.disabled, #mainsection.disabled, #lowersection.disabled, #snarkMessages.disabled {
display: none;
}
/* end topnav */
/* screenlogger */