diff --git a/LICENSE.txt b/LICENSE.txt index 32cd5d12b..3baa066b3 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -320,6 +320,10 @@ Applications: Copyright (c) 2013-2023 David J. Bradshaw See licenses/LICENSE-Iframe-resizer.txt + Router Console country coordinates: + Adapted from Google Dataset Publishing Language to convert to Mercator + CC BY 4.0 https://creativecommons.org/licenses/by/4.0/ + SAM (sam.jar): Public domain. diff --git a/apps/routerconsole/java/src/net/i2p/router/web/helpers/MapMaker.java b/apps/routerconsole/java/src/net/i2p/router/web/helpers/MapMaker.java new file mode 100644 index 000000000..7ac73bcb5 --- /dev/null +++ b/apps/routerconsole/java/src/net/i2p/router/web/helpers/MapMaker.java @@ -0,0 +1,610 @@ +package net.i2p.router.web.helpers; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.awt.image.ImageObserver; + +import javax.imageio.ImageIO; +import javax.imageio.stream.ImageOutputStream; +import javax.imageio.stream.MemoryCacheImageOutputStream; + +import net.i2p.data.DataHelper; +import net.i2p.data.Hash; +import net.i2p.data.router.RouterInfo; +import net.i2p.rrd4j.SimpleSVGGraphics2D; +import static net.i2p.rrd4j.SimpleSVGGraphics2D.*; +import net.i2p.rrd4j.SimpleSVGMaker; +import net.i2p.router.RouterContext; +import net.i2p.router.TunnelInfo; +import net.i2p.router.TunnelManagerFacade; +import net.i2p.router.tunnel.HopConfig; +import net.i2p.router.tunnel.pool.TunnelPool; +import net.i2p.router.web.ContextHelper; +import net.i2p.router.web.Messages; +import net.i2p.util.Log; +import net.i2p.util.ObjectCounterUnsafe; +import net.i2p.util.Translate; + +/** + * Generate a transparent image to overlay the world map in a Web Mercator format. + * + * Also contains commented-out code to generate the mercator.txt file. + * + * @since 0.9.xx + */ +public class MapMaker { + private final RouterContext _context; + private final Log _log; + private final Map hints = new HashMap(4); + + private static final Map _mercator = new HashMap(256); + + static { + readMercatorFile(); + } + + private static final String MERCATOR_DEFAULT = "mercator.txt"; + private static final int WIDTH = 1600; + private static final int HEIGHT = 1600; + private static final int MAP_HEIGHT = 828; + // offsets from mercator to image. + // left side at 171.9 degrees (rotated 34 pixels) + // tweak to make it line up, eyeball Taiwan + private static final int IMG_X_OFF = -34; + // We crop the top from 85 degrees down to about 75 degrees (283 pixels) + // We crop the bottom from 85 degrees down to about 57 degrees (489 pixels) + private static final int IMG_Y_OFF = -283; + private static final Color TEXT_COLOR = new Color(20, 20, 20); + private static final String FONT_NAME = "Dialog"; + private static final int FONT_STYLE = Font.BOLD; + private static final int FONT_SIZE = 16; + // center text on the spot + private static final int TEXT_Y_OFF = (FONT_SIZE / 2) - 2; + private static final Color CIRCLE_BORDER_COLOR = new Color(192, 0, 0, 192); + private static final Color CIRCLE_COLOR = new Color(160, 0, 0, 128); + private static final double CIRCLE_SIZE_FACTOR = 2.75; + private static final int MIN_CIRCLE_SIZE = 7; + private static final Color SQUARE_BORDER_COLOR = new Color(0, 0, 0); + private static final Color SQUARE_COLOR = new Color(255, 50, 255, 160); + private static final Color CLIENT_COLOR = new Color(255, 100, 0); + private static final Color EXPL_COLOR = new Color(255, 160, 160); + private static final Color PART_COLOR = new Color(255, 0, 100); + private static final Color ANIMATE_COLOR = new Color(100, 0, 255); + private static final Color TRANSPARENT = new Color(0, 0, 0, 0); + private static final BasicStroke STROKE = new BasicStroke(1); + private static final BasicStroke STROKE2 = new BasicStroke(3); + private static final int MODE_ROUTERS = 1; + private static final int MODE_EXPL = 2; + private static final int MODE_CLIENT = 4; + private static final int MODE_PART = 8; + private static final int MODE_ANIM = 16; + private static final int MODE_FF = 32; + private static final int MODE_DEFAULT = MODE_ROUTERS | MODE_EXPL | MODE_CLIENT | MODE_ANIM; + + private int tunnelCount; + + /** + * + */ + public MapMaker() { + this(ContextHelper.getContext(null)); + } + + /** + * + */ + public MapMaker(RouterContext ctx) { + _context = ctx; + _log = ctx.logManager().getLog(MapMaker.class); + } + + private static class Mercator { + public final int x, y; + public Mercator(int x, int y) { + this.x = x; this.y = y; + } + @Override + public int hashCode() { + return x + y; + } + @Override + public boolean equals(Object o) { + Mercator m = (Mercator) o; + return x == m.x && y == m.y; + } + } + + /** + * @param mode bitmask, 0 for default, see MODE definitions above + */ + public boolean render(int mode, OutputStream out) throws IOException { + if (_mercator.isEmpty()) { + _log.warn("mercator file not found"); + return false; + } + out.write(render(mode).getBytes("UTF-8")); + out.flush(); + return true; + } + + /** + * @param mode see above + */ + public String render(int mode) throws IOException { + if (mode == 0) + mode = MODE_DEFAULT; + ObjectCounterUnsafe countries = new ObjectCounterUnsafe(); + if ((mode & (MODE_ROUTERS | MODE_FF)) != 0) { + boolean ff = (mode & MODE_FF) != 0; + for (RouterInfo ri : _context.netDb().getRouters()) { + if (ff && ri.getCapabilities().indexOf('f') < 0) + continue; + Hash key = ri.getIdentity().getHash(); + String country = _context.commSystem().getCountry(key); + if (country != null) + countries.increment(country); + } + } + return render(mode, countries); + } + + /** + * @param mode see above + */ + public String render(int mode, ObjectCounterUnsafe countries) throws IOException { + // only for string widths + Graphics2D gg = new SimpleSVGGraphics2D(1, 1); + StringBuilder buf = new StringBuilder(32768); + SimpleSVGMaker g = new SimpleSVGMaker(buf); + g.startSVG(WIDTH, MAP_HEIGHT, TRANSPARENT, "mapoverlaysvg", null); + Font large = new Font(FONT_NAME, FONT_STYLE, FONT_SIZE); + + buf.append(""); + g.drawText(_t("Routers"), 25, 700, TEXT_COLOR, large, null, hints); + buf.append("\n"); + buf.append(""); + g.drawText(_t("Floodfills"), 25, 725, TEXT_COLOR, large, null, hints); + buf.append("\n"); + buf.append(""); + g.drawText(_t("Exploratory Tunnels"), 25, 750, TEXT_COLOR, large, null, hints); + buf.append("\n"); + buf.append(""); + g.drawText(_t("Client Tunnels"), 25, 775, TEXT_COLOR, large, null, hints); + buf.append("\n"); + buf.append(""); + g.drawText(_t("Participating Tunnels"), 25, 800, TEXT_COLOR, large, null, hints); + buf.append("\n"); + + if ((mode & (MODE_ROUTERS | MODE_FF)) != 0) { + for (String c : countries.objects()) { + Mercator m = _mercator.get(c); + if (m == null) + continue; + int count = countries.count(c); + String title = getTranslatedCountry(c) + ": " + ngettext("{0} router", "{0} routers", count); + + hints.put(KEY_ELEMENT_ID, "mapoverlaytext-" + c); + hints.put(KEY_ELEMENT_CLASS, "mapoverlaytext dynamic"); + //hints.put(KEY_ELEMENT_TITLE, title); + c = c.toUpperCase(Locale.US); + double width = getStringWidth(c, large, gg); + int xoff = (int) (width / 2); + g.drawText(c.toUpperCase(Locale.US), rotate(m.x) - xoff, m.y + IMG_Y_OFF + TEXT_Y_OFF, TEXT_COLOR, large, null, hints); + hints.clear(); + + // put the circle on top of the text so it captures the title + int sz = Math.max(MIN_CIRCLE_SIZE, (int) (CIRCLE_SIZE_FACTOR * Math.sqrt(count))); + // add count to ID so it will be replaced on change by ajaxchanges + hints.put(KEY_ELEMENT_ID, "mapoverlaycircle-" + c + '-' + count); + hints.put(KEY_ELEMENT_CLASS, "mapoverlaycircle dynamic"); + hints.put(KEY_ELEMENT_TITLE, title); + drawCircle(g, rotate(m.x), m.y + IMG_Y_OFF, sz); + hints.clear(); + } + } + + String us = _context.commSystem().getOurCountry(); + if (us != null) { + Mercator mus = _mercator.get(us); + if (mus != null) { + hints.put(KEY_ELEMENT_ID, "mapoverlaysquare-me"); + hints.put(KEY_ELEMENT_CLASS, "mapoverlaysquare"); + hints.put(KEY_ELEMENT_TITLE, _t("My router")); + drawSquare(g, rotate(mus.x), mus.y + IMG_Y_OFF, 24); + if ((mode & (MODE_EXPL | MODE_CLIENT)) != 0) { + TunnelManagerFacade tm = _context.tunnelManager(); + tunnelCount = 0; + if ((mode & MODE_EXPL) != 0) { + renderPool(mode, g, mus, tm.getInboundExploratoryPool(), EXPL_COLOR); + renderPool(mode, g, mus, tm.getOutboundExploratoryPool(), EXPL_COLOR); + } + if ((mode & MODE_CLIENT) != 0) { + Map pools = tm.getInboundClientPools(); + for (TunnelPool tp : pools.values()) { + if (tp.getSettings().getAliasOf() != null) + continue; + renderPool(mode, g, mus, tp, CLIENT_COLOR); + } + pools = tm.getOutboundClientPools(); + for (TunnelPool tp : pools.values()) { + if (tp.getSettings().getAliasOf() != null) + continue; + renderPool(mode, g, mus, tp, CLIENT_COLOR); + } + } + } + if ((mode & MODE_PART) != 0) { + ObjectCounterUnsafe tunnels = new ObjectCounterUnsafe(); + List participating = _context.tunnelDispatcher().listParticipatingTunnels(); + for (int i = 0; i < participating.size(); i++) { + HopConfig cfg = participating.get(i); + Hash from = cfg.getReceiveFrom(); + Hash to = cfg.getSendTo(); + String c = null; + if (from != null) { + c = _context.commSystem().getCountry(from); + if (c != null) + tunnels.increment(c); + } + if (to != null) { + String d = _context.commSystem().getCountry(to); + // only count once if to and from same country + if (d != null && !d.equals(c)) + tunnels.increment(d); + } + } + renderParticipating(g, mus, tunnels); + } + } + } + g.endSVG(); + return buf.toString(); + } + + /** + * Draw circle centered on x,y with a radius given + */ + private void drawCircle(SimpleSVGMaker g, int x, int y, int radius) { + g.drawCircle(x, y, radius, CIRCLE_BORDER_COLOR, CIRCLE_COLOR, STROKE, null, hints); + } + + /** + * Draw square centered on x,y with a width/height given + */ + private void drawSquare(SimpleSVGMaker g, int x, int y, int sz) { + g.drawSquare(x, y, sz, SQUARE_BORDER_COLOR, SQUARE_COLOR, STROKE, null, hints); + } + + /* + * @param mode see above + */ + private void renderPool(int mode, SimpleSVGMaker g, Mercator mus, TunnelPool tp, Color color) { + boolean isInbound = tp.getSettings().isInbound(); + boolean isExpl = tp.getSettings().isExploratory(); + // shift to 4 corners of box + int off = 12; + if (isExpl) { + if (isInbound) + mus = new Mercator(mus.x - off, mus.y - off); + else + mus = new Mercator(mus.x + off, mus.y - off); + } else { + if (isInbound) + mus = new Mercator(mus.x - off, mus.y + off); + else + mus = new Mercator(mus.x + off, mus.y + off); + } + List tunnels = tp.listTunnels(); + List hops = new ArrayList(8); + String nick = isExpl ? null : tp.getSettings().getDestinationNickname(); + int[] x = new int[8]; + int[] y = new int[8]; + for (TunnelInfo info : tunnels) { + int length = info.getLength(); + if (length < 2) + continue; + StringBuilder cbuf = new StringBuilder(16); + if (!isInbound) + cbuf.append("(me)"); + // gateway first + for (int j = 0; j < length; j++) { + Mercator m; + if (isInbound && j == length - 1) { + m = mus; + } else if (!isInbound && j == 0) { + m = mus; + } else { + if (cbuf.length() > 0) + cbuf.append("->"); // SVGMaker will escape + Hash peer = info.getPeer(j); + String country = _context.commSystem().getCountry(peer); + if (country == null) { + cbuf.append('?'); + continue; + } + cbuf.append(country.toUpperCase(Locale.US)); + Mercator mc = _mercator.get(country); + if (mc == null) + continue; + m = mc; + } + if (hops.isEmpty() || !m.equals(hops.get(hops.size() - 1))) { + hops.add(m); + } + } + if (isInbound) + cbuf.append("->(me)"); // SVGMaker will escape + int sz = hops.size(); + if (sz > 1) { + for (int i = 0; i < sz; i++) { + Mercator m = hops.get(i); + x[i] = rotate(m.x); + y[i] = m.y + IMG_Y_OFF; + } + long tid = isInbound ? info.getReceiveTunnelId(length - 1).getTunnelId() + : info.getSendTunnelId(0).getTunnelId(); + String svgid = "mapoverlaytunnel-" + tid; + String title; + if (isInbound) { + if (isExpl) + title = _t("Inbound exploratory tunnel"); + else + title = _t("Inbound client tunnel"); + } else { + if (isExpl) + title = _t("Outbound exploratory tunnel"); + else + title = _t("Outbound client tunnel"); + } + if (nick != null) + title += " (" + nick + ')'; + title += " " + tid + " " + cbuf; + hints.put(KEY_ELEMENT_ID, svgid); + hints.put(KEY_ELEMENT_CLASS, "mapoverlaytunnel dynamic"); + hints.put(KEY_ELEMENT_TITLE, title); + g.drawPolyline(x, y, sz, color, STROKE2, null, hints); + hints.clear(); + if ((mode & MODE_ANIM) != 0) { + hints.put(KEY_ELEMENT_ID, "mapoverlayanim-" + tid); + hints.put(KEY_ELEMENT_CLASS, "mapoverlayanim dynamic"); + // 3 hops is 10 sec + String anim = "\n" + + " \n" + + " "; + hints.put(KEY_ELEMENT_INNERSVG, anim); + // place them off-screen until animation starts + // the js will reset to (0,0) on begin, and remove them on end + drawCircle(g, -5, 0, 5); + hints.clear(); + } + } else { + if (_log.shouldDebug()) + _log.debug("Can't draw tunnel path " + cbuf); + } + hops.clear(); + } + } + + /* + * One line to each country, width = number of tunnels + */ + private void renderParticipating(SimpleSVGMaker g, Mercator mus, ObjectCounterUnsafe tunnels) { + int usx = rotate(mus.x); + int usy = mus.y + IMG_Y_OFF; + int off = 12; + for (String c : tunnels.objects()) { + Mercator m = _mercator.get(c); + if (m == null) + continue; + if (m.equals(mus)) + continue; + int count = tunnels.count(c); + hints.put(KEY_ELEMENT_ID, "part-" + c + '-' + count); + hints.put(KEY_ELEMENT_CLASS, "mapoverlaytunnel dynamic"); + String title = getTranslatedCountry(c) + ": " + ngettext("{0} participating tunnel", "{0} participating tunnels", count); + hints.put(KEY_ELEMENT_TITLE, title); + // shift to 4 corners of box + int mx, my; + int tx = rotate(m.x); + int ty = m.y + IMG_Y_OFF; + if (tx > usx) { + tx -= off; + mx = usx + off; + } else { + tx += off; + mx = usx - off; + } + if (ty > usy) { + ty -= off; + my = usy + off; + } else { + ty += off; + my = usy - off; + } + g.drawLine(tx, ty, mx, my, PART_COLOR, + new BasicStroke(Math.max(3, Math.min(30, 3 * count / 2))), + null, hints); + hints.clear(); + } + } + + private static double getStringWidth(String text, Font font, Graphics2D g) { + return font.getStringBounds(text, 0, text.length(), g.getFontRenderContext()).getBounds().getWidth(); + } + + private static int rotate(int x) { + x += IMG_X_OFF; + if (x < 0) + x += WIDTH; + return x; + } + + /** + * Countries now in a separate bundle + * @param code two-letter country code + */ + private String getTranslatedCountry(String code) { + String name = _context.commSystem().getCountryName(code); + return Translate.getString(name, _context, Messages.COUNTRY_BUNDLE_NAME); + } + + /** translate a string */ + private String _t(String s) { + return Messages.getString(s, _context); + } + + /** + * Read in and parse the mercator country file. + * The file need not be sorted. + * This file was created from the lat/long data at + * https://developers.google.com/public-data/docs/canonical/countries_csv + * using the convertLatLongFile() method below. + */ + private static void readMercatorFile() { + InputStream is = MapMaker.class.getResourceAsStream("/net/i2p/router/web/resources/" + MERCATOR_DEFAULT); + if (is == null) { + System.out.println("Country file not found"); + return; + } + BufferedReader br = null; + try { + br = new BufferedReader(new InputStreamReader(is, "UTF-8")); + String line = null; + while ( (line = br.readLine()) != null) { + try { + if (line.charAt(0) == '#') + continue; + String[] s = DataHelper.split(line, ",", 3); + if (s.length < 3) + continue; + int x = Integer.parseInt(s[1]); + int y = Integer.parseInt(s[2]); + _mercator.put(s[0], new Mercator(x, y)); + } catch (NumberFormatException nfe) { + System.out.println("Bad line " + nfe); + } + } + } catch (IOException ioe) { + System.out.println("Error reading the Country File " + ioe); + } finally { + if (is != null) try { is.close(); } catch (IOException ioe) {} + if (br != null) try { br.close(); } catch (IOException ioe) {} + } + } + + /** translate a string */ + private String ngettext(String s, String p, int n) { + return Messages.getString(n, s, p, _context); + } + + // Following is code to convert the latlong.csv file from Google + // to our mercator.txt file which is bundled in the war. + +/**** + private static final String LATLONG_DEFAULT = "latlong.csv"; + + private static class LatLong { + public final float lat, lon; + public LatLong(float lat, float lon) { + this.lat = lat; this.lon = lon; + } + } +****/ + + /** + * Read in and parse the lat/long file. + * The file need not be sorted. + * Convert the lat/long data from + * https://developers.google.com/public-data/docs/canonical/countries_csv + * to a 1600x1600 web mercator (85 degree) format. + * latlong.csv input format: XX,lat,long,countryname (lat and long are signed floats) + * mercator.txt output format: xx,x,y (x and y are integers 0-1200, not adjusted for a cropped projection) + * Output is sorted by country code. + */ +/*** + private static void convertLatLongFile() { + Map latlong = new HashMap(); + InputStream is = null; + BufferedReader br = null; + try { + is = new FileInputStream(LATLONG_DEFAULT); + br = new BufferedReader(new InputStreamReader(is, "UTF-8")); + String line = null; + while ( (line = br.readLine()) != null) { + try { + if (line.charAt(0) == '#') + continue; + String[] s = DataHelper.split(line, ",", 4); + if (s.length < 3) + continue; + String lc = s[0].toLowerCase(Locale.US); + float lat = Float.parseFloat(s[1]); + float lon = Float.parseFloat(s[2]); + latlong.put(lc, new LatLong(lat, lon)); + } catch (NumberFormatException nfe) { + System.out.println("Bad line " + nfe); + } + } + } catch (IOException ioe) { + System.out.println("Error reading the Country File " + ioe); + } finally { + if (is != null) try { is.close(); } catch (IOException ioe) {} + if (br != null) try { br.close(); } catch (IOException ioe) {} + } + Map mercator = new TreeMap(); + for (Map.Entry e : latlong.entrySet()) { + String c = e.getKey(); + LatLong ll = e.getValue(); + mercator.put(c, convert(ll)); + } + for (Map.Entry e : mercator.entrySet()) { + String c = e.getKey(); + Mercator m = e.getValue(); + System.out.println(c + ',' + m.x + ',' + m.y); + } + } +****/ + + /** + * https://stackoverflow.com/questions/57322997/convert-geolocation-to-pixels-on-a-mercator-projection-image + */ +/**** + private static Mercator convert(LatLong latlong) { + double rad = latlong.lat * Math.PI / 180; + double mercn = Math.log(Math.tan((Math.PI / 4) + (rad / 2))); + double x = (latlong.lon + 180d) * (WIDTH / 360d); + double y = (HEIGHT / 2d) - ((WIDTH * mercn) / (2 * Math.PI)); + return new Mercator((int) Math.round(x), (int) Math.round(y)); + } + + public static void main(String args[]) { + convertLatLongFile(); + } +****/ +} diff --git a/apps/routerconsole/java/src/net/i2p/router/web/helpers/NetDbRenderer.java b/apps/routerconsole/java/src/net/i2p/router/web/helpers/NetDbRenderer.java index 1ce5ceb59..161b2bf63 100644 --- a/apps/routerconsole/java/src/net/i2p/router/web/helpers/NetDbRenderer.java +++ b/apps/routerconsole/java/src/net/i2p/router/web/helpers/NetDbRenderer.java @@ -10,6 +10,9 @@ package net.i2p.router.web.helpers; import java.io.File; import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; import java.io.Serializable; import java.io.Writer; import java.math.BigInteger; // debug @@ -58,6 +61,7 @@ import net.i2p.util.Addresses; import net.i2p.util.ConvertToHash; import net.i2p.util.Log; import net.i2p.util.ObjectCounterUnsafe; +import net.i2p.util.SystemVersion; import net.i2p.util.Translate; import net.i2p.util.VersionComparator; @@ -1076,6 +1080,7 @@ class NetDbRenderer { /** * @param mode 0: charts only; 1: full routerinfos; 2: abbreviated routerinfos * mode 3: Same as 0 but sort countries by count + * Codes greater than 16 are map codes * 16 */ public void renderStatusHTML(Writer out, int pageSize, int page, int mode) throws IOException { if (!_context.netDb().isInitialized()) { @@ -1184,7 +1189,28 @@ class NetDbRenderer { // the summary table buf.append(""); + if (!SystemVersion.isSlow() && !_context.commSystem().isDummy()) { + // svg inline part 1 + out.append(buf); + buf.setLength(0); + buf.append(""); + out.append(buf); + } + buf.setLength(0); + } + mode &= 0x0f; + buf.append("
"); buf.append(_t("Network Database Router Statistics")); - buf.append("
"); + buf.append("
"); + boolean ok = embedResource(buf, "mapbase75p1.svg"); + if (ok) { + out.append(buf); + buf.setLength(0); + // overlay + MapMaker mm = new MapMaker(_context); + out.write(mm.render(mode >> 4)); + // svg inline part 2 + embedResource(buf, "mapbase75p2.svg"); + buf.append("
"); // versions table List versionList = new ArrayList(versions.objects()); if (!versionList.isEmpty()) { @@ -1288,6 +1314,30 @@ class NetDbRenderer { out.flush(); } + /** + * @return success + * @since 0.9.66 + */ + private boolean embedResource(StringBuilder buf, String rsc) { + InputStream is = this.getClass().getResourceAsStream("/net/i2p/router/web/resources/" + rsc); + if (is == null) + return false; + Reader br = null; + try { + br = new InputStreamReader(is, "UTF-8"); + char[] c = new char[4096]; + int read; + while ( (read = br.read(c)) >= 0) { + buf.append(c, 0, read); + } + } catch (IOException ioe) { + return false; + } finally { + if (br != null) try { br.close(); } catch (IOException ioe) {} + } + return true; + } + /** * Countries now in a separate bundle * @param code two-letter country code diff --git a/apps/routerconsole/jsp/css.jsi b/apps/routerconsole/jsp/css.jsi index 075974332..cfacd2f56 100644 --- a/apps/routerconsole/jsp/css.jsi +++ b/apps/routerconsole/jsp/css.jsi @@ -42,7 +42,11 @@ response.setHeader("X-Frame-Options", "SAMEORIGIN"); // unsafe-inline is a fallback for browsers not supporting nonce // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src - response.setHeader("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'nonce-" + cspNonce + "'; form-action 'self'; frame-ancestors 'self'; object-src 'none'; media-src 'none'"); + // we need unsafe-inline for the /netdb SVG + if ("/netdb.jsp".equals(request.getServletPath())) + response.setHeader("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; form-action 'self'; frame-ancestors 'self'; object-src 'none'; media-src 'none'"); + else + response.setHeader("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'nonce-" + cspNonce + "'; form-action 'self'; frame-ancestors 'self'; object-src 'none'; media-src 'none'"); } response.setHeader("X-XSS-Protection", "1; mode=block"); response.setHeader("X-Content-Type-Options", "nosniff"); diff --git a/apps/routerconsole/jsp/js/ajaxchanges.js b/apps/routerconsole/jsp/js/ajaxchanges.js new file mode 100644 index 000000000..b2284e812 --- /dev/null +++ b/apps/routerconsole/jsp/js/ajaxchanges.js @@ -0,0 +1,88 @@ +/* @license http://creativecommons.org/publicdomain/zero/1.0/legalcode CC0-1.0 */ + +// This component is dedicated to the public domain. It uses the CC0 +// as a formal dedication to the public domain and in circumstances where +// a public domain is not usable. + +var __ajaxchanges_fails = 0; + +/** + * + * Add/remove elements of changeclass that are inside target. + * All elements of the changeclass must have unique ids. + * All elements with the same id are assumed unchanged. + * + */ +function ajaxchanges(url, target, changeclass, refresh) { + // native XMLHttpRequest object + if (window.XMLHttpRequest) { + var req = new XMLHttpRequest(); + req.onreadystatechange = function() {ajaxchangesDone(req, url, target, changeclass, refresh);}; + req.open("GET", url, true); + req.setRequestHeader("If-Modified-Since","Sat, 1 Jan 2000 00:00:00 GMT"); + req.send(null); + } else if (window.ActiveXObject) { + var req = new ActiveXObject("Microsoft.XMLDOM"); + if (req) { + req.onreadystatechange = function() {ajaxchangesDone(target);}; + req.open("GET", url, true); + req.setRequestHeader("If-Modified-Since","Sat, 1 Jan 2000 00:00:00 GMT"); + req.send(null); + } + } +} + +/** + * + * Add/remove elements of changeclass that are inside target. + * All elements of the changeclass must have unique ids. + * All elements with the same id are assumed unchanged. TODO another class for changes + * + */ +function ajaxchangesDone(req, url, target, changeclass, refresh) { + if (req.readyState == 4) { + if (req.status == 200) { + __ajax_fails = 0; + const results = req.responseXML; + const oldtgt = document.getElementById(target); + const newtgt = results.getElementById(target); + const oldelements = oldtgt.getElementsByClassName(changeclass); + const newelements = newtgt.getElementsByClassName(changeclass); + //var added = 0; + //var removed = 0; + // remove old ones not in new + for (var i = 0; i < oldelements.length; i++) { + let e = oldelements[i]; + let id = e.id; + let e2 = newtgt.getElementById(id); + if (e2 == null) { + //console.warn("Removing " + id); + e.remove(); + //removed++; + } + } + // add new ones not in old + for (var i = 0; i < newelements.length; i++) { + let e = newelements[i]; + let id = e.id; + let e2 = oldtgt.getElementById(id); + if (e2 == null) { + //console.warn("Adding " + id); + oldtgt.appendChild(e); + //added++; + } + } + //console.warn("Added " + added + " removed " + removed); + } else if (__ajaxchanges_fails == 0) { + __ajaxchanges_fails++; + } else { + document.getElementById(target).innerHTML = failMessage; + } + + if (refresh > 0) { + setTimeout(function() {ajaxchanges(url, target, changeclass, refresh);}, refresh); + } + } +} + +/* @license-end */ diff --git a/apps/routerconsole/jsp/js/map.js b/apps/routerconsole/jsp/js/map.js new file mode 100644 index 000000000..2ae0eb848 --- /dev/null +++ b/apps/routerconsole/jsp/js/map.js @@ -0,0 +1,54 @@ +/* */ + +function initMap() { + drawTunnels(); + setTimeout(updateMap, 60 * 1000); +} + +function updateMap() { + var url = "/viewmap.jsp"; + const urlParams = new URLSearchParams(window.location.search); + const f = urlParams.get('f'); + if (f != null) + url += "?f=" + f; + ajaxchanges(url, "mapoverlaysvg", "dynamic", 60*1000); +} + +function drawTunnels() { + var paths = document.getElementsByClassName("mapoverlaytunnel"); + for (var i = 0; i < paths.length; i++) { + drawTunnel(paths[i]); + } +} + +function drawTunnel(path) { + // https://jakearchibald.com/2013/animated-line-drawing-svg/ + var len = path.getTotalLength(); + path.style.strokeDasharray = len + ' ' + len; + path.style.strokeDashoffset = len; + path.getBoundingClientRect(); + path.style.transition = 'stroke-dashoffset 5s ease-in-out'; + path.style.strokeDashoffset = '0'; +} + +function beginCircleAnim(circleid) { + // move onscreen + let circle = document.getElementById(circleid); + if (circle != null) { + circle.setAttribute('cx', '0'); + } +} + +function endCircleAnim(circleid) { + // remove it + let circle = document.getElementById(circleid); + if (circle != null) { + // move them offscreen, the first ajaxchanges will remove them + //circle.remove(); + circle.setAttribute('cx', '-5'); + } +} + +document.addEventListener("DOMContentLoaded", function() { + initMap(); +}); diff --git a/apps/routerconsole/jsp/netdb.jsp b/apps/routerconsole/jsp/netdb.jsp index 1c61de548..a96e1d6a0 100644 --- a/apps/routerconsole/jsp/netdb.jsp +++ b/apps/routerconsole/jsp/netdb.jsp @@ -6,6 +6,8 @@ <%@include file="css.jsi" %> <%=intl.title("network database")%> <%@include file="summaryajax.jsi" %> + + <%@include file="summary.jsi" %>

<%=intl._t("I2P Network Database")%>

diff --git a/apps/routerconsole/jsp/themes/console/dark/console.css b/apps/routerconsole/jsp/themes/console/dark/console.css index 55ef87922..3794a27e5 100644 --- a/apps/routerconsole/jsp/themes/console/dark/console.css +++ b/apps/routerconsole/jsp/themes/console/dark/console.css @@ -4666,6 +4666,15 @@ table#netdboverview { margin-bottom: 10px; } +#geomap { + width: 100%; +} + +path.mapoverlaytunnel:hover { + stroke: #ff33cc; + stroke-width: 7; +} + #netdboverview td { padding: 0; } diff --git a/apps/routerconsole/jsp/themes/console/light/console.css b/apps/routerconsole/jsp/themes/console/light/console.css index 6863477e2..b4553e0d4 100644 --- a/apps/routerconsole/jsp/themes/console/light/console.css +++ b/apps/routerconsole/jsp/themes/console/light/console.css @@ -5933,6 +5933,15 @@ table#leasesetsummary th a:hover { padding: 8px 5px 8px 32px; } +#geomap { + width: 100%; +} + +path.mapoverlaytunnel:hover { + stroke: #ff33cc; + stroke-width: 7; +} + #netdblookup th { text-transform: uppercase; font-size: 11pt !important; diff --git a/apps/routerconsole/jsp/viewmap.jsp b/apps/routerconsole/jsp/viewmap.jsp new file mode 100644 index 000000000..398f1e459 --- /dev/null +++ b/apps/routerconsole/jsp/viewmap.jsp @@ -0,0 +1,28 @@ +<% +/* + * USE CAUTION WHEN EDITING + * Trailing whitespace OR NEWLINE on the last line will cause + * IllegalStateExceptions !!! + * + * Do not tag this file for translation. + */ + response.setContentType("image/svg+xml"); + response.setCharacterEncoding("UTF-8"); + response.setHeader("Content-Disposition", "inline; filename=\"i2pmap.svg\""); + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Accept-Ranges", "none"); + response.setHeader("Connection", "Close"); + java.io.OutputStream cout = response.getOutputStream(); + net.i2p.router.web.helpers.MapMaker mm = new net.i2p.router.web.helpers.MapMaker(); + // don't include animations on updates, because the browser doesn't render them + int mode = 7; + String f = request.getParameter("f"); + if (f != null) + mode = Integer.parseInt(f) >> 4; + boolean rendered = mm.render(mode, cout); + + if (rendered) + cout.close(); + else + response.sendError(403, "Map not available"); +%> \ No newline at end of file diff --git a/apps/routerconsole/resources/mapbase75p1.svg b/apps/routerconsole/resources/mapbase75p1.svg new file mode 100644 index 000000000..9e62b1015 --- /dev/null +++ b/apps/routerconsole/resources/mapbase75p1.svg @@ -0,0 +1,244 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/routerconsole/resources/mapbase75p2.svg b/apps/routerconsole/resources/mapbase75p2.svg new file mode 100644 index 000000000..39ceda262 --- /dev/null +++ b/apps/routerconsole/resources/mapbase75p2.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/apps/routerconsole/resources/mercator.txt b/apps/routerconsole/resources/mercator.txt new file mode 100644 index 000000000..e14327821 --- /dev/null +++ b/apps/routerconsole/resources/mercator.txt @@ -0,0 +1,251 @@ +# +# Country coordinates on a square 1600x1600 Mercator projection +# This file was created from the lat/long data at +# https://developers.google.com/public-data/docs/canonical/countries_csv +# License: CC-BY 4.0 +# https://creativecommons.org/licenses/by/4.0/ +# +ad,807,591 +ae,1039,693 +af,1101,639 +ag,525,723 +ai,520,718 +al,890,599 +am,1000,605 +an,493,745 +ao,879,850 +aq,800,1321 +ar,517,985 +as,44,864 +at,865,559 +au,1395,916 +aw,489,744 +az,1011,605 +ba,879,582 +bb,535,741 +bd,1202,692 +be,820,539 +bf,793,745 +bg,913,590 +bh,1025,681 +bi,933,815 +bj,810,758 +bm,512,648 +bn,1310,780 +bo,517,873 +br,569,864 +bs,456,685 +bt,1202,673 +bv,815,1089 +bw,910,902 +by,924,516 +bz,407,722 +ca,327,497 +cc,1231,854 +cd,897,818 +cf,893,771 +cg,870,801 +ch,837,564 +ci,775,766 +ck,90,897 +cl,482,970 +cm,855,767 +cn,1263,629 +co,470,780 +cr,428,756 +cu,454,702 +cv,693,728 +cx,1270,847 +cy,949,633 +cz,869,544 +de,846,534 +dj,989,747 +dk,842,496 +dm,527,731 +do,488,715 +dz,807,670 +ec,453,808 +ee,911,477 +eg,937,676 +eh,743,689 +er,977,732 +es,783,603 +et,980,759 +fi,914,447 +fj,1597,875 +fk,535,1070 +fm,1469,767 +fo,769,447 +fr,810,568 +ga,852,804 +gb,785,503 +gd,526,745 +ge,993,592 +gf,564,783 +gg,789,546 +gh,795,765 +gi,776,628 +gl,611,335 +gm,732,740 +gn,757,756 +gp,524,723 +gq,846,793 +gr,897,611 +gs,637,1090 +gt,399,729 +gu,1444,740 +gw,733,747 +gy,538,778 +gz,952,653 +hk,1307,698 +hm,1127,1079 +hn,417,732 +hr,868,575 +ht,479,714 +hu,887,562 +id,1306,804 +ie,763,518 +il,955,655 +im,780,512 +in,1151,706 +io,1119,828 +iq,994,643 +ir,1039,648 +is,715,417 +it,856,595 +je,791,548 +jm,456,718 +jo,961,657 +jp,1414,627 +ke,968,800 +kg,1132,599 +kh,1267,744 +ki,50,815 +km,995,853 +kn,521,722 +kp,1367,604 +kr,1368,629 +kw,1011,664 +ky,442,712 +kz,1097,556 +la,1256,710 +lb,959,640 +lc,529,738 +li,842,562 +lk,1159,765 +lr,758,771 +ls,925,938 +lt,906,505 +lu,827,544 +lv,909,491 +ly,877,679 +ma,768,651 +mc,833,583 +md,926,560 +me,886,590 +mg,1008,885 +mh,1561,768 +mk,897,596 +ml,782,721 +mm,1226,700 +mn,1262,564 +mo,1305,699 +mp,1446,722 +mq,529,734 +mr,751,704 +ms,524,725 +mt,864,629 +mu,1056,892 +mv,1125,786 +mw,952,859 +mx,344,692 +my,1253,781 +mz,958,884 +na,882,905 +nc,1536,895 +ne,836,720 +nf,1546,935 +ng,839,759 +ni,421,742 +nl,824,528 +no,838,460 +np,1174,668 +nr,1542,802 +nu,45,886 +nz,1577,1000 +om,1049,702 +pa,441,762 +pe,467,841 +pf,136,880 +pg,1440,828 +ph,1341,742 +pk,1108,658 +pl,885,529 +pm,550,563 +pn,234,913 +pr,504,718 +ps,957,650 +pt,763,609 +pw,1398,767 +py,540,907 +qa,1027,683 +re,1047,896 +ro,911,570 +rs,893,582 +ru,1268,451 +rw,933,809 +sa,1000,691 +sb,1512,843 +sc,1047,821 +sd,934,742 +se,883,463 +sg,1261,794 +sh,755,911 +si,867,568 +sj,905,236 +sk,888,552 +sl,748,762 +sm,855,582 +sn,736,735 +so,1005,777 +sr,551,783 +st,829,799 +sv,405,738 +sy,973,635 +sz,940,922 +tc,481,701 +td,883,730 +tf,1108,1052 +tg,804,762 +th,1249,729 +tj,1117,612 +tk,36,840 +tl,1359,840 +tm,1065,612 +tn,842,640 +to,21,896 +tr,957,612 +tt,528,752 +tv,1590,832 +tw,1338,692 +tz,955,828 +ua,939,554 +ug,944,794 +us,375,622 +uy,552,953 +uz,1087,598 +va,855,595 +vc,528,742 +ve,504,771 +vg,513,717 +vi,512,717 +vn,1281,737 +vu,1542,869 +wf,13,862 +ws,35,862 +xk,893,590 +ye,1016,730 +yt,1001,857 +za,902,943 +zm,924,859 +zw,930,886