Skip to content

Commit 7d31889

Browse files
committed
persistent MongoCollections in unmodifiable map
also url-safe tokens (can be included as a query param) factor out logic from streaming / non-streaming database endpoints added login and token example to vector tile client
1 parent ab482b3 commit 7d31889

File tree

7 files changed

+128
-55
lines changed

7 files changed

+128
-55
lines changed

src/main/java/com/conveyal/analysis/components/HttpApi.java

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,21 @@ private spark.Service configureSparkService () {
7878
LOG.info("Analysis server will listen for HTTP connections on port {}.", config.serverPort());
7979
spark.Service sparkService = spark.Service.ignite();
8080
sparkService.port(config.serverPort());
81+
//sparkService.threadPool(1000);
82+
83+
// Set up TLS (HTTPS). Unfortunately Spark HTTP only accepts String paths to keystore files.
84+
// We want to build a Keystore instance programmatically from PEM files.
85+
// Digging through the Spark source code it seems extremely convoluted to directly inject a Keystore instance.
86+
// sparkService.secure();
87+
// Usage examples at:
88+
// https://github.com/Hakky54/sslcontext-kickstart/blob/master/sslcontext-kickstart-for-pem/src/test/java/nl/altindag/ssl/util/PemUtilsShould.java
89+
// Dependency:
90+
// Tools to load PEM files into Java Keystore (so we don't have to use arcane Java keytool)
91+
// implementation 'io.github.hakky54:sslcontext-kickstart-for-pem:7.4.1'
92+
8193
// Serve up UI files. staticFileLocation("vector-client") inside classpath will not see changes to files.
82-
// Note that this eliminates the need for CORS.
83-
sparkService.externalStaticFileLocation("src/main/resources/vector-client");
94+
// Note that this eliminates the need for CORS headers and eliminates CORS preflight request latency.
95+
sparkService.externalStaticFileLocation("../r5/src/main/resources/vector-client");
8496

8597
// Specify actions to take before the main logic of handling each HTTP request.
8698
sparkService.before((req, res) -> {

src/main/java/com/conveyal/analysis/components/TokenAuthentication.java

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,31 +54,37 @@ public TokenAuthentication (AnalysisDB database) {
5454

5555
@Override
5656
public UserPermissions authenticate(Request request) {
57-
String authHeader = request.headers("authorization");
58-
if (authHeader == null) {
59-
throw new AnalysisServerException(UNAUTHORIZED, "Authorization header mising.", 401);
57+
String token = request.headers("authorization");
58+
// Some places such as MopboxGL do not make it easy to add headers, so also accept token in query parameter.
59+
// The MapboxGL transformUrl setting seems to be missing from recent versions of the library.
60+
if (token == null) {
61+
token = request.queryParams("token");
6062
}
61-
if ("sesame".equalsIgnoreCase(authHeader)) {
63+
if (token == null) {
64+
throw new AnalysisServerException(UNAUTHORIZED, "Authorization token mising.", 401);
65+
}
66+
if ("sesame".equalsIgnoreCase(token)) {
6267
return new UserPermissions("local", true, "local");
6368
}
64-
UserPermissions userPermissions = userForToken(authHeader);
69+
UserPermissions userPermissions = userForToken(token);
6570
if (userPermissions == null) {
66-
throw new AnalysisServerException(UNAUTHORIZED, "Inalid authorization token.", 401);
71+
throw new AnalysisServerException(UNAUTHORIZED, "Invalid authorization token.", 401);
6772
} else {
6873
return userPermissions;
6974
}
7075
}
7176

7277
/**
7378
* TODO is SecureRandom a sufficiently secure source of randomness when used this way?
74-
* Should we be creating a new instance each time?
75-
* @return A Base64 encoded representation of 32 random bytes
79+
* Should we be creating a new instance of SecureRandom each time or reusing it?
80+
* Do not use basic Base64 encoding since it contains some characters that are invalid in URLs.
81+
* @return A url-safe representation of 32 random bytes
7682
*/
7783
public static String generateToken () {
7884
Random random = new SecureRandom();
7985
byte[] tokenBytes = new byte[32];
8086
random.nextBytes(tokenBytes);
81-
String token = Base64.getEncoder().encodeToString(tokenBytes);
87+
String token = Base64.getUrlEncoder().encodeToString(tokenBytes);
8288
return token;
8389
}
8490

src/main/java/com/conveyal/analysis/controllers/AuthTokenController.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ private Object createUser (Request req, Response res) {
4848
* Create a new token, replacing any existing one for the same user (email).
4949
*/
5050
private Map getTokenForEmail (Request req, Response res) {
51+
// These should probably be in the body not URL, to prevent them from appearing as plaintext in history.
5152
String email = req.queryParams("email");
5253
String password = req.queryParams("password");
5354
// Crude rate limiting, might just lead to connections piling up in event of attack.

src/main/java/com/conveyal/analysis/controllers/DatabaseController.java

Lines changed: 45 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,25 @@
33
import com.conveyal.analysis.UserPermissions;
44
import com.conveyal.analysis.persistence.AnalysisDB;
55
import com.google.common.collect.Lists;
6+
import com.mongodb.client.FindIterable;
67
import com.mongodb.client.MongoCollection;
7-
import com.mongodb.util.JSON;
8-
import org.bson.BsonArray;
98
import org.bson.Document;
109
import org.bson.conversions.Bson;
11-
import org.bson.json.JsonWriter;
1210
import org.slf4j.Logger;
1311
import org.slf4j.LoggerFactory;
1412
import spark.Request;
1513
import spark.Response;
1614

17-
import java.io.IOException;
1815
import java.io.OutputStream;
19-
import java.io.PrintWriter;
20-
import java.io.Writer;
2116
import java.lang.invoke.MethodHandles;
2217
import java.nio.charset.StandardCharsets;
2318
import java.util.ArrayList;
19+
import java.util.HashMap;
2420
import java.util.List;
21+
import java.util.Map;
2522

2623
import static com.conveyal.analysis.util.JsonUtil.toJson;
24+
import static com.google.common.base.Preconditions.checkNotNull;
2725
import static com.mongodb.client.model.Filters.and;
2826
import static com.mongodb.client.model.Filters.eq;
2927

@@ -38,61 +36,72 @@ public class DatabaseController implements HttpController {
3836

3937
private final AnalysisDB database;
4038

41-
private final MongoCollection<Document> regions;
42-
private final MongoCollection<Document> bundles;
39+
private final Map<String, MongoCollection<Document>> mongoCollections;
40+
41+
// Preloading these avoids synchronization during handling http requests by reading from an immutable map.
42+
// TODO verify if it is threadsafe to reuse MongoCollection in all threads.
43+
// Amazingly there seems to be no documentation on this at all. Drilling down into the function calls, it seems
44+
// to create a new session on each find() call, so should presumably go through synchronization.
45+
// In testing with siege and other http benchmarking tools, reusing the MongoCollection seems to result in much
46+
// smoother operation; creating a new MongoCollection on each request seems to jam up after a certain number
47+
// of requests (perhaps waiting for idle MongoCollectons to be cleaned up).
48+
public Map<String, MongoCollection<Document>> mongoCollectionMap (String... collectionNames) {
49+
Map<String, MongoCollection<Document>> map = new HashMap<>();
50+
for (String name : collectionNames) {
51+
map.put(name, database.getBsonCollection(name));
52+
}
53+
// Make the map immutable for threadsafe reading and return.
54+
return Map.copyOf(map);
55+
}
4356

4457
public DatabaseController(AnalysisDB database) {
4558
this.database = database;
46-
// TODO verify if it is threadsafe to reuse this collection in all threads
47-
// Also verify whether it's any slower to just get the collection on every GET operation.
48-
// Testing with Apache bench, retaining and reusing the collection seems much smoother.
49-
this.regions = database.getBsonCollection("regions");
50-
this.bundles = database.getBsonCollection("bundles");
59+
this.mongoCollections = mongoCollectionMap("regions", "bundles");
5160
}
5261

53-
/**
54-
* Fetch anything from database. Buffers in memory so not suitable for huge responses.
55-
* register serialization with sparkService.get("/api/db/:collection", this::getDocuments, toJson);
56-
*/
57-
private Iterable<Document> getDocuments (Request req, Response res) {
62+
/** Factored out for experimenting with streaming and non-streaming approaches to serialization. */
63+
private FindIterable<Document> getDocuments (Request req) {
5864
String accessGroup = UserPermissions.from(req).accessGroup;
5965
final String collectionName = req.params("collection");
60-
MongoCollection<Document> collection = collectionName.equals("bundles") ? bundles :
61-
database.getBsonCollection(collectionName);
66+
MongoCollection<Document> collection = mongoCollections.get(collectionName);
67+
checkNotNull(collection, "Collection not available: " + collectionName);
6268
List<Bson> filters = Lists.newArrayList(eq("accessGroup", accessGroup));
6369
req.queryMap().toMap().forEach((key, values) -> {
6470
for (String value : values) {
6571
filters.add(eq(key, value));
6672
}
6773
});
74+
return collection.find(and(filters));
75+
}
76+
77+
/**
78+
* Fetch anything from database. Buffers all documents in memory so may not not suitable for large responses.
79+
* Register result serialization with: sparkService.get("/api/db/:collection", this::getDocuments, toJson);
80+
*/
81+
private Iterable<Document> getDocuments (Request req, Response res) {
82+
FindIterable<Document> docs = getDocuments(req);
6883
List<Document> documents = new ArrayList<>();
69-
collection.find(and(filters)).into(documents);
84+
docs.into(documents);
7085
return documents;
7186
}
7287

7388
/**
7489
* Fetch anything from database. Streaming processing, no in-memory buffering of the BsonDocuments.
7590
* The output stream does buffer to some extent but should stream chunks instead of serializing into memory.
91+
* Anecdotally in testing with seige this does seem to almost double the response rate and allow double the
92+
* concurrent connections without stalling (though still low at 20, and it eventually does stall).
7693
*/
7794
private Object getDocumentsStreaming (Request req, Response res) {
78-
String accessGroup = UserPermissions.from(req).accessGroup;
79-
final String collectionName = req.params("collection");
80-
MongoCollection<Document> collection = collectionName.equals("bundles") ? bundles :
81-
database.getBsonCollection(collectionName);
82-
List<Bson> filters = Lists.newArrayList(eq("accessGroup", accessGroup));
83-
req.queryMap().toMap().forEach((key, values) -> {
84-
for (String value : values) {
85-
filters.add(eq(key, value));
86-
}
87-
});
95+
FindIterable<Document> docs = getDocuments(req);
8896
// getOutputStream returns a ServletOutputStream, usually Jetty implementation HttpOutputStream which
8997
// buffers the output. doc.toJson() creates a lot of short-lived objects which could be factored out.
9098
// The Mongo driver says to use JsonWriter or toJson() rather than utility methods:
9199
// https://github.com/mongodb/mongo-java-driver/commit/63409f9cb3bbd0779dd5139355113d9b227dfa05
92-
try (OutputStream out = res.raw().getOutputStream()) {
100+
try {
101+
OutputStream out = res.raw().getOutputStream();
93102
out.write('['); // Begin JSON array.
94103
boolean firstElement = true;
95-
for (Document doc : collection.find(and(filters))) {
104+
for (Document doc : docs) {
96105
if (firstElement) {
97106
firstElement = false;
98107
} else {
@@ -101,17 +110,16 @@ private Object getDocumentsStreaming (Request req, Response res) {
101110
out.write(doc.toJson().getBytes(StandardCharsets.UTF_8));
102111
}
103112
out.write(']'); // Close JSON array.
104-
} catch (IOException e) {
113+
// We do not close the OutputStream, even implicitly with a try-with-resources.
114+
// The thinking is that closing the stream might close the underlying connection, which might be keepalive.
115+
} catch (Exception e) {
105116
throw new RuntimeException("Failed to write database records as JSON.", e);
106117
}
107118
// Since we're directly writing to the OutputStream, no need to return anything.
108119
// But do not return null or Spark will complain cryptically.
109120
return "";
110121
}
111122

112-
// Testing with Apache bench shows some stalling
113-
// -k keepalive connections fails immediately
114-
115123
@Override
116124
public void registerEndpoints (spark.Service sparkService) {
117125
sparkService.get("/api/db/:collection", this::getDocuments, toJson);

src/main/resources/vector-client/index.html

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,16 @@
3434

3535
mapboxgl.accessToken = 'TOKEN_HERE';
3636

37+
let token = new URLSearchParams(window.location.search).get('token')
38+
function authFetch (url) {
39+
return fetch(url, { headers: {'Authorization': token }})
40+
}
41+
3742
const regionSelectElement = document.getElementById("regions")
3843
function updateRegionSelector () {
3944
regionSelectElement.add(new Option("None"));
4045
// Returns an array of regions. Add them to the region selector DOM element.
41-
fetch('http://localhost:7070/api/db/regions')
46+
authFetch('http://localhost:7070/api/db/regions')
4247
.then(response => response.json())
4348
.then(regions => {
4449
for (const region of regions) {
@@ -53,7 +58,7 @@
5358
// Returns an array of bundles. Add them to the bundle selector DOM element.
5459
document.querySelectorAll('#bundles option').forEach(option => option.remove())
5560
bundleSelectElement.add(new Option("None"));
56-
fetch(`http://localhost:7070/api/db/bundles?regionId=${regionId}`)
61+
authFetch(`http://localhost:7070/api/db/bundles?regionId=${regionId}`)
5762
.then(response => response.json())
5863
.then(bundles => {
5964
for (const bundle of bundles) {
@@ -80,7 +85,7 @@
8085
feedId = null;
8186

8287
function updateRegion (regionId) {
83-
fetch(`http://localhost:7070/api/db/regions?_id=${regionId}`)
88+
authFetch(`http://localhost:7070/api/db/regions?_id=${regionId}`)
8489
.then(response => response.json())
8590
.then(r => {
8691
region = r[0];
@@ -91,7 +96,7 @@
9196
}
9297

9398
function updateBundle (bundleId) {
94-
fetch(`http://localhost:7070/api/db/bundles?_id=${bundleId}`)
99+
authFetch(`http://localhost:7070/api/db/bundles?_id=${bundleId}`)
95100
.then(response => response.json())
96101
.then(b => {
97102
bundle = b[0];
@@ -112,8 +117,8 @@
112117

113118
feedSelectElement.onchange = function (event) {
114119
feedId = event.target.value;
115-
// setUrl expects a URL to TileJSON, not a URL to the tiles themselves.
116-
map.getSource('r5').setTiles([`http://localhost:7070/api/gtfs/${bundle.feedGroupId}/${feedId}/tiles/{z}/{x}/{y}`]);
120+
// setUrl expects a URL to TileJSON, not a URL to the tiles themselves (use setTiles()).
121+
map.getSource('r5').setTiles([`http://localhost:7070/api/gtfs/${bundle.feedGroupId}/${feedId}/tiles/{z}/{x}/{y}?token=${token}`]);
117122
}
118123

119124
let map = new mapboxgl.Map({
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<title>Conveyal Login</title>
6+
<style>
7+
body { margin: 0; padding: 0; font-family: sans-serif}
8+
#panel {
9+
display: flex; flex-direction: column; width: 20%; padding: 20px; margin: auto;
10+
}
11+
</style>
12+
</head>
13+
<body>
14+
<div id="panel">
15+
<label for="email">Email:</label>
16+
<input type="email" id="email"></input>
17+
<label for="password">Password:</label>
18+
<input type="password" id="password"></input>
19+
<button name="login" id="login">Log In</button>
20+
</div>
21+
<script>
22+
23+
let emailField = document.getElementById("email");
24+
let passwordField = document.getElementById("password");
25+
let loginButton = document.getElementById("login");
26+
loginButton.onclick = function (e) {
27+
// TODO validate/sanitize
28+
let email = emailField.value;
29+
let password = passwordField.value;
30+
let url = `http://localhost:7070/token?email=${email}&password=${password}`
31+
fetch(url)
32+
.then(response => response.json())
33+
.then(response => {
34+
console.log(response.token);
35+
window.location.href = `index.html?token=${response.token}`;
36+
});
37+
};
38+
39+
</script>
40+
</body>
41+
</html>

src/main/resources/vector-client/vectorstyle.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"r5": {
66
"type": "vector",
77
"tiles": [
8-
"http://localhost:7070/api/gtfs/61137f589919c7627cb5647f/61137f589919c7627cb56480/tiles/{z}/{x}/{y}"
8+
"http://localhost:7070/dummy"
99
],
1010
"maxzoom": 14
1111
},

0 commit comments

Comments
 (0)