/* * Visage * Copyright (c) 2015-2016, Aesen Vismea <aesen@unascribed.com> * * The MIT License * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ package com.surgeplay.visage.master; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.EnumSet; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; import java.util.logging.Level; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.UUID; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import joptsimple.internal.Strings; import org.eclipse.jetty.server.Request; import org.eclipse.jetty.server.handler.AbstractHandler; import org.isomorphism.util.TokenBucket; import org.isomorphism.util.TokenBuckets; import org.spacehq.mc.auth.GameProfile; import org.spacehq.mc.auth.GameProfileRepository; import org.spacehq.mc.auth.ProfileLookupCallback; import org.spacehq.mc.auth.ProfileTexture; import org.spacehq.mc.auth.ProfileTextureType; import org.spacehq.mc.auth.SessionService; import org.spacehq.mc.auth.exception.ProfileNotFoundException; import org.spacehq.mc.auth.properties.PropertyMap; import org.spacehq.mc.auth.serialize.GameProfileSerializer; import org.spacehq.mc.auth.serialize.PropertyMapSerializer; import org.spacehq.mc.auth.serialize.UUIDSerializer; import org.spacehq.mc.auth.util.URLUtils; import redis.clients.jedis.Jedis; import com.google.common.base.Charsets; import com.google.common.collect.Lists; import com.google.common.collect.Maps; import com.google.common.io.ByteStreams; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.surgeplay.visage.RenderMode; import com.surgeplay.visage.Visage; import com.surgeplay.visage.util.Profiles; public class VisageHandler extends AbstractHandler { public static final Pattern URL_WITH_MODE_PATTERN = Pattern.compile("^/([A-Za-z]*?)/([A-Za-z0-9_]*|X-Steve|X-Alex)(?:\\.png)?$"); public static final Pattern URL_WITH_SIZE_AND_MODE_PATTERN = Pattern.compile("^/([A-Za-z]*?)/([0-9]+)/([A-Za-z0-9_]*|X-Steve|X-Alex)(?:\\.png)?$"); public static final Pattern USERNAME_PATTERN = Pattern.compile("^[A-Za-z0-9_]{1,16}$"); public static final Pattern DASHLESS_UUID_PATTERN = Pattern.compile("^([A-Fa-f0-9]{8})([A-Fa-f0-9]{4})([A-Fa-f0-9]{4})([A-Fa-f0-9]{4})([A-Fa-f0-9]{12})$"); private static final long ONE_DAY = 1000 * 60 * 60 * 24; private static final long THIRTY_DAYS = ONE_DAY * 30; private final VisageMaster master; private final SessionService ss = new SessionService(); private final GameProfileRepository gpr = new GameProfileRepository(); private final Gson gson = new GsonBuilder() .registerTypeAdapter(GameProfile.class, new GameProfileSerializer()) .registerTypeAdapter(PropertyMap.class, new PropertyMapSerializer()) .registerTypeAdapter(UUID.class, new UUIDSerializer()) .create(); private final boolean cacheHeader, slaveHeader, reportExceptions, usernames; private final int supersampling, minSize, defaultSize, maxSize, maxAttempts, granularity; private final long resolverTtlMillis, skinTtlMillis; private final String baseUrl; private final EnumSet<RenderMode> allowedModes = EnumSet.noneOf(RenderMode.class); private final String allowedModesS; private final boolean doRatelimit; private final int defaultBurst; private final double defaultRate; private final int relaxBurst; private final double relaxRate; private final Map<String, List<Pattern>> whitelisted = Maps.newHashMap(); private final Map<String, List<Pattern>> relaxed = Maps.newHashMap(); private Map<String, TokenBucket> tokenBuckets = Maps.newHashMap(); public VisageHandler(VisageMaster master) { this.master = master; List<String> debug = master.config.getStringList("debug"); slaveHeader = debug.contains("slave"); cacheHeader = debug.contains("cache"); reportExceptions = debug.contains("error"); if (slaveHeader || cacheHeader) { Visage.log.warning("Visage is set to include debugging information in HTTP headers. This should be disabled in production."); } if (reportExceptions) { Visage.log.warning("Visage is set to include exception stack traces in failed requests. This can expose internal system information such as authentication information."); } usernames = master.config.getBoolean("lookup-names"); supersampling = master.config.getInt("render.supersampling"); minSize = master.config.getInt("render.min-size"); defaultSize = master.config.getInt("render.default-size"); maxSize = master.config.getInt("render.max-size"); maxAttempts = master.config.getInt("render.tries"); granularity = master.config.getInt("render.size-granularity"); resolverTtlMillis = master.config.getDuration("redis.resolver-ttl", TimeUnit.MILLISECONDS); skinTtlMillis = master.config.getDuration("redis.skin-ttl", TimeUnit.MILLISECONDS); baseUrl = master.config.getString("base-url"); doRatelimit = master.config.getBoolean("ratelimit.enabled"); defaultBurst = master.config.getInt("ratelimit.burst"); defaultRate = master.config.getDouble("ratelimit.rate"); relaxBurst = master.config.getInt("ratelimit.relaxed-burst"); relaxRate = master.config.getDouble("ratelimit.relaxed-rate"); List<String> modes = master.config.getStringList("modes"); for (String s : modes) { try { allowedModes.add(RenderMode.valueOf(s.toUpperCase())); } catch (IllegalArgumentException ignore) {} } allowedModesS = Strings.join(modes, ", "); compileList(master.config.getStringList("ratelimit.whitelist"), whitelisted); compileList(master.config.getStringList("ratelimit.relaxed"), relaxed); } private void compileList(List<String> in, Map<String, List<Pattern>> out) { for (String s : in) { int idx = s.indexOf(':'); String kind = (idx == -1 ? "ip" : s.substring(0, idx)); String key = s.substring(idx+1); // -1 + 1 = 0 List<Pattern> li = out.get(kind); if (li == null) { li = Lists.newArrayList(); out.put(kind, li); } if (kind.equals("ip")) { li.add(Pattern.compile("^"+Pattern.quote(key)+"$")); } else { li.add(Pattern.compile(key)); } } } private boolean ratelimited(String kind, String key) { if (key == null) return false; String tbKey = kind+":"+key; TokenBucket tb; if (tokenBuckets.containsKey(tbKey)) { tb = tokenBuckets.get(tbKey); if (tb == null) return false; } else { double rate = defaultRate; int burst = defaultBurst; List<Pattern> whitelist = whitelisted.get(kind); if (whitelist != null) { for (Pattern p : whitelist) { if (p.matcher(key).matches()) { tokenBuckets.put(tbKey, null); return false; } } } List<Pattern> relax = relaxed.get(kind); if (relax != null) { for (Pattern p : relax) { if (p.matcher(key).matches()) { rate = relaxRate; burst = relaxBurst; } } } tb = TokenBuckets.builder() .withCapacity(burst) .withFixedIntervalRefillStrategy(1, (int)(rate*1000), TimeUnit.MILLISECONDS) .build(); tokenBuckets.put(tbKey, tb); } return !tb.tryConsume(); } @Override public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { baseRequest.setHandled(true); if (!"GET".equals(request.getMethod())) { response.sendError(405); return; } if (doRatelimit) { if (ratelimited("ip", request.getRemoteAddr())) { response.sendRedirect("/2fast.png"); return; } if (ratelimited("referer", request.getHeader("Referer"))) { response.sendRedirect("/2fast.png"); return; } } RenderMode mode = RenderMode.FULL; String subject; final List<String> missed = cacheHeader ? new ArrayList<String>() : null; if (target.contains("-") && !(target.endsWith("X-Steve") || target.endsWith("X-Alex"))) { sendPermanentRedirect(baseUrl+target.replace("-", ""), response); return; } // XXX Regex is probably a slow (and somewhat confusing) way to do this Matcher uwsam = URL_WITH_SIZE_AND_MODE_PATTERN.matcher(target); String modeStr; int size = defaultSize; if (uwsam.matches()) { try { modeStr = uwsam.group(1); if (!allowedModes.contains(mode)) { throw new IllegalArgumentException(); } } catch (IllegalArgumentException e) { response.sendError(400, "Invalid render mode '"+uwsam.group(1)+"' - must be one of "+allowedModesS); return; } try { size = Integer.parseInt(uwsam.group(2)); } catch (NumberFormatException e) { response.sendError(400, "Invalid size '"+uwsam.group(2)+"' - must be a decimal integer"); return; } subject = uwsam.group(3); } else { Matcher uwm = URL_WITH_MODE_PATTERN.matcher(target); if (uwm.matches()) { modeStr = uwm.group(1); subject = uwm.group(2); } else { response.sendError(404); return; } } int width = size; int height = size; try { if ("PLAYER".equalsIgnoreCase(modeStr)) { height *= 1.625; sendPermanentRedirect(baseUrl+"/full/"+height+"/"+subject, response); return; } else if ("FULL".equalsIgnoreCase(modeStr) || "FRONTFULL".equalsIgnoreCase(modeStr)) { width = (int)Math.ceil(width / 1.625f); } else if ("HELM".equalsIgnoreCase(modeStr)) { sendPermanentRedirect(baseUrl+"/face/"+height+"/"+subject, response); return; } else if ("PORTRAIT".equalsIgnoreCase(modeStr)) { sendPermanentRedirect(baseUrl+"/bust/"+height+"/"+subject, response); return; } mode = RenderMode.valueOf(modeStr.toUpperCase()); if (!allowedModes.contains(mode)) { throw new IllegalArgumentException(); } } catch (IllegalArgumentException e) { response.sendError(400, "Invalid render mode '"+modeStr+"' - must be one of "+allowedModesS); return; } if (mode == RenderMode.FULL || mode == RenderMode.FRONTFULL) { int clamped = Math.max(minSize, Math.min(height, (int)(maxSize*1.625))); if (clamped != height) { sendPermanentRedirect(baseUrl+"/"+modeStr+"/"+clamped+"/"+subject, response); return; } } else { int clamped = Math.max(minSize, Math.min(height, maxSize)); if (clamped != height) { sendPermanentRedirect(baseUrl+"/"+modeStr+"/"+clamped+"/"+subject, response); return; } } int rounded = Math.round(height / (float) granularity)*granularity; if (rounded != height) { sendPermanentRedirect(baseUrl+"/"+modeStr+"/"+rounded+"/"+subject, response); return; } UUID uuid = null; if (subject.equals("X-Steve")) { uuid = new UUID(0 | (8 << 12), 0); } else if (subject.equals("X-Alex")) { uuid = new UUID(0 | (8 << 12), 1); } else { Matcher dashless = DASHLESS_UUID_PATTERN.matcher(subject); if (dashless.matches()) { uuid = UUID.fromString(dashless.replaceAll("$1-$2-$3-$4-$5")); } else { if (usernames) { Matcher username = USERNAME_PATTERN.matcher(subject); if (username.matches()) { try (Jedis j = master.getResolverJedis();) { String resp = j.get(subject); if (resp != null) { response.sendRedirect(baseUrl+"/"+modeStr+"/"+height+"/"+resp.replace("-", "")); return; } else { if (cacheHeader) missed.add("username"); final Object[] result = new Object[1]; gpr.findProfilesByNames(new String[] {subject}, new ProfileLookupCallback() { @Override public void onProfileLookupSucceeded(GameProfile profile) { result[0] = profile.getId(); } @Override public void onProfileLookupFailed(GameProfile profile, Exception e) { result[0] = e; } }); if (result[0] == null || result[0] instanceof ProfileNotFoundException) { response.sendError(400, "Could not find a player named '"+subject+"'"); return; } else if (result[0] instanceof Exception) { Exception e = (Exception) result[0]; Visage.log.log(Level.WARNING, "An error occurred while looking up a player name", e); if (reportExceptions) { response.setContentType("text/plain"); e.printStackTrace(response.getWriter()); response.setStatus(500); response.flushBuffer(); } else { response.sendError(500, "Could not render your request"); } return; } else if (result[0] instanceof UUID) { uuid = (UUID) result[0]; response.sendRedirect(baseUrl+"/"+modeStr+"/"+height+"/"+uuid.toString().replace("-", "")); j.set(subject, uuid.toString()); j.pexpire(subject, resolverTtlMillis); return; } else { response.sendError(500, "Could not render your request"); return; } } } } else { response.sendError(400, "Subject must be a dashless UUID, dashed UUID, or username"); return; } } else { response.sendError(400, "Subject must be a dashless or dashed UUID"); return; } } } if (uuid == null) { response.sendError(500, "Could not render your request"); } width *= supersampling; height *= supersampling; GameProfile profile = new GameProfile(uuid, "<unknown>"); byte[] skin; try (Jedis sj = master.getSkinJedis()) { String resp = sj.get(uuid.toString()+":profile"); byte[] skinResp = sj.get((uuid.toString()+":skin").getBytes(Charsets.UTF_8)); if (resp != null) { profile = gson.fromJson(resp, GameProfile.class); } else { if (uuid.version() == 8) { profile = new GameProfile(uuid, subject.substring(2)); } else { if (cacheHeader) missed.add("profile"); profile = ss.fillProfileProperties(profile); sj.set(uuid.toString()+":profile", gson.toJson(profile)); sj.pexpire(uuid.toString()+":profile", skinTtlMillis); } } if (skinResp != null && skinResp.length > 3) { skin = skinResp; } else { if (cacheHeader) missed.add("skin"); Map<ProfileTextureType, ProfileTexture> tex = ss.getTextures(profile, false); if (tex.containsKey(ProfileTextureType.SKIN)) { ProfileTexture skinTex = tex.get(ProfileTextureType.SKIN); try (InputStream in = URLUtils.constantURL(skinTex.getUrl()).openStream()) { skin = ByteStreams.toByteArray(in); } } else { if (Profiles.isSlim(profile)) { skin = master.alex; } else { skin = master.steve; } } sj.set((uuid.toString()+":skin").getBytes(Charsets.UTF_8), skin); sj.pexpire(uuid.toString()+":skin", skinTtlMillis); } } catch (Exception e) { Visage.log.log(Level.WARNING, "An error occurred while resolving texture data", e); if (reportExceptions) { response.setContentType("text/plain"); e.printStackTrace(response.getWriter()); response.setStatus(500); response.flushBuffer(); return; } else { if (Profiles.isSlim(profile)) { skin = master.alex; } else { skin = master.steve; } } } if (mode == RenderMode.SKIN) { write(response, missed, skin, "none"); return; } RenderResponse resp = null; Exception ex = null; int attempts = 0; while (attempts < maxAttempts) { attempts++; try { resp = master.renderRpc(mode, width, height, supersampling, profile, skin, request.getParameterMap()); } catch (Exception e) { ex = e; continue; } if (resp == null) { continue; } write(response, missed, resp.png, resp.slave); return; } if (ex != null) { Visage.log.log(Level.WARNING, "An error occurred while rendering a request", ex); if (reportExceptions) { response.setContentType("text/plain"); ex.printStackTrace(response.getWriter()); response.setStatus(500); response.flushBuffer(); } else { response.sendError(500, "Could not render your request"); } return; } else if (resp == null) { response.setContentType("text/plain;charset=utf-8"); response.getWriter().println("Could not render your request"); response.setStatus(500); response.flushBuffer(); } } private void sendPermanentRedirect(String path, HttpServletResponse response) { response.setStatus(301); response.setHeader("Location", path); } private void write(HttpServletResponse response, List<String> missed, byte[] png, String slave) throws IOException { if (slaveHeader) { response.setHeader("X-Visage-Slave", slave); } response.setContentType("image/png"); response.setContentLength(png.length); if (cacheHeader) { if (missed.isEmpty()) { response.setHeader("X-Visage-Cache-Miss", "none"); } else { response.setHeader("X-Visage-Cache-Miss", Strings.join(missed, ", ")); } } response.getOutputStream().write(png); response.getOutputStream().flush(); response.setStatus(200); response.flushBuffer(); } }