module api_mapping; import handy_http_primitives; import handy_http_handlers.path_handler; import handy_http_handlers.filtered_handler; import slf4d; /** * Defines the Finnow API mapping with a main PathHandler. * Params: * webOrigin = The origin to use when configuring CORS headers. * Returns: The handler to plug into an HttpServer. */ HttpRequestHandler mapApiHandlers(string webOrigin) { PathHandler publicHandler = new PathHandler(); PathHandler authenticatedHandler = new PathHandler(); // Public endpoints: publicHandler.addMapping(HttpMethod.GET, "/api/status", HttpRequestHandler.of(&getStatus)); publicHandler.addMapping(HttpMethod.OPTIONS, "/**", HttpRequestHandler.of(&getOptions)); // Note: the download endpoint is public! We authenticate via token in query params instead of header here. import attachment.api; publicHandler.registerHandlers!(attachment.api); import auth.api_public; publicHandler.registerHandlers!(auth.api_public); // Dev endpoint for sample data: REMOVE BEFORE DEPLOYING!!! // h.map(HttpMethod.POST, "/sample-data", &sampleDataEndpoint); // Authenticated endpoints: import auth.api; authenticatedHandler.registerHandlers!(auth.api); import profile.api; authenticatedHandler.registerHandlers!(profile.api); import account.api; authenticatedHandler.registerHandlers!(account.api); import transaction.api; authenticatedHandler.registerHandlers!(transaction.api); import analytics.api; authenticatedHandler.registerHandlers!(analytics.api); import data_api; authenticatedHandler.registerHandlers!(data_api); // Protect all authenticated paths with a filter. import auth.service : AuthenticationFilter; HttpRequestFilter authenticationFilter = new AuthenticationFilter(); publicHandler.addMapping("/api/**", new FilteredHandler( [authenticationFilter], authenticatedHandler )); // Build the main handler into a filter chain: return new FilteredHandler( [ cast(HttpRequestFilter) new CorsFilter(webOrigin), cast(HttpRequestFilter) new ContentLengthFilter(), cast(HttpRequestFilter) new TokenBucketRateLimitingFilter(10, 50), cast(HttpRequestFilter) new ExceptionHandlingFilter() ], publicHandler ); } private void getStatus(ref ServerHttpRequest request, ref ServerHttpResponse response) { response.writeBodyString("online", ContentTypes.TEXT_PLAIN); } private void getOptions(ref ServerHttpRequest request, ref ServerHttpResponse response) { // Do nothing, just return 200 OK. } private void sampleDataEndpoint(ref ServerHttpRequest request, ref ServerHttpResponse response) { import slf4d; import util.sample_data; import core.thread; Thread t = new Thread(() { try { generateSampleData(); } catch (Exception e) { error("Error while generating sample data.", e); } }); t.start(); info("Started new thread to generate sample data."); } /** * A filter that adds CORS response headers. */ private class CorsFilter : HttpRequestFilter { private string webOrigin; this(string webOrigin) { this.webOrigin = webOrigin; } void doFilter(ref ServerHttpRequest request, ref ServerHttpResponse response, FilterChain filterChain) { response.headers.add("Access-Control-Allow-Origin", webOrigin); response.headers.add("Access-Control-Allow-Methods", "*"); response.headers.add("Access-Control-Allow-Headers", "Authorization, Content-Type"); response.headers.add("Access-Control-Expose-Headers", "Content-Disposition"); filterChain.doFilter(request, response); } } /** * A filter that rejects requests with a body that's too large, to avoid issues * later with handling such large objects in memory. */ private class ContentLengthFilter : HttpRequestFilter { const MAX_LENGTH = 1024 * 1024 * 20; // 2MB limit void doFilter(ref ServerHttpRequest request, ref ServerHttpResponse response, FilterChain filterChain) { if ("Content-Length" in request.headers) { ulong contentLength = request.getHeaderAs!ulong("Content-Length"); if (contentLength > MAX_LENGTH) { warnF!"Received request with content length of %d, larger than max allowed %d bytes."( contentLength, MAX_LENGTH ); import std.conv; response.status = HttpStatus.PAYLOAD_TOO_LARGE; response.writeBodyString( "Request body is too large. Must be at most " ~ MAX_LENGTH.to!string ~ " bytes." ); return; // Don't propagate the filter. } } filterChain.doFilter(request, response); } } /** * A filter that catches any exception thrown by the filter chain, and nicely * formats the response status and message. */ private class ExceptionHandlingFilter : HttpRequestFilter { void doFilter(ref ServerHttpRequest request, ref ServerHttpResponse response, FilterChain filterChain) { try { filterChain.doFilter(request, response); } catch (HttpStatusException e) { response.status = e.status; response.writeBodyString(e.message.idup); } catch (Exception e) { error(e); response.status = HttpStatus.INTERNAL_SERVER_ERROR; response.writeBodyString("An error occurred: " ~ e.msg); } catch (Throwable e) { errorF!"A throwable was caught! %s %s"(e.msg, e.info); response.status = HttpStatus.INTERNAL_SERVER_ERROR; response.writeBodyString("An error occurred."); throw e; } } } /** * A filter that uses a shared token bucket to limit clients' requests. Each * client's IP address is used as the identifier, and each client is given a * maximum of N requests to make, and a rate at which that limit replenishes * over time. */ private class TokenBucketRateLimitingFilter : HttpRequestFilter { import std.datetime; import std.math : floor; import std.algorithm : min; private static struct TokenBucket { uint tokens; SysTime lastRequest; } TokenBucket[string] tokenBuckets; const uint tokensPerSecond; const uint maxTokens; this(uint tokensPerSecond, uint maxTokens) { this.tokensPerSecond = tokensPerSecond; this.maxTokens = maxTokens; } void doFilter(ref ServerHttpRequest request, ref ServerHttpResponse response, FilterChain filterChain) { string clientAddr = getClientId(request); bool shouldBlockRequest = false; synchronized { const now = Clock.currTime(); TokenBucket* bucket = getOrCreateBucket(clientAddr, now); incrementTokensForElapsedTime(bucket, now); if (bucket.tokens < 1) { shouldBlockRequest = true; } else { bucket.tokens--; } bucket.lastRequest = now; clearOldBuckets(); } if (shouldBlockRequest) { infoF!"Rate-limiting client %s because they have made too many requests."(clientAddr); response.status = HttpStatus.TOO_MANY_REQUESTS; response.writeBodyString("You have made too many requests to the API."); } else { filterChain.doFilter(request, response); } } private string getClientId(in ServerHttpRequest req) { import handy_http_transport.helpers : indexOf; string clientAddr = req.clientAddress.toString(); auto portIdx = indexOf(clientAddr, ':'); if (portIdx != -1) { clientAddr = clientAddr[0..portIdx]; } return clientAddr; } private TokenBucket* getOrCreateBucket(string clientAddr, SysTime now) { TokenBucket* bucket = clientAddr in tokenBuckets; if (bucket is null) { tokenBuckets[clientAddr] = TokenBucket(maxTokens, now); bucket = clientAddr in tokenBuckets; } return bucket; } private void incrementTokensForElapsedTime(TokenBucket* bucket, SysTime now) { Duration timeSinceLastRequest = now - bucket.lastRequest; const tokensAddedSinceLastRequest = floor((timeSinceLastRequest.total!"msecs") * (tokensPerSecond / 1000.0)); bucket.tokens = cast(uint) min(bucket.tokens + tokensAddedSinceLastRequest, maxTokens); } private void clearOldBuckets() { const Duration fillTime = seconds(maxTokens * tokensPerSecond); foreach (id; tokenBuckets.byKey()) { TokenBucket bucket = tokenBuckets[id]; const Duration timeSinceLastRequest = Clock.currTime() - bucket.lastRequest; if (timeSinceLastRequest > fillTime) { tokenBuckets.remove(id); } } } }