diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..362af52a522d8b71fd943a3d1ea75403103083ff
--- /dev/null
+++ b/README.md
@@ -0,0 +1,8 @@
+# api-server
+
+## Running api-server
+
+1. start the docker environment: `npm run exec:env`
+2. start the server: `npm run watch`
+3. start the queue worker: `npm run watch:queue`
+4. start NGrok: `ngrok http 7123 --hostname <myhostname>`
\ No newline at end of file
diff --git a/database/migrations/20210000000001-create-uploads-table.js b/database/migrations/20210000000001-create-uploads-table.js
new file mode 100644
index 0000000000000000000000000000000000000000..6f3faab2c2cc1b88b08edd57de1a9c89441ebf35
--- /dev/null
+++ b/database/migrations/20210000000001-create-uploads-table.js
@@ -0,0 +1,69 @@
+module.exports = {
+	up: (migration, Types) => {
+		return migration.createTable('uploads', {
+			id: {
+				type: Types.UUID,
+				primaryKey: true,
+				defaultValue: Types.UUIDV4,
+				allowNull: false,
+			},
+			user_id: {
+				type: Types.UUID,
+				allowNull: true,
+				references: {
+					model: 'users',
+					key: 'id',
+				},
+			},
+			provider: {
+				type: Types.TEXT,
+				allowNull: false,
+			},
+			upload_url: {
+				type: Types.TEXT,
+				allowNull: false,
+			},
+			request_params: {
+				type: Types.JSONB,
+				allowNull: false,
+			},
+			expires_at: {
+				type: Types.DATE,
+				allowNull: false,
+			},
+			status: {
+				type: Types.TEXT,
+				allowNull: 'false',
+				default: 'pending',
+			},
+			status_reason: {
+				type: Types.TEXT,
+				allowNull: true,
+			},
+			meta: {
+				type: Types.JSONB,
+				defaultValue: {},
+				allowNull: false,
+			},
+			created_at: {
+				type: Types.DATE,
+				defaultValue: Types.fn('now'),
+				allowNull: false,
+			},
+			updated_at: {
+				type: Types.DATE,
+				defaultValue: Types.fn('now'),
+				allowNull: false,
+			},
+			deleted_at: {
+				type: Types.DATE,
+				defaultValue: null,
+				allowNull: true,
+			},
+		})
+	},
+
+	down: (migration, Types) => {
+		return migration.dropTable('uploads')
+	},
+}
diff --git a/package-lock.json b/package-lock.json
index 87b04fea73525a04f6f05c88dccb2638d1ab7eb1..fec1df2f0c3a0e83cc946e826f01ae13133aaeda 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6345,9 +6345,9 @@
       },
       "dependencies": {
         "debug": {
-          "version": "3.2.6",
-          "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz",
-          "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==",
+          "version": "3.2.7",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+          "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
           "requires": {
             "ms": "^2.1.1"
           }
diff --git a/package.json b/package.json
index 68592072e2e20c38b9881f58e622882e30a85663..79cbf7e16a60e8373b29abda69087e5ca573a158 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,7 @@
     "cmd": "NODE_PATH=src node run",
     "sql": "NODE_PATH=src node scripts/npx-boot.js sequelize",
     "repl": "NODE_PATH=src node -e 'Object.entries(require(\"bootstrap\")).forEach(([key, value]) => Object.defineProperty(global, key, { value })); boot().then(() => console.log(\"Booted\"))' -i",
-	"prettier": "prettier server.js worker.js run.js src database --write"
+    "prettier": "prettier server.js worker.js run.js src database --write"
   },
   "author": "Louis Capitanchik <louis@microhacks.co.uk>",
   "license": "GPL-3.0+",
diff --git a/public/logo.png b/public/logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..4ba916fb3e6dea1378b96fc2f73dfae48b9c5fb8
--- /dev/null
+++ b/public/logo.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:6521c82a8519e2ae987ad3d4a6143681a4618b4fa3a5585f82aa65a778403928
+size 97893
diff --git a/public/main.css b/public/main.css
new file mode 100644
index 0000000000000000000000000000000000000000..921ada56d0947bd5f698ac28978af3e0f556b2d0
--- /dev/null
+++ b/public/main.css
@@ -0,0 +1,50 @@
+* {
+	margin: 0;
+	padding: 0;
+}
+
+html {
+	font-size: 16px;
+	font-family: Arial, sans-serif;
+}
+
+.container {
+	display: flex;
+	flex-direction: column;
+	padding: 2rem;
+	align-items: center;
+}
+
+.inner {
+	width: 100%;
+	max-width: 600px;
+}
+
+.row {
+	display: flex;
+}
+
+.header {
+	padding: 1rem 0;
+}
+
+.text-container {
+	padding: 2rem;
+}
+
+.centered {
+	padding: 1rem 0;
+}
+
+.controls {
+	display: flex;
+	flex-direction: column;
+	flex: 1;
+	max-width: 300px;
+}
+
+.controls input {
+	width: 100%;
+	padding: 5px;
+	margin: 5px 0;
+}
\ No newline at end of file
diff --git a/src/app.js b/src/app.js
index b717c76914829b9ac9c84c3404a72dadb30e97f0..72b1476d456a34b0433ff1dfd60239dbea2b691d 100644
--- a/src/app.js
+++ b/src/app.js
@@ -4,10 +4,13 @@ const logger = require('koa-logger')
 const bodyparser = require('koa-bodyparser')
 const etag = require('koa-etag')
 const session = require('koa-session')
+const static = require('koa-static')
 
 const hbs = require('vendor/koa-handlebars')
 const { config } = require('bootstrap')
 
+const pathutil = require('path')
+
 const debughbs = require('debug')('server:templates')
 const debug = require('debug')('server:boot')
 const requestLog = require('debug')('server:request')
@@ -28,6 +31,7 @@ module.exports = async function createApp(app = new Koa()) {
 	app.use(etag({ weak: true }))
 	app.use(bodyparser())
 	app.use(logger(s => requestLog(s)))
+	app.use(static(pathutil.resolve(__dirname + '/../public')))
 
 	app.use(
 		session(
diff --git a/src/core/services/serialise.js b/src/core/services/serialise.js
new file mode 100644
index 0000000000000000000000000000000000000000..3e80e4d68ecf00ab3741db874cb99ba6fbeafb60
--- /dev/null
+++ b/src/core/services/serialise.js
@@ -0,0 +1,22 @@
+exports.serialise = async function serialise(pattern, data) {
+	const output = {}
+
+	for (const [key, mapper] of Object.entries(pattern)) {
+		switch (typeof mapper) {
+			case 'string':
+			case 'number':
+			case 'symbol':
+				console.log(data[mapper])
+				output[key] = data[mapper]
+				break
+			case 'function':
+				output[key] = await mapper(data, output)
+				break
+			case 'object':
+				output[key] = await serialise(mapper, data)
+				break
+		}
+	}
+
+	return output
+}
diff --git a/src/core/utils/sym.js b/src/core/utils/sym.js
new file mode 100644
index 0000000000000000000000000000000000000000..11b42a17df63fdc3cb0bc777b3128625f8206c84
--- /dev/null
+++ b/src/core/utils/sym.js
@@ -0,0 +1,10 @@
+const SYM = new Proxy(
+	{},
+	{
+		get(target, p, receiver) {
+			return p
+		},
+	},
+)
+
+module.exports = SYM
diff --git a/src/database/models/Upload.js b/src/database/models/Upload.js
new file mode 100644
index 0000000000000000000000000000000000000000..99aa08e7e3ecb14c20da935ed480e150201e5316
--- /dev/null
+++ b/src/database/models/Upload.js
@@ -0,0 +1,78 @@
+const timestamps = require('./properties/timestamps')
+const BaseModel = require('./BaseModel')
+
+class Upload extends BaseModel {
+	static associate(models) {
+		this.belongsTo(models.User, { foreignKey: 'user_id' })
+	}
+
+	toJSON() {
+		return {
+			id: this.id,
+			provider: this.provider,
+			upload_url: this.upload_url,
+			request_params: this.request_params,
+			expires_at: this.expires_at,
+			status: this.status,
+			status_reason: this.status_reason,
+			meta: this.meta,
+			created_at: this.created_at,
+			updated_at: this.updated_at,
+		}
+	}
+}
+
+function generatePublicUri(provider, file) {
+	return [
+		'https://storage.googleapis.com',
+		file.meta.bucket,
+		file.file_root,
+		file.file_name,
+	].join('/')
+}
+
+module.exports = (sequelize, DataTypes) => {
+	Upload.init(
+		Object.assign(
+			{
+				id: {
+					type: DataTypes.UUID,
+					primaryKey: true,
+					defaultValue: DataTypes.UUIDV4,
+					validate: {
+						isUUID: 4,
+					},
+				},
+				provider: {
+					type: DataTypes.TEXT,
+				},
+				upload_url: {
+					type: DataTypes.TEXT,
+				},
+				request_params: {
+					type: DataTypes.JSONB,
+				},
+				expires_at: {
+					type: DataTypes.DATE,
+				},
+				status: {
+					type: DataTypes.TEXT,
+				},
+				status_reason: {
+					type: DataTypes.TEXT,
+				},
+				meta: {
+					type: DataTypes.JSONB,
+				},
+			},
+			timestamps(DataTypes),
+		),
+		{
+			sequelize,
+			paranoid: true,
+			tableName: 'uploads',
+		},
+	)
+
+	return Upload
+}
diff --git a/src/database/models/User.js b/src/database/models/User.js
index 3a23fb1193a0df99289205181b2c49f91e66df09..8b9f3b0f9a64c303bab16483e57c3320d26672f5 100644
--- a/src/database/models/User.js
+++ b/src/database/models/User.js
@@ -8,6 +8,7 @@ class User extends BaseModel {
 		this.hasMany(models.AccessToken, { foreignKey: 'user_id' })
 		this.hasMany(models.RefreshToken, { foreignKey: 'user_id' })
 		this.hasMany(models.File, { foreignKey: 'user_id' })
+		this.hasMany(models.Upload, { foreignKey: 'user_id' })
 		this.belongsToMany(models.File, {
 			as: 'likes',
 			through: 'user_file_likes',
@@ -166,6 +167,28 @@ class User extends BaseModel {
 			updated_at: this.updated_at,
 		}
 	}
+
+	serialise() {
+		const { serialise } = require('core/services/serialise')
+		const SYM = require('core/utils/sym')
+
+		const { id, name, email, created_at, updated_at } = SYM
+
+		return serialise(
+			{
+				id,
+				name,
+				email,
+				meta: user => ({
+					...user.meta,
+					dob: undefined,
+				}),
+				created_at,
+				updated_at,
+			},
+			this,
+		)
+	}
 }
 
 module.exports = (sequelize, DataTypes) => {
diff --git a/src/domain/auth/AuthenticationService.js b/src/domain/auth/AuthenticationService.js
index 7ea548c5defad19a3348c77093d6ff3c9be2158c..25fdb36f342b75871b38559ebe03ec08838ad697 100644
--- a/src/domain/auth/AuthenticationService.js
+++ b/src/domain/auth/AuthenticationService.js
@@ -68,7 +68,6 @@ module.exports = class AuthenticationService extends ContextualModule {
 			}
 		} else if (this.ctx.get('x-api-token')) {
 			const token = this.ctx.get('x-api-token')
-			console.log(token, this.ctx.get('x-token-type'))
 			try {
 				const user = await User.fromToken(token, this.ctx.get('x-token-type'))
 				if (user) {
diff --git a/src/domain/data/MetricsService.js b/src/domain/data/MetricsService.js
index 996ccf0b5d7e9ad1fd76d6393582832c5d0d8805..832087609270e732c08eeaf41141dc82bc82abc8 100644
--- a/src/domain/data/MetricsService.js
+++ b/src/domain/data/MetricsService.js
@@ -15,7 +15,7 @@ module.exports = class MetricsService extends ContextualModule {
 		return ['queryAggregate', 'queryAll', 'recordMetric']
 	}
 
-	async recordMetric(value, type, location) {
+	async recordMetric(value, type, location, meta = {}, transaction) {
 		const user = await this.ctx.services['core.auth'].getUser()
 		const point = {
 			type: 'Point',
@@ -26,11 +26,15 @@ module.exports = class MetricsService extends ContextualModule {
 			type,
 			location: point,
 			author_id: user ? user.id : null,
+			meta,
 		}
 		if (this.ctx.request.device) {
-			payload.meta = { device: this.ctx.request.device }
+			payload.meta = {
+				...(payload.meta ?? {}),
+				device: this.ctx.request.device,
+			}
 		}
-		return await Metric.create(payload)
+		return await Metric.create(payload, { transaction })
 	}
 
 	async queryAggregate(pointBuffer, types, from, to) {
diff --git a/src/http/controllers/api/auth.js b/src/http/controllers/api/auth.js
index 126913a45670956e270b4259b7d2af05ea09c10c..08f9fa98862909836c546b9e01b8e2b71b519efb 100644
--- a/src/http/controllers/api/auth.js
+++ b/src/http/controllers/api/auth.js
@@ -55,8 +55,6 @@ exports.triggerPasswordReset = async ctx => {
 		})
 	}
 
-	console.log(ctx.request.device)
-
 	await queue.dispatch('send-user-password-reset', {
 		email,
 		device: ctx.request.device,
diff --git a/src/http/controllers/api/content.js b/src/http/controllers/api/content.js
index b65487206a8c072e967154514e7c3eefd2a71a33..1f126df9f4a691a6499f3268974f72223aad764c 100644
--- a/src/http/controllers/api/content.js
+++ b/src/http/controllers/api/content.js
@@ -5,8 +5,52 @@ const moment = require('moment')
 
 exports.postMetric = async ctx => {
 	const allowedTypes = new Set(Metric.getSupportedMetricTypes())
-	const { value, type, location } = ctx.request.body
+	const { value, type, location, meta, batch } = ctx.request.body
 
+	let metric
+	let metrics
+
+	if (batch) {
+		metrics = []
+		try {
+			await sequelize.transaction(async t => {
+				for (const m of batch) {
+					const result = await save(m.value, m.type, m.location, m.meta, {
+						allowedTypes,
+						transaction: t,
+						ctx,
+					})
+
+					metrics.push(result)
+				}
+			})
+		} catch (e) {
+			console.log(e)
+
+			throw new HttpError({
+				status: 400,
+				code: 'MTR-001',
+				title: 'Failed to save metric',
+				description: 'Something went wrong trying to save the metric',
+			})
+		}
+
+		ctx.body = {
+			metrics: metrics.map(metric => metric.toJSON()),
+		}
+	} else {
+		metric = await save(value, type, location, meta, { allowedTypes, ctx })
+		ctx.body = { metric: metric.toJSON() }
+	}
+}
+
+const save = async (
+	value,
+	type,
+	location,
+	meta,
+	{ allowedTypes, transaction, ctx },
+) => {
 	if (
 		location == null ||
 		location.longitude == null ||
@@ -21,12 +65,13 @@ exports.postMetric = async ctx => {
 	}
 
 	if (allowedTypes.has(type)) {
-		const metric = await ctx.services['data.metrics'].recordMetric(
+		return await ctx.services['data.metrics'].recordMetric(
 			value,
 			type,
 			location,
+			meta,
+			transaction,
 		)
-		ctx.body = { metric: metric.toJSON() }
 	} else {
 		throw new HttpError({
 			status: 400,
diff --git a/src/http/controllers/api/user.js b/src/http/controllers/api/user.js
index dbb3ee8fb1ff2064d4c3f5d40ecc1cef5b346804..7c100458b8f026af5fddecdeb2a4409b351fbf6f 100644
--- a/src/http/controllers/api/user.js
+++ b/src/http/controllers/api/user.js
@@ -3,10 +3,16 @@ const HttpError = require('core/errors/HttpError')
 exports.self = async ctx => {
 	const user = await ctx.services['core.auth'].getUser()
 	if (user) {
-		await user.handleIncludes(ctx.includes)
+		ctx.body = {
+			user: await user.serialise(),
+		}
+	} else {
+		throw new HttpError({
+			status: 404,
+			title: 'No such user',
+			description: 'No user is currently logged in',
+		})
 	}
-
-	ctx.body = { user }
 }
 
 exports.updateSelf = async ctx => {
diff --git a/src/http/controllers/api/v2/uploads.js b/src/http/controllers/api/v2/uploads.js
new file mode 100644
index 0000000000000000000000000000000000000000..62f03586532c700bf6cd8bb2ebcc8689f5f44a75
--- /dev/null
+++ b/src/http/controllers/api/v2/uploads.js
@@ -0,0 +1,30 @@
+exports.createUpload = async ctx => {
+	const { fs } = require('services')
+	const { v4: uuid } = require('uuid')
+	const { config } = require('bootstrap')
+	const HttpError = require('core/errors/HttpError')
+
+	const { content_type, id } = ctx.request.body
+	const user = await ctx.services['core.auth'].getUser()
+	if (!user) {
+		throw new HttpError(403, 'You must be logged in to upload an image')
+	}
+
+	const payload = await fs.createUploadUrl(
+		`${user.id}/${id ?? uuid()}.jpg`,
+		60 * 60 * 2,
+		{
+			contentType: content_type,
+		},
+	)
+
+	const upload = await user.createUpload({
+		provider: config('fs.driver'),
+		upload_url: payload.url,
+		request_params: payload,
+		expires_at: payload.expires_at_ms,
+		status: 'pending',
+	})
+
+	ctx.body = { upload: payload }
+}
diff --git a/src/http/routers/routes_v2.js b/src/http/routers/routes_v2.js
index cc55705e627026a13dedaf911a9fd8ada83a0b41..2ad5574c333bc6be51b0f11220f15b29e9f1a97f 100644
--- a/src/http/routers/routes_v2.js
+++ b/src/http/routers/routes_v2.js
@@ -1,77 +1,103 @@
 const Router = require('@koa/router')
 const { apiMiddlewareGroup } = require('../middleware/groups')
-const router = new Router()
+const router = new Router({ prefix: '/v2' })
 
 const controller = (path, handler) => require(`../controllers/${path}`)[handler]
 const param = name => require(`../params/${name}`)
 
 apiMiddlewareGroup.forEach(middleware => router.use(middleware))
 
-router.post('/auth/login')
-router.post('/auth/register')
-router.post('/auth/password-reset')
-
-router.get('/self')
-router.get('/self/bundles')
-router.get('/self/:property')
-
-router.get('/metrics')
-router.post('/metrics')
-
-router.get('/images')
-router.post('/images')
-router.post('/images/:imageId/share')
-
-router.post('/an/ev', controller('api/analytics', 'track'))
-
-api.post('/metrics', controller('api/content', 'postMetric'))
-api.get('/metrics', controller('api/content', 'getWithin'))
-
-api.get('/images', controller('api/storage', 'getFiles'))
-api.post(
-	'/images',
-	upload.single('featured_image'),
-	controller('api/storage', 'saveFile'),
+const noop = ctx =>
+	(ctx.body = {
+		body: ctx.request.body,
+		headers: ctx.request.headers,
+		path: ctx.path,
+		query: ctx.query,
+	})
+
+router.get(
+	'/',
+	ctx =>
+		(ctx.body = {
+			name: 'Jetsam Data API',
+			prefix: ctx.path,
+		}),
 )
-api.post('/images/:imageId/feature', controller('api/storage', 'featureImage'))
 
-/** @deprecated */
-api.post(
-	'/feature',
-	upload.single('featured_image'),
-	controller('api/storage', 'saveFile'),
+router.post('/auth/login', controller('api/auth', 'login'))
+router.post('/auth/register', controller('api/auth', 'register'))
+router.post(
+	'/auth/password-reset',
+	controller('api/auth', 'triggerPasswordReset'),
 )
 
-api.get('/feed', controller('api/storage', 'feed'))
-api.post('/feed/:fileId/like', controller('api/storage', 'like'))
-api.post('/feed/:fileId/unlike', controller('api/storage', 'unlike'))
-
-api.post('/register', controller('api/auth', 'register'))
-api.post('/login', controller('api/auth', 'login'))
-
-api.post('/auth/reset-token', controller('api/auth', 'triggerPasswordReset'))
-api.post('/auth/reset-password', controller('api/auth', 'handlePasswordReset'))
+router.get('/self', controller('api/user', 'self'))
+router.get('/self/bundles', controller('api/app', 'getBundles'))
+router.put('/self/:property', controller('api/user', 'updateOne'))
 
-api.param('oauthClientId', param('oauth_client'))
+router.get('/metrics', controller('api/content', 'getWithin'))
+router.post('/metrics', controller('api/content', 'postMetric'))
 
-api.get('/oauth/clients', controller('api/oauth', 'listClients'))
-api.post('/oauth/clients', controller('api/oauth', 'createClient'))
-api.post(
-	'/oauth/clients/:oauthClientId/redirects',
-	controller('api/oauth', 'addClientRedirect'),
-)
-api.delete(
-	'/oauth/clients/:oauthClientId/redirects',
-	controller('api/oauth', 'removeClientRedirect'),
-)
+router.get('/images', noop)
+router.post('/images', noop)
+router.post('/images/:imageId/share', noop)
 
-api.get('/self', controller('api/user', 'self'))
-api.get('/self/bundles', controller('api/app', 'getBundles'))
-api.put('/self/:property', controller('api/user', 'updateOne'))
+router.get('/uploads', noop)
+router.post('/uploads', controller('api/v2/uploads', 'createUpload'))
+router.get('/uploads/:upload_id', noop)
+router.delete('/uploads/:upload_id', noop)
+router.put('/uploads/:upload_id/:property', noop)
 
-api.post('/an/id', async ctx => {})
-api.post('/an/ev', controller('api/analytics', 'track'))
+router.post('/an/ev', controller('api/analytics', 'track'))
 
-api.post('/feedback', controller('api/feedback', 'send'))
+// api.post('/metrics', controller('api/content', 'postMetric'))
+// api.get('/metrics', controller('api/content', 'getWithin'))
+//
+// api.get('/images', controller('api/storage', 'getFiles'))
+// api.post(
+// 	'/images',
+// 	upload.single('featured_image'),
+// 	controller('api/storage', 'saveFile'),
+// )
+// api.post('/images/:imageId/feature', controller('api/storage', 'featureImage'))
+//
+// /** @deprecated */
+// api.post(
+// 	'/feature',
+// 	upload.single('featured_image'),
+// 	controller('api/storage', 'saveFile'),
+// )
+//
+// api.get('/feed', controller('api/storage', 'feed'))
+// api.post('/feed/:fileId/like', controller('api/storage', 'like'))
+// api.post('/feed/:fileId/unlike', controller('api/storage', 'unlike'))
+//
+// api.post('/register', controller('api/auth', 'register'))
+// api.post('/login', controller('api/auth', 'login'))
+//
+// api.post('/auth/reset-token', controller('api/auth', 'triggerPasswordReset'))
+// api.post('/auth/reset-password', controller('api/auth', 'handlePasswordReset'))
+//
+// api.param('oauthClientId', param('oauth_client'))
+//
+// api.get('/oauth/clients', controller('api/oauth', 'listClients'))
+// api.post('/oauth/clients', controller('api/oauth', 'createClient'))
+// api.post(
+// 	'/oauth/clients/:oauthClientId/redirects',
+// 	controller('api/oauth', 'addClientRedirect'),
+// )
+// api.delete(
+// 	'/oauth/clients/:oauthClientId/redirects',
+// 	controller('api/oauth', 'removeClientRedirect'),
+// )
+//
+// api.get('/self', controller('api/user', 'self'))
+// api.get('/self/bundles', controller('api/app', 'getBundles'))
+// api.put('/self/:property', controller('api/user', 'updateOne'))
+//
+// api.post('/an/id', async ctx => {})
+// api.post('/an/ev', controller('api/analytics', 'track'))
+//
+// api.post('/feedback', controller('api/feedback', 'send'))
 
 module.exports = router
diff --git a/src/http/routes.js b/src/http/routes.js
index 648d4c54d93c11a71dea923254616764dc7e2756..9573e0028dea83887d4c8e45d665d8ed6c195114 100644
--- a/src/http/routes.js
+++ b/src/http/routes.js
@@ -7,6 +7,7 @@ const { env, config } = require('bootstrap')
 const Router = require('@koa/router')
 const multer = require('@koa/multer')
 const upload = multer({ dest: '/tmp/' })
+const attach = require('koa-mount')
 
 const context = require('http/middleware/ThreadContextWrapper')
 const errors = require('http/middleware/ErrorHandler')
@@ -16,6 +17,8 @@ const loaders = require('http/middleware/MountLoaders')
 const userGate = require('http/middleware/RequiresAuth')
 const device = require('http/middleware/DeviceProperties').extractDevice
 
+const v2 = require('./routers/routes_v2')
+
 const well_known = new Router({ prefix: '/.well-known' })
 well_known.get('wk.jwks', '/jwks.json', async ctx => {
 	const { getKeys } = require('core/utils/jwt')
@@ -96,6 +99,7 @@ function mount(api) {
 		ctx.body = {
 			name: 'Jetsam Data API',
 			version: pkg.version,
+			prefix: ctx.path,
 		}
 	})
 
@@ -154,6 +158,9 @@ function mount(api) {
 	api.post('/an/ev', controller('api/analytics', 'track'))
 
 	api.post('/feedback', controller('api/feedback', 'send'))
+
+	api.use(v2.allowedMethods())
+	api.use(v2.routes())
 }
 
 mount(apiRouter)
diff --git a/src/services/fs/gcs.js b/src/services/fs/gcs.js
index 55ac411f66981e8334a7b6f61a5aed57b1abf814..dba7eb2bf82190d7a1d6fda1696f6f2ae17474af 100644
--- a/src/services/fs/gcs.js
+++ b/src/services/fs/gcs.js
@@ -66,9 +66,10 @@ class GCSFS extends FS {
 		return files
 	}
 	async createUploadUrl(path, ttl, opts) {
+		const ttl_ms = ttl * 1000
 		const gopts = {
 			version: 'v4',
-			expires: Date.now() + ttl * 1000,
+			expires: Date.now() + ttl_ms,
 			action: 'write',
 			contentType: getContentType(opts.headers ?? {}) ?? opts.contentType,
 		}
@@ -79,9 +80,9 @@ class GCSFS extends FS {
 			url,
 			expires_at_ms: gopts.expires,
 			method: 'PUT',
-			headers: opts.headers
-				? { 'Content-Type': getContentType(opts.headers) ?? opts.contentType }
-				: {},
+			headers: {
+				'Content-Type': gopts.contentType,
+			},
 		}
 	}
 	async createDownloadUrl(path, ttl, opts) {
@@ -124,9 +125,10 @@ class GCSFS extends FS {
 
 function getContentType(obj) {
 	return (
-		obj.headers?.['Content-Type'] ??
-		obj.headers?.['content-type'] ??
-		obj.headers?.contentType
+		obj?.headers?.['Content-Type'] ??
+		obj?.headers?.['content-type'] ??
+		obj?.headers?.contentType ??
+		null
 	)
 }
 
diff --git a/views/auth/reset-password-error.hbs b/views/auth/reset-password-error.hbs
index c4af0693152653c2da9c7d90fd50e90f0174c9b0..b1a54e3c26ff450c5b895495b065fa56d6eeb48e 100644
--- a/views/auth/reset-password-error.hbs
+++ b/views/auth/reset-password-error.hbs
@@ -12,33 +12,25 @@
 
 	<meta name="viewport" content="width=device-width, initial-scale=1">
 
-	<link rel="shortcut icon" type="image/png" href="https://jetsam.tech/images/logo.png">
-	<link rel="stylesheet"
-		  href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css"
-		  integrity="sha256-l85OmPOjvil/SOvVt3HnSSjzF1TUMyT9eV0c2BzEGzU="
-		  crossorigin="anonymous" />
-	<link rel="stylesheet"
-		  href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css"
-		  integrity="sha256-2YQRJMXD7pIAPHiXr0s+vlRWA7GYJEK0ARns7k2sbHY="
-		  crossorigin="anonymous" />
-
-	<link rel="stylesheet" href="/css/main.css?v=2">
+	<link rel="shortcut icon" type="image/png" href="https://app.jetsam.tech/logo.png">
+	<link rel="stylesheet" href="/main.css?v=2">
 </head>
 
 <body>
-<main class="container" style="max-width: 600px">
-	<header class="header">
-		<img class="logo" src="https://jetsam.tech/images/logo.png" width="128px" height="128px">
-		<div>
-			<h1>Jetsam</h1>
-			<h3>Your World; Cleaner</h3>
+<main class="container">
+	<div class="inner">
+		<div class="row header">
+			<img class="logo" src="/logo.png" width="128px" height="128px">
+			<div class="text-container">
+				<h1>Jetsam</h1>
+				<h3>Your World; Cleaner</h3>
+			</div>
+		</div>
+		<h3 class="centered">Password Reset Error</h3>
+		<p class="centered">{{ message }}</p>
+		<div class="row centered">
+			<a href="{{ back_link }}">Go Back</a>
 		</div>
-	</header>
-
-	<h3 class="centered">Password Reset Error</h3>
-	<p class="centered">{{ message }}</p>
-	<div class="row centered">
-		<a href="{{ back_link }}">Go Back</a>
 	</div>
 </main>
 
diff --git a/views/auth/reset-password-success.hbs b/views/auth/reset-password-success.hbs
index 16dee630eabb53c88ca82323eef54442adfd270f..304ff98255d15900c05090fe035ff961eb30363d 100644
--- a/views/auth/reset-password-success.hbs
+++ b/views/auth/reset-password-success.hbs
@@ -12,35 +12,30 @@
 
 	<meta name="viewport" content="width=device-width, initial-scale=1">
 
-	<link rel="shortcut icon" type="image/png" href="https://jetsam.tech/images/logo.png">
-	<link rel="stylesheet"
-		  href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css"
-		  integrity="sha256-l85OmPOjvil/SOvVt3HnSSjzF1TUMyT9eV0c2BzEGzU="
-		  crossorigin="anonymous" />
-	<link rel="stylesheet"
-		  href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css"
-		  integrity="sha256-2YQRJMXD7pIAPHiXr0s+vlRWA7GYJEK0ARns7k2sbHY="
-		  crossorigin="anonymous" />
+	<link rel="shortcut icon" type="image/png" href="https://app.jetsam.tech/logo.png">
 
-	<link rel="stylesheet" href="/css/main.css?v=2">
+	<link rel="stylesheet" href="/main.css?v=2">
 </head>
 
 <body>
-<main class="container" style="max-width: 600px">
-	<header class="header">
-		<img class="logo" src="https://jetsam.tech/images/logo.png" width="128px" height="128px">
-		<div>
-			<h1>Jetsam</h1>
-			<h3>Your World; Cleaner</h3>
-		</div>
-	</header>
 
-	<h3 class="centered">Password Reset Successful</h3>
-	<p class="centered">You successfully reset your password. You can log in to the Jetsam app with the password you just created. Now go and snap some pesky plastics!</p>
-	<div class="row centered">
-		<a href="https://jetsam.tech">Go to the Jetsam website</a>
+<main class="container">
+	<div class="inner">
+		<div class="row header">
+			<img class="logo" src="/logo.png" width="128px" height="128px">
+			<div class="text-container">
+				<h1>Jetsam</h1>
+				<h3>Your World; Cleaner</h3>
+			</div>
+		</div>
+		<h3 class="centered">Password Reset Successful</h3>
+		<p class="centered">You successfully reset your password. You can log in to the Jetsam app with the password you just created. Now go and snap some pesky plastics!</p>
+		<div class="row centered">
+			<a href="https://jetsam.tech">Go to the Jetsam website</a>
+		</div>
 	</div>
 </main>
 
 </body>
 </html>
+
diff --git a/views/auth/reset-password.hbs b/views/auth/reset-password.hbs
index e74337f344cc347175f04d64114baa10726633e8..0b6ee245d2a631928609f6c86c278880d0f68eb2 100644
--- a/views/auth/reset-password.hbs
+++ b/views/auth/reset-password.hbs
@@ -12,44 +12,46 @@
 
 	<meta name="viewport" content="width=device-width, initial-scale=1">
 
-	<link rel="shortcut icon" type="image/png" href="https://jetsam.tech/images/logo.png">
-	<link rel="stylesheet"
-		  href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css"
-		  integrity="sha256-l85OmPOjvil/SOvVt3HnSSjzF1TUMyT9eV0c2BzEGzU="
-		  crossorigin="anonymous" />
-	<link rel="stylesheet"
-		  href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css"
-		  integrity="sha256-2YQRJMXD7pIAPHiXr0s+vlRWA7GYJEK0ARns7k2sbHY="
-		  crossorigin="anonymous" />
+	<link rel="shortcut icon" type="image/png" href="https://app.jetsam.tech/logo.png">
+<!--	<link rel="stylesheet"-->
+<!--		  href="https://cdnjs.cloudflare.com/ajax/libs/normalize/8.0.1/normalize.min.css"-->
+<!--		  integrity="sha256-l85OmPOjvil/SOvVt3HnSSjzF1TUMyT9eV0c2BzEGzU="-->
+<!--		  crossorigin="anonymous" />-->
+<!--	<link rel="stylesheet"-->
+<!--		  href="https://cdnjs.cloudflare.com/ajax/libs/skeleton/2.0.4/skeleton.min.css"-->
+<!--		  integrity="sha256-2YQRJMXD7pIAPHiXr0s+vlRWA7GYJEK0ARns7k2sbHY="-->
+<!--		  crossorigin="anonymous" />-->
+
+	<link rel="stylesheet" href="/main.css?v=2">
 
-	<link rel="stylesheet" href="/css/main.css?v=2">
 </head>
 
 <body>
-<main class="container" style="max-width: 600px">
-	<header class="header">
-		<img class="logo" src="https://jetsam.tech/images/logo.png" width="128px" height="128px">
-		<div>
-			<h1>Jetsam</h1>
-			<h3>Your World; Cleaner</h3>
+<main class="container">
+	<div class="inner">
+		<div class="row header">
+			<img class="logo" src="/logo.png" width="128px" height="128px">
+			<div class="text-container">
+				<h1>Jetsam</h1>
+				<h3>Your World; Cleaner</h3>
+			</div>
 		</div>
-	</header>
 
 	<form method="POST" action="/reset-password" id="reset_password_form">
-		<h3>Reset Password</h3>
-		<p>
+		<h3 class="centered">Reset Password</h3>
+		<p class="centered">
 			You've requested a reset of your password; please use this form to choose a new one. This link is valid for 1 hour from the time we send it to you,
-			so if you've gone to grab a cup of tea between requesting a new password and using this form, you may need to request another password reset!
+			so if you've gone to grab a cup of tea between requesting a new password and using this form, you may need to request another password reset.
 		</p>
 		<input id=reset_token name=reset_token required type=hidden class="u-full-width" value="{{token}}">
-		<div class="row">
-			<div class="twelve columns">
+		<div class="row centered">
+			<div class="controls">
 				<label for=new_password><sup class="required" title="Required">*</sup>New Password:</label>
 				<input id=new_password name=new_password class="u-full-width" required type="password">
 			</div>
 		</div>
-		<div class="row">
-			<div class="twelve columns">
+		<div class="row centered">
+			<div class="controls">
 				<label for=confirm_password><sup class="required" title="Required">*</sup>Confirm Password:</label>
 				<input id=confirm_password name=confirm_password class="u-full-width" required type="password">
 			</div>
@@ -61,8 +63,13 @@
 		</div>
 		<div id="formmessage" class="row" style="color: red"></div>
 	</form>
+
+	</div>
 </main>
 
+
+
+
 <script async>
 	(function() {
 		var script = document.createElement('script');