/* * CatSaver * Copyright (C) 2015 HiHex Ltd. * * 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 hihex.cs; import android.content.pm.ApplicationInfo; import android.content.pm.PackageManager; import android.content.res.AssetManager; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; import android.graphics.Typeface; import android.graphics.drawable.Drawable; import android.net.Uri; import com.google.common.base.Functions; import com.google.common.base.Optional; import com.google.common.eventbus.Subscribe; import com.google.common.io.ByteStreams; import com.google.gson.JsonSyntaxException; import java.io.BufferedOutputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.EOFException; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.Writer; import java.net.URLDecoder; import java.util.ArrayList; import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.StringTokenizer; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; import java.util.zip.GZIPInputStream; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import fi.iki.elonen.NanoHTTPD; /** * The HTTP server that allows testers to download the crash logs. */ final class WebServer extends NanoHTTPD { public static final int PORT = 47689; // Perhaps we should let the OS choose the port? private final Config mConfig; private final Drawable mCatSaverIcon; private final Paint mFaviconFillPaint; private final Paint mFaviconOutlinePaint; private byte[] mFaviconPng = {}; public WebServer(final Config config, final Drawable catSaverIcon) { super(PORT); mConfig = config; mCatSaverIcon = catSaverIcon; Events.bus.register(this); { final Paint paint = new Paint(); paint.setTextAlign(Paint.Align.RIGHT); paint.setColor(Color.BLACK); paint.setTextSize(12); paint.setTypeface(Typeface.SANS_SERIF); paint.setAntiAlias(true); paint.setTextScaleX(1.17f); paint.setStyle(Paint.Style.FILL); mFaviconFillPaint = paint; } { final Paint paint = new Paint(mFaviconFillPaint); paint.setStyle(Paint.Style.STROKE); paint.setStrokeWidth(5); paint.setColor(Color.WHITE); mFaviconOutlinePaint = paint; } } private static String getMimeType(final String path) { final int lastDot = path.lastIndexOf('.'); if (lastDot >= 0) { final String extension = path.substring(lastDot + 1); switch (extension) { case "png": return "image/png"; case "ttf": return "application/font-sfnt"; case "css": return "text/css"; case "js": return "text/javascript"; default: break; } } return "application/octet-stream"; } @Override public Response serve(final IHTTPSession session) { final Uri uri = Uri.parse(session.getUri()); final String path = uri.getPath(); switch (path) { case "/": return serveIndex(); case "/favicon.ico": return serveFavicon(); case "/settings": return serveSettings(); case "/filters": return serveFilters(); case "/live": return serveLive(); case "/live-events": return serveLiveEvents(); case "/update-settings": { try { session.parseBody(new HashMap<String, String>()); return updateSettings(session.getParms()); } catch (final IOException | ResponseException e) { return serve404(); } } case "/update-filter-settings": { try { session.parseBody(new HashMap<String, String>()); return updateFilterSettings(session.getParms()); } catch (final IOException | ResponseException e) { return serve404(); } } case "/bulk": try { session.parseBody(new HashMap<String, String>()); // We can't really use the map because there will be multiple "file-selector"s. But we cannot pass // a HashMultimap above. So we need to parse the query string ourselves. The query string won't be // available without calling parseBody() though, so the statement above still exists. final StringTokenizer tokenizer = new StringTokenizer(session.getQueryParameterString(), "&"); final ArrayList<String> files = new ArrayList<>(); String action = ""; // We parse the string ourselves instead of using Uri.parse(), because the latter does not properly // translate '+' back to spaces. while (tokenizer.hasMoreTokens()) { final String token = tokenizer.nextToken(); final int sep = token.indexOf('='); final String key; final String value; if (sep >= 0) { key = URLDecoder.decode(token.substring(0, sep), "UTF-8"); value = URLDecoder.decode(token.substring(sep + 1), "UTF-8"); } else { key = URLDecoder.decode(token, "UTF-8"); value = ""; } switch (key) { case "file-selector": files.add(value); break; case "action": action = value; break; default: break; } } switch (action) { case "delete": return bulkDelete(files); case "download": return bulkDownload(files); default: break; } } catch (final IOException | ResponseException e) { CsLog.e("Bulk action failed", e); } return serve404(); default: if (path.length() <= 1) { return serve404(); } break; } final int secondSlash = path.indexOf('/', 1); if (secondSlash < 0) { return serve404(); } final String action = path.substring(1, secondSlash); final String filename = path.substring(secondSlash + 1); switch (action) { case "static": if ("favicon.png".equals(filename)) { return serveFavicon(); } else { return serveStatic(filename); } case "read": return serveLog(filename); case "download": return serveDownload(filename); case "apk": return serveApk(filename); case "stop": return stopPid(filename); case "start": return startPid(filename); case "delete": return deleteLog(filename); default: return serve404(); } } private Response serveIndex() { mConfig.refreshPids(); final String content = mConfig.renderer.renderIndex(mConfig.logFiles, mConfig.pidDatabase); return new Response(content); } private Response serveStatic(final String filename) { final AssetManager assets = mConfig.context.getAssets(); try { final InputStream stream = assets.open("static/" + filename); final String mimeType = getMimeType(filename); return new Response(Response.Status.OK, mimeType, stream); } catch (final IOException e) { return serve404(); } } private Response serveLog(final String filename) { mConfig.flushWriter(filename); try { final InputStream stream = mConfig.logFiles.open(filename); final Response resp = new Response(Response.Status.OK, MIME_HTML, stream); resp.addHeader("Content-Encoding", "gzip"); return resp; } catch (final IOException e) { return serve404(); } } private Response serveDownload(final String filename) { try { final InputStream stream = mConfig.logFiles.open(filename); return serveFileDownload(filename, "application/x-gzip", stream); } catch (final IOException e) { return serve404(); } } private Response serveRedirect(final String message, final String destination) { final Response resp = new Response(Response.Status.REDIRECT, MIME_PLAINTEXT, message); resp.addHeader("Refresh", "0; url=" + destination); return resp; } private Response stopPid(final String pidString) { final int pid = Integer.parseInt(pidString); mConfig.stopRecording(pid, Functions.<Writer>identity()); return serveRedirect("Recording stopped: " + pidString, "/"); } private Response startPid(final String pidString) { final int pid = Integer.parseInt(pidString); try { mConfig.startRecording(pid, Optional.<String>absent(), new Date()); } catch (final IOException e) { CsLog.e("Cannot start recording", e); } return serveRedirect("Recording started: " + pidString, "/"); } private Response deleteLog(final String fileName) { mConfig.logFiles.delete(fileName); return serveRedirect("File deleted: " + fileName, "/"); } private Response serveApk(final String packageName) { final PackageManager packageManager = mConfig.context.getPackageManager(); try { final ApplicationInfo info = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA); final String apkPath = info.sourceDir; final FileInputStream stream = new FileInputStream(apkPath); return serveFileDownload(packageName + ".apk", "application/vnd.android.package-archive", stream); } catch (final PackageManager.NameNotFoundException e) { return serve404(); } catch (final FileNotFoundException e) { return new Response(Response.Status.FORBIDDEN, MIME_PLAINTEXT, "Cannot download"); } } private Response serveSettings() { final Preferences preferences = mConfig.preferences; final String content = mConfig.renderer.renderSettings(preferences); return new Response(content); } private Response updateSettings(final Map<String, String> parameters) { final String filterString = parameters.get("filter"); if (filterString == null) { return serveInvalidSettingError("settings", "Missing filter", null); } try { final Pattern filter = Pattern.compile(filterString); final long filesize; final long duration; final long splitSize; final boolean shouldShowIndictor = "on".equals(parameters.get("show-indicator")); final boolean shouldRunOnBoot = "on".equals(parameters.get("run-on-boot")); if ("on".equals(parameters.get("purge-by-filesize"))) { filesize = Math.max(1, Long.parseLong(parameters.get("filesize")) * 1048576); } else { filesize = -1; } if ("on".equals(parameters.get("purge-by-date"))) { duration = Math.max(1, Long.parseLong(parameters.get("date")) * 86400000); } else { duration = -1; } if ("on".equals(parameters.get("split-size-enabled"))) { splitSize = Math.max(1, Long.parseLong(parameters.get("split-size")) * 1024); } else { splitSize = -1; } mConfig.preferences.updateSettings(filter, filesize, duration, shouldShowIndictor, shouldRunOnBoot, splitSize); return serveRedirect("Settings updated", "/"); } catch (final PatternSyntaxException e) { return serveInvalidSettingError("settings", "Invalid filter syntax", e); } catch (final NumberFormatException e) { return serveInvalidSettingError("settings", "Invalid number", e); } } private Response updateFilterSettings(final Map<String, String> parameters) { final String filterString = parameters.get("filter"); if (filterString == null) { return serveInvalidSettingError("filters", "Missing filter", null); } final boolean hasDefaultLogFilter = "on".equals(parameters.get("log-filter-use-default")); final String logFilter = hasDefaultLogFilter ? null : parameters.get("log-filters"); try { final Pattern filter = Pattern.compile(filterString); mConfig.preferences.updateFilters(filter, logFilter); return serveRedirect("Filters updated", "/"); } catch (final PatternSyntaxException e) { return serveInvalidSettingError("filters", "Invalid filter regular expression", e); } catch (final IllegalStateException e) { return serveInvalidSettingError("filters", "Invalid TOML syntax", e); } catch (final JsonSyntaxException e) { return serveInvalidSettingError("filters", "Invalid TOML type", e); } } private Response serveFilters() { final Preferences preferences = mConfig.preferences; final String content = mConfig.renderer.renderFilterSettings(preferences); return new Response(content); } private Response bulkDownload(final List<String> files) throws IOException { final File zipFile = File.createTempFile("CatSaverLogs", ".zip"); try { final ZipOutputStream stream = new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(zipFile))); stream.putNextEntry(new ZipEntry("CatSaverLogs/")); for (final String fileName : files) { final InputStream source = mConfig.logFiles.open(fileName); final InputStream input; final ZipEntry entry; if (fileName.endsWith(".gz")) { entry = new ZipEntry("CatSaverLogs/" + fileName.substring(0, fileName.length() - 3)); input = new GZIPInputStream(source); } else { entry = new ZipEntry("CatSaverLogs/" + fileName); input = source; } stream.putNextEntry(entry); try { ByteStreams.copy(input, stream); } catch (final EOFException e) { // Early EOF, ignore? } input.close(); stream.closeEntry(); } stream.close(); final FileInputStream result = new FileInputStream(zipFile); return serveFileDownload(zipFile.getName(), "application/zip", result); } finally { zipFile.delete(); } } private Response bulkDelete(final List<String> files) { for (final String fileName : files) { mConfig.logFiles.delete(fileName); } return serveRedirect("Deleted " + files.size() + " files", "/"); } private Response serve404() { return new Response(Response.Status.NOT_FOUND, MIME_PLAINTEXT, "Not found"); } private Response serveInvalidSettingError(final String source, final String title, final Throwable e) { final String content = mConfig.renderer.renderSettingsError(source, title, e); return new Response(Response.Status.BAD_REQUEST, MIME_HTML, content); } private Response serveFileDownload(final String fileName, final String mimeType, final InputStream stream) { final Response resp = new Response(Response.Status.OK, mimeType, stream); resp.addHeader("Content-Disposition", "attachment; filename=" + fileName); return resp; } private Response serveFavicon() { final ByteArrayInputStream stream = new ByteArrayInputStream(mFaviconPng); return new Response(Response.Status.OK, "image/png", stream); } private Response serveLive() { final String content = mConfig.renderer.renderLive(); return new Response(content); } private Response serveLiveEvents() { return new LiveEventSource(); } @Subscribe public void refreshFavicon(final Events.UpdateIpAddress ipValue) { final Bitmap bitmap = Bitmap.createBitmap(36, 36, Bitmap.Config.ARGB_8888); final Canvas canvas = new Canvas(bitmap); mCatSaverIcon.setBounds(0, 0, 36, 36); mCatSaverIcon.draw(canvas); final String ipSegment = IpAddresses.extractLastPart(ipValue.ipAddress); canvas.drawText(ipSegment, 36, 34, mFaviconOutlinePaint); canvas.drawText(ipSegment, 36, 34, mFaviconFillPaint); final ByteArrayOutputStream stream = new ByteArrayOutputStream(); bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream); mFaviconPng = stream.toByteArray(); } }