From 3178bb20c72380c4379e7b72afa7e468d24e3e97 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sat, 31 Mar 2018 21:41:08 +0900
Subject: [PATCH 01/28] Use Vue rendering function

and some refactors
---
 .../app/common/views/components/index.ts      |   2 +-
 .../components/messaging-room.message.vue     |  41 ++---
 .../app/common/views/components/post-html.ts  | 157 ++++++++++++++++++
 .../app/common/views/components/post-html.vue | 103 ------------
 .../app/common/views/components/url.vue       |  57 +++++++
 .../views/components/welcome-timeline.vue     |   2 +-
 .../views/components/post-detail.sub.vue      |   2 +-
 .../desktop/views/components/post-detail.vue  |  42 +++--
 .../desktop/views/components/posts.post.vue   |  41 ++---
 .../views/components/sub-post-content.vue     |   2 +-
 .../mobile/views/components/post-detail.vue   |  37 +++--
 .../app/mobile/views/components/post.vue      |  43 ++---
 .../views/components/sub-post-content.vue     |   2 +-
 src/common/text/parse/index.ts                |   2 +-
 14 files changed, 322 insertions(+), 211 deletions(-)
 create mode 100644 src/client/app/common/views/components/post-html.ts
 delete mode 100644 src/client/app/common/views/components/post-html.vue
 create mode 100644 src/client/app/common/views/components/url.vue

diff --git a/src/client/app/common/views/components/index.ts b/src/client/app/common/views/components/index.ts
index 8c10bdee28..b58ba37ecb 100644
--- a/src/client/app/common/views/components/index.ts
+++ b/src/client/app/common/views/components/index.ts
@@ -4,7 +4,7 @@ import signin from './signin.vue';
 import signup from './signup.vue';
 import forkit from './forkit.vue';
 import nav from './nav.vue';
-import postHtml from './post-html.vue';
+import postHtml from './post-html';
 import poll from './poll.vue';
 import pollEditor from './poll-editor.vue';
 import reactionIcon from './reaction-icon.vue';
diff --git a/src/client/app/common/views/components/messaging-room.message.vue b/src/client/app/common/views/components/messaging-room.message.vue
index 25ceab85a1..91af26bffe 100644
--- a/src/client/app/common/views/components/messaging-room.message.vue
+++ b/src/client/app/common/views/components/messaging-room.message.vue
@@ -4,13 +4,13 @@
 		<img class="avatar" :src="`${message.user.avatarUrl}?thumbnail&size=80`" alt=""/>
 	</router-link>
 	<div class="content">
-		<div class="balloon" :data-no-text="message.textHtml == null">
+		<div class="balloon" :data-no-text="message.text == null">
 			<p class="read" v-if="isMe && message.isRead">%i18n:common.tags.mk-messaging-message.is-read%</p>
 			<button class="delete-button" v-if="isMe" title="%i18n:common.delete%">
 				<img src="/assets/desktop/messaging/delete.png" alt="Delete"/>
 			</button>
 			<div class="content" v-if="!message.isDeleted">
-				<mk-post-html class="text" v-if="message.textHtml" ref="text" :html="message.textHtml" :i="os.i"/>
+				<mk-post-html class="text" v-if="message.text" ref="text" :text="message.text" :i="os.i"/>
 				<div class="file" v-if="message.file">
 					<a :href="message.file.url" target="_blank" :title="message.file.name">
 						<img v-if="message.file.type.split('/')[0] == 'image'" :src="message.file.url" :alt="message.file.name"/>
@@ -35,35 +35,30 @@
 <script lang="ts">
 import Vue from 'vue';
 import getAcct from '../../../../../common/user/get-acct';
+import parse from '../../../../../common/text/parse';
 
 export default Vue.extend({
-	props: ['message'],
-	data() {
-		return {
-			urls: []
-		};
+	props: {
+		message: {
+			required: true
+		}
 	},
 	computed: {
-		acct() {
+		acct(): string {
 			return getAcct(this.message.user);
 		},
 		isMe(): boolean {
 			return this.message.userId == (this as any).os.i.id;
-		}
-	},
-	watch: {
-		message: {
-			handler(newMessage, oldMessage) {
-				if (!oldMessage || newMessage.textHtml !== oldMessage.textHtml) {
-					this.$nextTick(() => {
-						const elements = this.$refs.text.$el.getElementsByTagName('a');
-
-						this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin)
-							.map(({ href }) => href);
-					});
-				}
-			},
-			immediate: true
+		},
+		urls(): string[] {
+			if (this.message.text) {
+				const ast = parse(this.message.text);
+				return ast
+					.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
+					.map(t => t.url);
+			} else {
+				return null;
+			}
 		}
 	}
 });
diff --git a/src/client/app/common/views/components/post-html.ts b/src/client/app/common/views/components/post-html.ts
new file mode 100644
index 0000000000..c5c3b72758
--- /dev/null
+++ b/src/client/app/common/views/components/post-html.ts
@@ -0,0 +1,157 @@
+import Vue from 'vue';
+import * as emojilib from 'emojilib';
+import parse from '../../../../../common/text/parse';
+import getAcct from '../../../../../common/user/get-acct';
+import { url } from '../../../config';
+import MkUrl from './url.vue';
+
+const flatten = list => list.reduce(
+	(a, b) => a.concat(Array.isArray(b) ? flatten(b) : b), []
+);
+
+export default Vue.component('mk-post-html', {
+	props: {
+		text: {
+			type: String,
+			required: true
+		},
+		ast: {
+			type: [],
+			required: false
+		},
+		shouldBreak: {
+			type: Boolean,
+			default: true
+		},
+		i: {
+			type: Object,
+			default: null
+		}
+	},
+
+	render(createElement) {
+		let ast;
+
+		if (this.ast == null) {
+			// Parse text to ast
+			ast = parse(this.text);
+		} else {
+			ast = this.ast;
+		}
+
+		// Parse ast to DOM
+		const els = flatten(ast.map(token => {
+			switch (token.type) {
+				case 'text':
+					const text = token.content.replace(/(\r\n|\n|\r)/g, '\n');
+
+					if (this.shouldBreak) {
+						const x = text.split('\n')
+							.map(t => t == '' ? [createElement('br')] : [createElement('span', t), createElement('br')]);
+						x[x.length - 1].pop();
+						return x;
+					} else {
+						return createElement('span', text.replace(/\n/g, ' '));
+					}
+
+				case 'bold':
+					return createElement('strong', token.bold);
+
+				case 'url':
+					return createElement(MkUrl, {
+						props: {
+							url: token.content,
+							target: '_blank'
+						}
+					});
+
+				case 'link':
+					return createElement('a', {
+						attrs: {
+							class: 'link',
+							href: token.url,
+							target: '_blank',
+							title: token.url
+						}
+					}, token.title);
+
+				case 'mention':
+					return (createElement as any)('a', {
+						attrs: {
+							href: `${url}/@${getAcct(token)}`,
+							target: '_blank',
+							dataIsMe: (this as any).i && getAcct((this as any).i) == getAcct(token)
+						},
+						directives: [{
+							name: 'user-preview',
+							value: token.content
+						}]
+					}, token.content);
+
+				case 'hashtag':
+					return createElement('a', {
+						attrs: {
+							href: `${url}/search?q=${token.content}`,
+							target: '_blank'
+						}
+					}, token.content);
+
+				case 'code':
+					return createElement('pre', [
+						createElement('code', {
+							domProps: {
+								innerHTML: token.html
+							}
+						})
+					]);
+
+				case 'inline-code':
+					return createElement('code', {
+						domProps: {
+							innerHTML: token.html
+						}
+					});
+
+				case 'quote':
+					const text2 = token.quote.replace(/(\r\n|\n|\r)/g, '\n');
+
+					if (this.shouldBreak) {
+						const x = text2.split('\n')
+							.map(t => [createElement('span', t), createElement('br')]);
+						x[x.length - 1].pop();
+						return createElement('div', {
+							attrs: {
+								class: 'quote'
+							}
+						}, x);
+					} else {
+						return createElement('span', {
+							attrs: {
+								class: 'quote'
+							}
+						}, text2.replace(/\n/g, ' '));
+					}
+
+				case 'emoji':
+					const emoji = emojilib.lib[token.emoji];
+					return createElement('span', emoji ? emoji.char : token.content);
+
+				default:
+					console.log('unknown ast type:', token.type);
+			}
+		}));
+
+		const _els = [];
+		els.forEach((el, i) => {
+			if (el.tag == 'br') {
+				if (els[i - 1].tag != 'div') {
+					_els.push(el);
+				}
+			} else {
+				_els.push(el);
+			}
+		});
+
+		return createElement('span', _els);
+	}
+});
diff --git a/src/client/app/common/views/components/post-html.vue b/src/client/app/common/views/components/post-html.vue
deleted file mode 100644
index 1c949052b9..0000000000
--- a/src/client/app/common/views/components/post-html.vue
+++ /dev/null
@@ -1,103 +0,0 @@
-<template><div class="mk-post-html" v-html="html"></div></template>
-
-<script lang="ts">
-import Vue from 'vue';
-import getAcct from '../../../../../common/user/get-acct';
-import { url } from '../../../config';
-
-function markUrl(a) {
-	while (a.firstChild) {
-		a.removeChild(a.firstChild);
-	}
-
-	const schema = document.createElement('span');
-	const delimiter = document.createTextNode('//');
-	const host = document.createElement('span');
-	const pathname = document.createElement('span');
-	const query = document.createElement('span');
-	const hash = document.createElement('span');
-
-	schema.className = 'schema';
-	schema.textContent = a.protocol;
-
-	host.className = 'host';
-	host.textContent = a.host;
-
-	pathname.className = 'pathname';
-	pathname.textContent = a.pathname;
-
-	query.className = 'query';
-	query.textContent = a.search;
-
-	hash.className = 'hash';
-	hash.textContent = a.hash;
-
-	a.appendChild(schema);
-	a.appendChild(delimiter);
-	a.appendChild(host);
-	a.appendChild(pathname);
-	a.appendChild(query);
-	a.appendChild(hash);
-}
-
-function markMe(me, a) {
-	a.setAttribute("data-is-me", me && `${url}/@${getAcct(me)}` == a.href);
-}
-
-function markTarget(a) {
-	a.setAttribute("target", "_blank");
-}
-
-export default Vue.component('mk-post-html', {
-	props: {
-		html: {
-			type: String,
-			required: true
-		},
-		i: {
-			type: Object,
-			default: null
-		}
-	},
-	watch {
-		html: {
-			handler() {
-				this.$nextTick(() => [].forEach.call(this.$el.getElementsByTagName('a'), a => {
-					if (a.href === a.textContent) {
-						markUrl(a);
-					} else {
-						markMe((this as any).i, a);
-					}
-
-					markTarget(a);
-				}));
-			},
-			immediate: true,
-		}
-	}
-});
-</script>
-
-<style lang="stylus">
-.mk-post-html
-	a
-		word-break break-all
-
-		> .schema
-			opacity 0.5
-
-		> .host
-			font-weight bold
-
-		> .pathname
-			opacity 0.8
-
-		> .query
-			opacity 0.5
-
-		> .hash
-			font-style italic
-
-	p
-		margin 0
-</style>
diff --git a/src/client/app/common/views/components/url.vue b/src/client/app/common/views/components/url.vue
new file mode 100644
index 0000000000..e6ffe4466d
--- /dev/null
+++ b/src/client/app/common/views/components/url.vue
@@ -0,0 +1,57 @@
+<template>
+<a class="mk-url" :href="url" :target="target">
+	<span class="schema">{{ schema }}//</span>
+	<span class="hostname">{{ hostname }}</span>
+	<span class="port" v-if="port != ''">:{{ port }}</span>
+	<span class="pathname" v-if="pathname != ''">{{ pathname }}</span>
+	<span class="query">{{ query }}</span>
+	<span class="hash">{{ hash }}</span>
+	%fa:external-link-square-alt%
+</a>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+export default Vue.extend({
+	props: ['url', 'target'],
+	data() {
+		return {
+			schema: null,
+			hostname: null,
+			port: null,
+			pathname: null,
+			query: null,
+			hash: null
+		};
+	},
+	created() {
+		const url = new URL(this.url);
+		this.schema = url.protocol;
+		this.hostname = url.hostname;
+		this.port = url.port;
+		this.pathname = url.pathname;
+		this.query = url.search;
+		this.hash = url.hash;
+	}
+});
+</script>
+
+<style lang="stylus" scoped>
+.mk-url
+	word-break break-all
+	> [data-fa]
+		padding-left 2px
+		font-size .9em
+		font-weight 400
+		font-style normal
+	> .schema
+		opacity 0.5
+	> .hostname
+		font-weight bold
+	> .pathname
+		opacity 0.8
+	> .query
+		opacity 0.5
+	> .hash
+		font-style italic
+</style>
diff --git a/src/client/app/common/views/components/welcome-timeline.vue b/src/client/app/common/views/components/welcome-timeline.vue
index f379029f9f..09b090bdc1 100644
--- a/src/client/app/common/views/components/welcome-timeline.vue
+++ b/src/client/app/common/views/components/welcome-timeline.vue
@@ -15,7 +15,7 @@
 				</div>
 			</header>
 			<div class="text">
-				<mk-post-html :html="post.textHtml"/>
+				<mk-post-html :text="post.text"/>
 			</div>
 		</div>
 	</div>
diff --git a/src/client/app/desktop/views/components/post-detail.sub.vue b/src/client/app/desktop/views/components/post-detail.sub.vue
index b6148d9b28..1d5649cf92 100644
--- a/src/client/app/desktop/views/components/post-detail.sub.vue
+++ b/src/client/app/desktop/views/components/post-detail.sub.vue
@@ -16,7 +16,7 @@
 			</div>
 		</header>
 		<div class="body">
-			<mk-post-html v-if="post.textHtml" :html="post.textHtml" :i="os.i" :class="$style.text"/>
+			<mk-post-html v-if="post.text" :text="post.text" :i="os.i" :class="$style.text"/>
 			<div class="media" v-if="post.media > 0">
 				<mk-media-list :media-list="post.media"/>
 			</div>
diff --git a/src/client/app/desktop/views/components/post-detail.vue b/src/client/app/desktop/views/components/post-detail.vue
index e75ebe34b4..70bfdbba35 100644
--- a/src/client/app/desktop/views/components/post-detail.vue
+++ b/src/client/app/desktop/views/components/post-detail.vue
@@ -38,7 +38,7 @@
 			</router-link>
 		</header>
 		<div class="body">
-			<mk-post-html :class="$style.text" v-if="p.text" ref="text" :text="p.text" :i="os.i"/>
+			<mk-post-html :class="$style.text" v-if="p.text" :text="p.text" :i="os.i"/>
 			<div class="media" v-if="p.media.length > 0">
 				<mk-media-list :media-list="p.media"/>
 			</div>
@@ -79,6 +79,7 @@
 import Vue from 'vue';
 import dateStringify from '../../../common/scripts/date-stringify';
 import getAcct from '../../../../../common/user/get-acct';
+import parse from '../../../../../common/text/parse';
 
 import MkPostFormWindow from './post-form-window.vue';
 import MkRepostFormWindow from './repost-form-window.vue';
@@ -90,6 +91,7 @@ export default Vue.extend({
 	components: {
 		XSub
 	},
+
 	props: {
 		post: {
 			type: Object,
@@ -99,19 +101,15 @@ export default Vue.extend({
 			default: false
 		}
 	},
-	computed: {
-		acct() {
-			return getAcct(this.post.user);
-		}
-	},
+
 	data() {
 		return {
 			context: [],
 			contextFetching: false,
-			replies: [],
-			urls: []
+			replies: []
 		};
 	},
+
 	computed: {
 		isRepost(): boolean {
 			return (this.post.repost &&
@@ -131,8 +129,22 @@ export default Vue.extend({
 		},
 		title(): string {
 			return dateStringify(this.p.createdAt);
+		},
+		acct(): string {
+			return getAcct(this.p.user);
+		},
+		urls(): string[] {
+			if (this.p.text) {
+				const ast = parse(this.p.text);
+				return ast
+					.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
+					.map(t => t.url);
+			} else {
+				return null;
+			}
 		}
 	},
+
 	mounted() {
 		// Get replies
 		if (!this.compact) {
@@ -162,21 +174,7 @@ export default Vue.extend({
 			}
 		}
 	},
-	watch: {
-		post: {
-			handler(newPost, oldPost) {
-				if (!oldPost || newPost.text !== oldPost.text) {
-					this.$nextTick(() => {
-						const elements = this.$refs.text.$el.getElementsByTagName('a');
 
-						this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin)
-							.map(({ href }) => href);
-					});
-				}
-			},
-			immediate: true
-		}
-	},
 	methods: {
 		fetchContext() {
 			this.contextFetching = true;
diff --git a/src/client/app/desktop/views/components/posts.post.vue b/src/client/app/desktop/views/components/posts.post.vue
index f3566c81bf..c31e28d67f 100644
--- a/src/client/app/desktop/views/components/posts.post.vue
+++ b/src/client/app/desktop/views/components/posts.post.vue
@@ -38,7 +38,7 @@
 				</p>
 				<div class="text">
 					<a class="reply" v-if="p.reply">%fa:reply%</a>
-					<mk-post-html v-if="p.textHtml" ref="text" :html="p.textHtml" :i="os.i" :class="$style.text"/>
+					<mk-post-html v-if="p.textHtml" :text="p.text" :i="os.i" :class="$style.text"/>
 					<a class="rp" v-if="p.repost">RP:</a>
 				</div>
 				<div class="media" v-if="p.media.length > 0">
@@ -86,6 +86,8 @@
 import Vue from 'vue';
 import dateStringify from '../../../common/scripts/date-stringify';
 import getAcct from '../../../../../common/user/get-acct';
+import parse from '../../../../../common/text/parse';
+
 import MkPostFormWindow from './post-form-window.vue';
 import MkRepostFormWindow from './repost-form-window.vue';
 import MkPostMenu from '../../../common/views/components/post-menu.vue';
@@ -107,17 +109,19 @@ export default Vue.extend({
 	components: {
 		XSub
 	},
+
 	props: ['post'],
+
 	data() {
 		return {
 			isDetailOpened: false,
 			connection: null,
-			connectionId: null,
-			urls: []
+			connectionId: null
 		};
 	},
+
 	computed: {
-		acct() {
+		acct(): string {
 			return getAcct(this.p.user);
 		},
 		isRepost(): boolean {
@@ -141,14 +145,26 @@ export default Vue.extend({
 		},
 		url(): string {
 			return `/@${this.acct}/${this.p.id}`;
+		},
+		urls(): string[] {
+			if (this.p.text) {
+				const ast = parse(this.p.text);
+				return ast
+					.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
+					.map(t => t.url);
+			} else {
+				return null;
+			}
 		}
 	},
+
 	created() {
 		if ((this as any).os.isSignedIn) {
 			this.connection = (this as any).os.stream.getConnection();
 			this.connectionId = (this as any).os.stream.use();
 		}
 	},
+
 	mounted() {
 		this.capture(true);
 
@@ -174,6 +190,7 @@ export default Vue.extend({
 			}
 		}
 	},
+
 	beforeDestroy() {
 		this.decapture(true);
 
@@ -182,21 +199,7 @@ export default Vue.extend({
 			(this as any).os.stream.dispose(this.connectionId);
 		}
 	},
-	watch: {
-		post: {
-			handler(newPost, oldPost) {
-				if (!oldPost || newPost.textHtml !== oldPost.textHtml) {
-					this.$nextTick(() => {
-						const elements = this.$refs.text.$el.getElementsByTagName('a');
 
-						this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin)
-							.map(({ href }) => href);
-					});
-				}
-			},
-			immediate: true
-		}
-	},
 	methods: {
 		capture(withHandler = false) {
 			if ((this as any).os.isSignedIn) {
@@ -457,7 +460,7 @@ export default Vue.extend({
 					font-size 1.1em
 					color #717171
 
-					>>> blockquote
+					>>> .quote
 						margin 8px
 						padding 6px 12px
 						color #aaa
diff --git a/src/client/app/desktop/views/components/sub-post-content.vue b/src/client/app/desktop/views/components/sub-post-content.vue
index 58c81e7552..17899af280 100644
--- a/src/client/app/desktop/views/components/sub-post-content.vue
+++ b/src/client/app/desktop/views/components/sub-post-content.vue
@@ -2,7 +2,7 @@
 <div class="mk-sub-post-content">
 	<div class="body">
 		<a class="reply" v-if="post.replyId">%fa:reply%</a>
-		<mk-post-html ref="text" :html="post.textHtml" :i="os.i"/>
+		<mk-post-html :text="post.text" :i="os.i"/>
 		<a class="rp" v-if="post.repostId" :href="`/post:${post.repostId}`">RP: ...</a>
 	</div>
 	<details v-if="post.media.length > 0">
diff --git a/src/client/app/mobile/views/components/post-detail.vue b/src/client/app/mobile/views/components/post-detail.vue
index 77a73426f2..0a4e36fc60 100644
--- a/src/client/app/mobile/views/components/post-detail.vue
+++ b/src/client/app/mobile/views/components/post-detail.vue
@@ -81,6 +81,8 @@
 <script lang="ts">
 import Vue from 'vue';
 import getAcct from '../../../../../common/user/get-acct';
+import parse from '../../../../../common/text/parse';
+
 import MkPostMenu from '../../../common/views/components/post-menu.vue';
 import MkReactionPicker from '../../../common/views/components/reaction-picker.vue';
 import XSub from './post-detail.sub.vue';
@@ -89,6 +91,7 @@ export default Vue.extend({
 	components: {
 		XSub
 	},
+
 	props: {
 		post: {
 			type: Object,
@@ -98,19 +101,20 @@ export default Vue.extend({
 			default: false
 		}
 	},
+
 	data() {
 		return {
 			context: [],
 			contextFetching: false,
-			replies: [],
-			urls: []
+			replies: []
 		};
 	},
+
 	computed: {
-		acct() {
+		acct(): string {
 			return getAcct(this.post.user);
 		},
-		pAcct() {
+		pAcct(): string {
 			return getAcct(this.p.user);
 		},
 		isRepost(): boolean {
@@ -128,8 +132,19 @@ export default Vue.extend({
 					.map(key => this.p.reactionCounts[key])
 					.reduce((a, b) => a + b)
 				: 0;
+		},
+		urls(): string[] {
+			if (this.p.text) {
+				const ast = parse(this.p.text);
+				return ast
+					.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
+					.map(t => t.url);
+			} else {
+				return null;
+			}
 		}
 	},
+
 	mounted() {
 		// Get replies
 		if (!this.compact) {
@@ -159,21 +174,7 @@ export default Vue.extend({
 			}
 		}
 	},
-	watch: {
-		post: {
-			handler(newPost, oldPost) {
-				if (!oldPost || newPost.text !== oldPost.text) {
-					this.$nextTick(() => {
-						const elements = this.$refs.text.$el.getElementsByTagName('a');
 
-						this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin)
-							.map(({ href }) => href);
-					});
-				}
-			},
-			immediate: true
-		}
-	},
 	methods: {
 		fetchContext() {
 			this.contextFetching = true;
diff --git a/src/client/app/mobile/views/components/post.vue b/src/client/app/mobile/views/components/post.vue
index 96ec9632f1..f4f845b49a 100644
--- a/src/client/app/mobile/views/components/post.vue
+++ b/src/client/app/mobile/views/components/post.vue
@@ -37,7 +37,7 @@
 					<a class="reply" v-if="p.reply">
 						%fa:reply%
 					</a>
-					<mk-post-html v-if="p.text" ref="text" :text="p.text" :i="os.i" :class="$style.text"/>
+					<mk-post-html v-if="p.text" :text="p.text" :i="os.i" :class="$style.text"/>
 					<a class="rp" v-if="p.repost != null">RP:</a>
 				</div>
 				<div class="media" v-if="p.media.length > 0">
@@ -78,6 +78,8 @@
 <script lang="ts">
 import Vue from 'vue';
 import getAcct from '../../../../../common/user/get-acct';
+import parse from '../../../../../common/text/parse';
+
 import MkPostMenu from '../../../common/views/components/post-menu.vue';
 import MkReactionPicker from '../../../common/views/components/reaction-picker.vue';
 import XSub from './post.sub.vue';
@@ -86,19 +88,21 @@ export default Vue.extend({
 	components: {
 		XSub
 	},
+
 	props: ['post'],
+
 	data() {
 		return {
 			connection: null,
-			connectionId: null,
-			urls: []
+			connectionId: null
 		};
 	},
+
 	computed: {
-		acct() {
+		acct(): string {
 			return getAcct(this.post.user);
 		},
-		pAcct() {
+		pAcct(): string {
 			return getAcct(this.p.user);
 		},
 		isRepost(): boolean {
@@ -119,14 +123,26 @@ export default Vue.extend({
 		},
 		url(): string {
 			return `/@${this.pAcct}/${this.p.id}`;
+		},
+		urls(): string[] {
+			if (this.p.text) {
+				const ast = parse(this.p.text);
+				return ast
+					.filter(t => (t.type == 'url' || t.type == 'link') && !t.silent)
+					.map(t => t.url);
+			} else {
+				return null;
+			}
 		}
 	},
+
 	created() {
 		if ((this as any).os.isSignedIn) {
 			this.connection = (this as any).os.stream.getConnection();
 			this.connectionId = (this as any).os.stream.use();
 		}
 	},
+
 	mounted() {
 		this.capture(true);
 
@@ -152,6 +168,7 @@ export default Vue.extend({
 			}
 		}
 	},
+
 	beforeDestroy() {
 		this.decapture(true);
 
@@ -160,21 +177,7 @@ export default Vue.extend({
 			(this as any).os.stream.dispose(this.connectionId);
 		}
 	},
-	watch: {
-		post: {
-			handler(newPost, oldPost) {
-				if (!oldPost || newPost.text !== oldPost.text) {
-					this.$nextTick(() => {
-						const elements = this.$refs.text.$el.getElementsByTagName('a');
 
-						this.urls = [].filter.call(elements, ({ origin }) => origin !== location.origin)
-							.map(({ href }) => href);
-					});
-				}
-			},
-			immediate: true
-		}
-	},
 	methods: {
 		capture(withHandler = false) {
 			if ((this as any).os.isSignedIn) {
@@ -396,7 +399,7 @@ export default Vue.extend({
 					font-size 1.1em
 					color #717171
 
-					>>> blockquote
+					>>> .quote
 						margin 8px
 						padding 6px 12px
 						color #aaa
diff --git a/src/client/app/mobile/views/components/sub-post-content.vue b/src/client/app/mobile/views/components/sub-post-content.vue
index 955bb406b4..97dd987dd7 100644
--- a/src/client/app/mobile/views/components/sub-post-content.vue
+++ b/src/client/app/mobile/views/components/sub-post-content.vue
@@ -2,7 +2,7 @@
 <div class="mk-sub-post-content">
 	<div class="body">
 		<a class="reply" v-if="post.replyId">%fa:reply%</a>
-		<mk-post-html v-if="post.text" :ast="post.text" :i="os.i"/>
+		<mk-post-html v-if="post.text" :text="post.text" :i="os.i"/>
 		<a class="rp" v-if="post.repostId">RP: ...</a>
 	</div>
 	<details v-if="post.media.length > 0">
diff --git a/src/common/text/parse/index.ts b/src/common/text/parse/index.ts
index 1e2398dc38..b958da81b0 100644
--- a/src/common/text/parse/index.ts
+++ b/src/common/text/parse/index.ts
@@ -14,7 +14,7 @@ const elements = [
 	require('./elements/emoji')
 ];
 
-export default (source: string) => {
+export default (source: string): any[] => {
 
 	if (source == '') {
 		return null;

From a09613cdd47dbe2b68a8bc7f339103af769aaefe Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sat, 31 Mar 2018 21:46:58 +0900
Subject: [PATCH 02/28] Fix bug

---
 .../app/desktop/views/components/post-detail.vue      | 11 +++++++----
 1 file changed, 7 insertions(+), 4 deletions(-)

diff --git a/src/client/app/desktop/views/components/post-detail.vue b/src/client/app/desktop/views/components/post-detail.vue
index 70bfdbba35..d6481e13d0 100644
--- a/src/client/app/desktop/views/components/post-detail.vue
+++ b/src/client/app/desktop/views/components/post-detail.vue
@@ -27,13 +27,13 @@
 		</p>
 	</div>
 	<article>
-		<router-link class="avatar-anchor" :to="`/@${acct}`">
+		<router-link class="avatar-anchor" :to="`/@${pAcct}`">
 			<img class="avatar" :src="`${p.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="p.user.id"/>
 		</router-link>
 		<header>
-			<router-link class="name" :to="`/@${acct}`" v-user-preview="p.user.id">{{ p.user.name }}</router-link>
-			<span class="username">@{{ acct }}</span>
-			<router-link class="time" :to="`/@${acct}/${p.id}`">
+			<router-link class="name" :to="`/@${pAcct}`" v-user-preview="p.user.id">{{ p.user.name }}</router-link>
+			<span class="username">@{{ pAcct }}</span>
+			<router-link class="time" :to="`/@${pAcct}/${p.id}`">
 				<mk-time :time="p.createdAt"/>
 			</router-link>
 		</header>
@@ -131,6 +131,9 @@ export default Vue.extend({
 			return dateStringify(this.p.createdAt);
 		},
 		acct(): string {
+			return getAcct(this.post.user);
+		},
+		pAcct(): string {
 			return getAcct(this.p.user);
 		},
 		urls(): string[] {

From ae580462653539da4d90a92dc880e9e9b36007d6 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sat, 31 Mar 2018 21:49:26 +0900
Subject: [PATCH 03/28] Update migration script

---
 tools/migration/nighthike/6.js | 14 +++++++++++++-
 1 file changed, 13 insertions(+), 1 deletion(-)

diff --git a/tools/migration/nighthike/6.js b/tools/migration/nighthike/6.js
index ff78df4e09..8b97e9f7b3 100644
--- a/tools/migration/nighthike/6.js
+++ b/tools/migration/nighthike/6.js
@@ -1 +1,13 @@
-db.posts.update({ mediaIds: null }, { $set: { mediaIds: [] } }, false, true);
+db.posts.update({
+	$or: [{
+		mediaIds: null
+	}, {
+		mediaIds: {
+			$exist: false
+		}
+	}]
+}, {
+	$set: {
+		mediaIds: []
+	}
+}, false, true);

From 621c36c42be3b2d9cbea525e4929f2155c28c5ab Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sat, 31 Mar 2018 21:54:29 +0900
Subject: [PATCH 04/28] Update migration script

---
 tools/migration/nighthike/7.js | 44 ++++++++++++++++++++++++++--------
 tools/migration/nighthike/8.js | 40 +++++++++++++++++++++++++++++++
 2 files changed, 74 insertions(+), 10 deletions(-)
 create mode 100644 tools/migration/nighthike/8.js

diff --git a/tools/migration/nighthike/7.js b/tools/migration/nighthike/7.js
index c5055da8ba..c8efb8952b 100644
--- a/tools/migration/nighthike/7.js
+++ b/tools/migration/nighthike/7.js
@@ -1,16 +1,40 @@
-// for Node.js interpretation
+// for Node.js interpret
 
-const Message = require('../../../built/models/messaging-message').default;
-const Post = require('../../../built/models/post').default;
+const { default: Post } = require('../../../built/api/models/post');
+const { default: zip } = require('@prezzemolo/zip')
 const html = require('../../../built/common/text/html').default;
 const parse = require('../../../built/common/text/parse').default;
 
-Promise.all([Message, Post].map(async model => {
-	const documents = await model.find();
-
-	return Promise.all(documents.map(({ _id, text }) => model.update(_id, {
+const migrate = async (post) => {
+	const result = await Post.update(post._id, {
 		$set: {
-			textHtml: html(parse(text))
+			textHtml: post.text ? html(parse(post.text)) : null
 		}
-	})));
-})).catch(console.error).then(process.exit);
+	});
+	return result.ok === 1;
+}
+
+async function main() {
+	const count = await Post.count({});
+
+	const dop = Number.parseInt(process.argv[2]) || 5
+	const idop = ((count - (count % dop)) / dop) + 1
+
+	return zip(
+		1,
+		async (time) => {
+			console.log(`${time} / ${idop}`)
+			const doc = await Post.find({}, {
+				limit: dop, skip: time * dop
+			})
+			return Promise.all(doc.map(migrate))
+		},
+		idop
+	).then(a => {
+		const rv = []
+		a.forEach(e => rv.push(...e))
+		return rv
+	})
+}
+
+main().then(console.dir).catch(console.error)
diff --git a/tools/migration/nighthike/8.js b/tools/migration/nighthike/8.js
new file mode 100644
index 0000000000..5e0cf95078
--- /dev/null
+++ b/tools/migration/nighthike/8.js
@@ -0,0 +1,40 @@
+// for Node.js interpret
+
+const { default: Message } = require('../../../built/api/models/message');
+const { default: zip } = require('@prezzemolo/zip')
+const html = require('../../../built/common/text/html').default;
+const parse = require('../../../built/common/text/parse').default;
+
+const migrate = async (message) => {
+	const result = await Message.update(message._id, {
+		$set: {
+			textHtml: message.text ? html(parse(message.text)) : null
+		}
+	});
+	return result.ok === 1;
+}
+
+async function main() {
+	const count = await Message.count({});
+
+	const dop = Number.parseInt(process.argv[2]) || 5
+	const idop = ((count - (count % dop)) / dop) + 1
+
+	return zip(
+		1,
+		async (time) => {
+			console.log(`${time} / ${idop}`)
+			const doc = await Message.find({}, {
+				limit: dop, skip: time * dop
+			})
+			return Promise.all(doc.map(migrate))
+		},
+		idop
+	).then(a => {
+		const rv = []
+		a.forEach(e => rv.push(...e))
+		return rv
+	})
+}
+
+main().then(console.dir).catch(console.error)

From c0e63e00e588b418a5482c7ba41f12b3ce261ab8 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sat, 31 Mar 2018 22:02:30 +0900
Subject: [PATCH 05/28] Fix tests

---
 test/api.js | 338 ++++++++++++++++++++++++++--------------------------
 1 file changed, 169 insertions(+), 169 deletions(-)

diff --git a/test/api.js b/test/api.js
index c2c08dd95d..5b3b8e38de 100644
--- a/test/api.js
+++ b/test/api.js
@@ -46,12 +46,12 @@ describe('API', () => {
 	beforeEach(() => Promise.all([
 		db.get('users').drop(),
 		db.get('posts').drop(),
-		db.get('drive_files.files').drop(),
-		db.get('drive_files.chunks').drop(),
-		db.get('drive_folders').drop(),
+		db.get('driveFiles.files').drop(),
+		db.get('driveFiles.chunks').drop(),
+		db.get('driveFolders').drop(),
 		db.get('apps').drop(),
-		db.get('access_tokens').drop(),
-		db.get('auth_sessions').drop()
+		db.get('accessTokens').drop(),
+		db.get('authSessions').drop()
 	]));
 
 	it('greet server', done => {
@@ -195,7 +195,7 @@ describe('API', () => {
 		it('ユーザーが取得できる', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/users/show', {
-				user_id: me._id.toString()
+				userId: me._id.toString()
 			}, me);
 			res.should.have.status(200);
 			res.body.should.be.a('object');
@@ -204,14 +204,14 @@ describe('API', () => {
 
 		it('ユーザーが存在しなかったら怒る', async(async () => {
 			const res = await request('/users/show', {
-				user_id: '000000000000000000000000'
+				userId: '000000000000000000000000'
 			});
 			res.should.have.status(400);
 		}));
 
 		it('間違ったIDで怒られる', async(async () => {
 			const res = await request('/users/show', {
-				user_id: 'kyoppie'
+				userId: 'kyoppie'
 			});
 			res.should.have.status(400);
 		}));
@@ -226,32 +226,32 @@ describe('API', () => {
 			const res = await request('/posts/create', post, me);
 			res.should.have.status(200);
 			res.body.should.be.a('object');
-			res.body.should.have.property('created_post');
-			res.body.created_post.should.have.property('text').eql(post.text);
+			res.body.should.have.property('createdPost');
+			res.body.createdPost.should.have.property('text').eql(post.text);
 		}));
 
 		it('ファイルを添付できる', async(async () => {
 			const me = await insertSakurako();
 			const file = await insertDriveFile({
-				user_id: me._id
+				userId: me._id
 			});
 			const res = await request('/posts/create', {
-				media_ids: [file._id.toString()]
+				mediaIds: [file._id.toString()]
 			}, me);
 			res.should.have.status(200);
 			res.body.should.be.a('object');
-			res.body.should.have.property('created_post');
-			res.body.created_post.should.have.property('media_ids').eql([file._id.toString()]);
+			res.body.should.have.property('createdPost');
+			res.body.createdPost.should.have.property('mediaIds').eql([file._id.toString()]);
 		}));
 
 		it('他人のファイルは添付できない', async(async () => {
 			const me = await insertSakurako();
 			const hima = await insertHimawari();
 			const file = await insertDriveFile({
-				user_id: hima._id
+				userId: hima._id
 			});
 			const res = await request('/posts/create', {
-				media_ids: [file._id.toString()]
+				mediaIds: [file._id.toString()]
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -259,7 +259,7 @@ describe('API', () => {
 		it('存在しないファイルは添付できない', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/posts/create', {
-				media_ids: ['000000000000000000000000']
+				mediaIds: ['000000000000000000000000']
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -267,7 +267,7 @@ describe('API', () => {
 		it('不正なファイルIDで怒られる', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/posts/create', {
-				media_ids: ['kyoppie']
+				mediaIds: ['kyoppie']
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -275,65 +275,65 @@ describe('API', () => {
 		it('返信できる', async(async () => {
 			const hima = await insertHimawari();
 			const himaPost = await db.get('posts').insert({
-				user_id: hima._id,
+				userId: hima._id,
 				text: 'ひま'
 			});
 
 			const me = await insertSakurako();
 			const post = {
 				text: 'さく',
-				reply_id: himaPost._id.toString()
+				replyId: himaPost._id.toString()
 			};
 			const res = await request('/posts/create', post, me);
 			res.should.have.status(200);
 			res.body.should.be.a('object');
-			res.body.should.have.property('created_post');
-			res.body.created_post.should.have.property('text').eql(post.text);
-			res.body.created_post.should.have.property('reply_id').eql(post.reply_id);
-			res.body.created_post.should.have.property('reply');
-			res.body.created_post.reply.should.have.property('text').eql(himaPost.text);
+			res.body.should.have.property('createdPost');
+			res.body.createdPost.should.have.property('text').eql(post.text);
+			res.body.createdPost.should.have.property('replyId').eql(post.replyId);
+			res.body.createdPost.should.have.property('reply');
+			res.body.createdPost.reply.should.have.property('text').eql(himaPost.text);
 		}));
 
 		it('repostできる', async(async () => {
 			const hima = await insertHimawari();
 			const himaPost = await db.get('posts').insert({
-				user_id: hima._id,
+				userId: hima._id,
 				text: 'こらっさくらこ!'
 			});
 
 			const me = await insertSakurako();
 			const post = {
-				repost_id: himaPost._id.toString()
+				repostId: himaPost._id.toString()
 			};
 			const res = await request('/posts/create', post, me);
 			res.should.have.status(200);
 			res.body.should.be.a('object');
-			res.body.should.have.property('created_post');
-			res.body.created_post.should.have.property('repost_id').eql(post.repost_id);
-			res.body.created_post.should.have.property('repost');
-			res.body.created_post.repost.should.have.property('text').eql(himaPost.text);
+			res.body.should.have.property('createdPost');
+			res.body.createdPost.should.have.property('repostId').eql(post.repostId);
+			res.body.createdPost.should.have.property('repost');
+			res.body.createdPost.repost.should.have.property('text').eql(himaPost.text);
 		}));
 
 		it('引用repostできる', async(async () => {
 			const hima = await insertHimawari();
 			const himaPost = await db.get('posts').insert({
-				user_id: hima._id,
+				userId: hima._id,
 				text: 'こらっさくらこ!'
 			});
 
 			const me = await insertSakurako();
 			const post = {
 				text: 'さく',
-				repost_id: himaPost._id.toString()
+				repostId: himaPost._id.toString()
 			};
 			const res = await request('/posts/create', post, me);
 			res.should.have.status(200);
 			res.body.should.be.a('object');
-			res.body.should.have.property('created_post');
-			res.body.created_post.should.have.property('text').eql(post.text);
-			res.body.created_post.should.have.property('repost_id').eql(post.repost_id);
-			res.body.created_post.should.have.property('repost');
-			res.body.created_post.repost.should.have.property('text').eql(himaPost.text);
+			res.body.should.have.property('createdPost');
+			res.body.createdPost.should.have.property('text').eql(post.text);
+			res.body.createdPost.should.have.property('repostId').eql(post.repostId);
+			res.body.createdPost.should.have.property('repost');
+			res.body.createdPost.repost.should.have.property('text').eql(himaPost.text);
 		}));
 
 		it('文字数ぎりぎりで怒られない', async(async () => {
@@ -358,7 +358,7 @@ describe('API', () => {
 			const me = await insertSakurako();
 			const post = {
 				text: 'さく',
-				reply_id: '000000000000000000000000'
+				replyId: '000000000000000000000000'
 			};
 			const res = await request('/posts/create', post, me);
 			res.should.have.status(400);
@@ -367,7 +367,7 @@ describe('API', () => {
 		it('存在しないrepost対象で怒られる', async(async () => {
 			const me = await insertSakurako();
 			const post = {
-				repost_id: '000000000000000000000000'
+				repostId: '000000000000000000000000'
 			};
 			const res = await request('/posts/create', post, me);
 			res.should.have.status(400);
@@ -377,7 +377,7 @@ describe('API', () => {
 			const me = await insertSakurako();
 			const post = {
 				text: 'さく',
-				reply_id: 'kyoppie'
+				replyId: 'kyoppie'
 			};
 			const res = await request('/posts/create', post, me);
 			res.should.have.status(400);
@@ -386,7 +386,7 @@ describe('API', () => {
 		it('不正なrepost対象IDで怒られる', async(async () => {
 			const me = await insertSakurako();
 			const post = {
-				repost_id: 'kyoppie'
+				repostId: 'kyoppie'
 			};
 			const res = await request('/posts/create', post, me);
 			res.should.have.status(400);
@@ -402,8 +402,8 @@ describe('API', () => {
 			}, me);
 			res.should.have.status(200);
 			res.body.should.be.a('object');
-			res.body.should.have.property('created_post');
-			res.body.created_post.should.have.property('poll');
+			res.body.should.have.property('createdPost');
+			res.body.createdPost.should.have.property('poll');
 		}));
 
 		it('投票の選択肢が無くて怒られる', async(async () => {
@@ -439,11 +439,11 @@ describe('API', () => {
 		it('投稿が取得できる', async(async () => {
 			const me = await insertSakurako();
 			const myPost = await db.get('posts').insert({
-				user_id: me._id,
+				userId: me._id,
 				text: 'お腹ペコい'
 			});
 			const res = await request('/posts/show', {
-				post_id: myPost._id.toString()
+				postId: myPost._id.toString()
 			}, me);
 			res.should.have.status(200);
 			res.body.should.be.a('object');
@@ -452,14 +452,14 @@ describe('API', () => {
 
 		it('投稿が存在しなかったら怒る', async(async () => {
 			const res = await request('/posts/show', {
-				post_id: '000000000000000000000000'
+				postId: '000000000000000000000000'
 			});
 			res.should.have.status(400);
 		}));
 
 		it('間違ったIDで怒られる', async(async () => {
 			const res = await request('/posts/show', {
-				post_id: 'kyoppie'
+				postId: 'kyoppie'
 			});
 			res.should.have.status(400);
 		}));
@@ -469,13 +469,13 @@ describe('API', () => {
 		it('リアクションできる', async(async () => {
 			const hima = await insertHimawari();
 			const himaPost = await db.get('posts').insert({
-				user_id: hima._id,
+				userId: hima._id,
 				text: 'ひま'
 			});
 
 			const me = await insertSakurako();
 			const res = await request('/posts/reactions/create', {
-				post_id: himaPost._id.toString(),
+				postId: himaPost._id.toString(),
 				reaction: 'like'
 			}, me);
 			res.should.have.status(204);
@@ -484,12 +484,12 @@ describe('API', () => {
 		it('自分の投稿にはリアクションできない', async(async () => {
 			const me = await insertSakurako();
 			const myPost = await db.get('posts').insert({
-				user_id: me._id,
+				userId: me._id,
 				text: 'お腹ペコい'
 			});
 
 			const res = await request('/posts/reactions/create', {
-				post_id: myPost._id.toString(),
+				postId: myPost._id.toString(),
 				reaction: 'like'
 			}, me);
 			res.should.have.status(400);
@@ -498,19 +498,19 @@ describe('API', () => {
 		it('二重にリアクションできない', async(async () => {
 			const hima = await insertHimawari();
 			const himaPost = await db.get('posts').insert({
-				user_id: hima._id,
+				userId: hima._id,
 				text: 'ひま'
 			});
 
 			const me = await insertSakurako();
-			await db.get('post_reactions').insert({
-				user_id: me._id,
-				post_id: himaPost._id,
+			await db.get('postReactions').insert({
+				userId: me._id,
+				postId: himaPost._id,
 				reaction: 'like'
 			});
 
 			const res = await request('/posts/reactions/create', {
-				post_id: himaPost._id.toString(),
+				postId: himaPost._id.toString(),
 				reaction: 'like'
 			}, me);
 			res.should.have.status(400);
@@ -519,7 +519,7 @@ describe('API', () => {
 		it('存在しない投稿にはリアクションできない', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/posts/reactions/create', {
-				post_id: '000000000000000000000000',
+				postId: '000000000000000000000000',
 				reaction: 'like'
 			}, me);
 			res.should.have.status(400);
@@ -534,7 +534,7 @@ describe('API', () => {
 		it('間違ったIDで怒られる', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/posts/reactions/create', {
-				post_id: 'kyoppie',
+				postId: 'kyoppie',
 				reaction: 'like'
 			}, me);
 			res.should.have.status(400);
@@ -545,19 +545,19 @@ describe('API', () => {
 		it('リアクションをキャンセルできる', async(async () => {
 			const hima = await insertHimawari();
 			const himaPost = await db.get('posts').insert({
-				user_id: hima._id,
+				userId: hima._id,
 				text: 'ひま'
 			});
 
 			const me = await insertSakurako();
-			await db.get('post_reactions').insert({
-				user_id: me._id,
-				post_id: himaPost._id,
+			await db.get('postReactions').insert({
+				userId: me._id,
+				postId: himaPost._id,
 				reaction: 'like'
 			});
 
 			const res = await request('/posts/reactions/delete', {
-				post_id: himaPost._id.toString()
+				postId: himaPost._id.toString()
 			}, me);
 			res.should.have.status(204);
 		}));
@@ -565,13 +565,13 @@ describe('API', () => {
 		it('リアクションしていない投稿はリアクションをキャンセルできない', async(async () => {
 			const hima = await insertHimawari();
 			const himaPost = await db.get('posts').insert({
-				user_id: hima._id,
+				userId: hima._id,
 				text: 'ひま'
 			});
 
 			const me = await insertSakurako();
 			const res = await request('/posts/reactions/delete', {
-				post_id: himaPost._id.toString()
+				postId: himaPost._id.toString()
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -579,7 +579,7 @@ describe('API', () => {
 		it('存在しない投稿はリアクションをキャンセルできない', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/posts/reactions/delete', {
-				post_id: '000000000000000000000000'
+				postId: '000000000000000000000000'
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -593,7 +593,7 @@ describe('API', () => {
 		it('間違ったIDで怒られる', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/posts/reactions/delete', {
-				post_id: 'kyoppie'
+				postId: 'kyoppie'
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -604,7 +604,7 @@ describe('API', () => {
 			const hima = await insertHimawari();
 			const me = await insertSakurako();
 			const res = await request('/following/create', {
-				user_id: hima._id.toString()
+				userId: hima._id.toString()
 			}, me);
 			res.should.have.status(204);
 		}));
@@ -613,12 +613,12 @@ describe('API', () => {
 			const hima = await insertHimawari();
 			const me = await insertSakurako();
 			await db.get('following').insert({
-				followee_id: hima._id,
-				follower_id: me._id,
-				deleted_at: new Date()
+				followeeId: hima._id,
+				followerId: me._id,
+				deletedAt: new Date()
 			});
 			const res = await request('/following/create', {
-				user_id: hima._id.toString()
+				userId: hima._id.toString()
 			}, me);
 			res.should.have.status(204);
 		}));
@@ -627,11 +627,11 @@ describe('API', () => {
 			const hima = await insertHimawari();
 			const me = await insertSakurako();
 			await db.get('following').insert({
-				followee_id: hima._id,
-				follower_id: me._id
+				followeeId: hima._id,
+				followerId: me._id
 			});
 			const res = await request('/following/create', {
-				user_id: hima._id.toString()
+				userId: hima._id.toString()
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -639,7 +639,7 @@ describe('API', () => {
 		it('存在しないユーザーはフォローできない', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/following/create', {
-				user_id: '000000000000000000000000'
+				userId: '000000000000000000000000'
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -647,7 +647,7 @@ describe('API', () => {
 		it('自分自身はフォローできない', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/following/create', {
-				user_id: me._id.toString()
+				userId: me._id.toString()
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -661,7 +661,7 @@ describe('API', () => {
 		it('間違ったIDで怒られる', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/following/create', {
-				user_id: 'kyoppie'
+				userId: 'kyoppie'
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -672,11 +672,11 @@ describe('API', () => {
 			const hima = await insertHimawari();
 			const me = await insertSakurako();
 			await db.get('following').insert({
-				followee_id: hima._id,
-				follower_id: me._id
+				followeeId: hima._id,
+				followerId: me._id
 			});
 			const res = await request('/following/delete', {
-				user_id: hima._id.toString()
+				userId: hima._id.toString()
 			}, me);
 			res.should.have.status(204);
 		}));
@@ -685,16 +685,16 @@ describe('API', () => {
 			const hima = await insertHimawari();
 			const me = await insertSakurako();
 			await db.get('following').insert({
-				followee_id: hima._id,
-				follower_id: me._id,
-				deleted_at: new Date()
+				followeeId: hima._id,
+				followerId: me._id,
+				deletedAt: new Date()
 			});
 			await db.get('following').insert({
-				followee_id: hima._id,
-				follower_id: me._id
+				followeeId: hima._id,
+				followerId: me._id
 			});
 			const res = await request('/following/delete', {
-				user_id: hima._id.toString()
+				userId: hima._id.toString()
 			}, me);
 			res.should.have.status(204);
 		}));
@@ -703,7 +703,7 @@ describe('API', () => {
 			const hima = await insertHimawari();
 			const me = await insertSakurako();
 			const res = await request('/following/delete', {
-				user_id: hima._id.toString()
+				userId: hima._id.toString()
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -711,7 +711,7 @@ describe('API', () => {
 		it('存在しないユーザーはフォロー解除できない', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/following/delete', {
-				user_id: '000000000000000000000000'
+				userId: '000000000000000000000000'
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -719,7 +719,7 @@ describe('API', () => {
 		it('自分自身はフォロー解除できない', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/following/delete', {
-				user_id: me._id.toString()
+				userId: me._id.toString()
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -733,7 +733,7 @@ describe('API', () => {
 		it('間違ったIDで怒られる', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/following/delete', {
-				user_id: 'kyoppie'
+				userId: 'kyoppie'
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -743,15 +743,15 @@ describe('API', () => {
 		it('ドライブ情報を取得できる', async(async () => {
 			const me = await insertSakurako();
 			await insertDriveFile({
-				user_id: me._id,
+				userId: me._id,
 				datasize: 256
 			});
 			await insertDriveFile({
-				user_id: me._id,
+				userId: me._id,
 				datasize: 512
 			});
 			await insertDriveFile({
-				user_id: me._id,
+				userId: me._id,
 				datasize: 1024
 			});
 			const res = await request('/drive', {}, me);
@@ -784,11 +784,11 @@ describe('API', () => {
 		it('名前を更新できる', async(async () => {
 			const me = await insertSakurako();
 			const file = await insertDriveFile({
-				user_id: me._id
+				userId: me._id
 			});
 			const newName = 'いちごパスタ.png';
 			const res = await request('/drive/files/update', {
-				file_id: file._id.toString(),
+				fileId: file._id.toString(),
 				name: newName
 			}, me);
 			res.should.have.status(200);
@@ -800,10 +800,10 @@ describe('API', () => {
 			const me = await insertSakurako();
 			const hima = await insertHimawari();
 			const file = await insertDriveFile({
-				user_id: hima._id
+				userId: hima._id
 			});
 			const res = await request('/drive/files/update', {
-				file_id: file._id.toString(),
+				fileId: file._id.toString(),
 				name: 'いちごパスタ.png'
 			}, me);
 			res.should.have.status(400);
@@ -812,47 +812,47 @@ describe('API', () => {
 		it('親フォルダを更新できる', async(async () => {
 			const me = await insertSakurako();
 			const file = await insertDriveFile({
-				user_id: me._id
+				userId: me._id
 			});
 			const folder = await insertDriveFolder({
-				user_id: me._id
+				userId: me._id
 			});
 			const res = await request('/drive/files/update', {
-				file_id: file._id.toString(),
-				folder_id: folder._id.toString()
+				fileId: file._id.toString(),
+				folderId: folder._id.toString()
 			}, me);
 			res.should.have.status(200);
 			res.body.should.be.a('object');
-			res.body.should.have.property('folder_id').eql(folder._id.toString());
+			res.body.should.have.property('folderId').eql(folder._id.toString());
 		}));
 
 		it('親フォルダを無しにできる', async(async () => {
 			const me = await insertSakurako();
 			const file = await insertDriveFile({
-				user_id: me._id,
-				folder_id: '000000000000000000000000'
+				userId: me._id,
+				folderId: '000000000000000000000000'
 			});
 			const res = await request('/drive/files/update', {
-				file_id: file._id.toString(),
-				folder_id: null
+				fileId: file._id.toString(),
+				folderId: null
 			}, me);
 			res.should.have.status(200);
 			res.body.should.be.a('object');
-			res.body.should.have.property('folder_id').eql(null);
+			res.body.should.have.property('folderId').eql(null);
 		}));
 
 		it('他人のフォルダには入れられない', async(async () => {
 			const me = await insertSakurako();
 			const hima = await insertHimawari();
 			const file = await insertDriveFile({
-				user_id: me._id
+				userId: me._id
 			});
 			const folder = await insertDriveFolder({
-				user_id: hima._id
+				userId: hima._id
 			});
 			const res = await request('/drive/files/update', {
-				file_id: file._id.toString(),
-				folder_id: folder._id.toString()
+				fileId: file._id.toString(),
+				folderId: folder._id.toString()
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -860,11 +860,11 @@ describe('API', () => {
 		it('存在しないフォルダで怒られる', async(async () => {
 			const me = await insertSakurako();
 			const file = await insertDriveFile({
-				user_id: me._id
+				userId: me._id
 			});
 			const res = await request('/drive/files/update', {
-				file_id: file._id.toString(),
-				folder_id: '000000000000000000000000'
+				fileId: file._id.toString(),
+				folderId: '000000000000000000000000'
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -872,11 +872,11 @@ describe('API', () => {
 		it('不正なフォルダIDで怒られる', async(async () => {
 			const me = await insertSakurako();
 			const file = await insertDriveFile({
-				user_id: me._id
+				userId: me._id
 			});
 			const res = await request('/drive/files/update', {
-				file_id: file._id.toString(),
-				folder_id: 'kyoppie'
+				fileId: file._id.toString(),
+				folderId: 'kyoppie'
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -884,7 +884,7 @@ describe('API', () => {
 		it('ファイルが存在しなかったら怒る', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/drive/files/update', {
-				file_id: '000000000000000000000000',
+				fileId: '000000000000000000000000',
 				name: 'いちごパスタ.png'
 			}, me);
 			res.should.have.status(400);
@@ -893,7 +893,7 @@ describe('API', () => {
 		it('間違ったIDで怒られる', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/drive/files/update', {
-				file_id: 'kyoppie',
+				fileId: 'kyoppie',
 				name: 'いちごパスタ.png'
 			}, me);
 			res.should.have.status(400);
@@ -916,10 +916,10 @@ describe('API', () => {
 		it('名前を更新できる', async(async () => {
 			const me = await insertSakurako();
 			const folder = await insertDriveFolder({
-				user_id: me._id
+				userId: me._id
 			});
 			const res = await request('/drive/folders/update', {
-				folder_id: folder._id.toString(),
+				folderId: folder._id.toString(),
 				name: 'new name'
 			}, me);
 			res.should.have.status(200);
@@ -931,10 +931,10 @@ describe('API', () => {
 			const me = await insertSakurako();
 			const hima = await insertHimawari();
 			const folder = await insertDriveFolder({
-				user_id: hima._id
+				userId: hima._id
 			});
 			const res = await request('/drive/folders/update', {
-				folder_id: folder._id.toString(),
+				folderId: folder._id.toString(),
 				name: 'new name'
 			}, me);
 			res.should.have.status(400);
@@ -943,47 +943,47 @@ describe('API', () => {
 		it('親フォルダを更新できる', async(async () => {
 			const me = await insertSakurako();
 			const folder = await insertDriveFolder({
-				user_id: me._id
+				userId: me._id
 			});
 			const parentFolder = await insertDriveFolder({
-				user_id: me._id
+				userId: me._id
 			});
 			const res = await request('/drive/folders/update', {
-				folder_id: folder._id.toString(),
-				parent_id: parentFolder._id.toString()
+				folderId: folder._id.toString(),
+				parentId: parentFolder._id.toString()
 			}, me);
 			res.should.have.status(200);
 			res.body.should.be.a('object');
-			res.body.should.have.property('parent_id').eql(parentFolder._id.toString());
+			res.body.should.have.property('parentId').eql(parentFolder._id.toString());
 		}));
 
 		it('親フォルダを無しに更新できる', async(async () => {
 			const me = await insertSakurako();
 			const folder = await insertDriveFolder({
-				user_id: me._id,
-				parent_id: '000000000000000000000000'
+				userId: me._id,
+				parentId: '000000000000000000000000'
 			});
 			const res = await request('/drive/folders/update', {
-				folder_id: folder._id.toString(),
-				parent_id: null
+				folderId: folder._id.toString(),
+				parentId: null
 			}, me);
 			res.should.have.status(200);
 			res.body.should.be.a('object');
-			res.body.should.have.property('parent_id').eql(null);
+			res.body.should.have.property('parentId').eql(null);
 		}));
 
 		it('他人のフォルダを親フォルダに設定できない', async(async () => {
 			const me = await insertSakurako();
 			const hima = await insertHimawari();
 			const folder = await insertDriveFolder({
-				user_id: me._id
+				userId: me._id
 			});
 			const parentFolder = await insertDriveFolder({
-				user_id: hima._id
+				userId: hima._id
 			});
 			const res = await request('/drive/folders/update', {
-				folder_id: folder._id.toString(),
-				parent_id: parentFolder._id.toString()
+				folderId: folder._id.toString(),
+				parentId: parentFolder._id.toString()
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -992,11 +992,11 @@ describe('API', () => {
 			const me = await insertSakurako();
 			const folder = await insertDriveFolder();
 			const parentFolder = await insertDriveFolder({
-				parent_id: folder._id
+				parentId: folder._id
 			});
 			const res = await request('/drive/folders/update', {
-				folder_id: folder._id.toString(),
-				parent_id: parentFolder._id.toString()
+				folderId: folder._id.toString(),
+				parentId: parentFolder._id.toString()
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -1005,14 +1005,14 @@ describe('API', () => {
 			const me = await insertSakurako();
 			const folderA = await insertDriveFolder();
 			const folderB = await insertDriveFolder({
-				parent_id: folderA._id
+				parentId: folderA._id
 			});
 			const folderC = await insertDriveFolder({
-				parent_id: folderB._id
+				parentId: folderB._id
 			});
 			const res = await request('/drive/folders/update', {
-				folder_id: folderA._id.toString(),
-				parent_id: folderC._id.toString()
+				folderId: folderA._id.toString(),
+				parentId: folderC._id.toString()
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -1021,8 +1021,8 @@ describe('API', () => {
 			const me = await insertSakurako();
 			const folder = await insertDriveFolder();
 			const res = await request('/drive/folders/update', {
-				folder_id: folder._id.toString(),
-				parent_id: '000000000000000000000000'
+				folderId: folder._id.toString(),
+				parentId: '000000000000000000000000'
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -1031,8 +1031,8 @@ describe('API', () => {
 			const me = await insertSakurako();
 			const folder = await insertDriveFolder();
 			const res = await request('/drive/folders/update', {
-				folder_id: folder._id.toString(),
-				parent_id: 'kyoppie'
+				folderId: folder._id.toString(),
+				parentId: 'kyoppie'
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -1040,7 +1040,7 @@ describe('API', () => {
 		it('存在しないフォルダを更新できない', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/drive/folders/update', {
-				folder_id: '000000000000000000000000'
+				folderId: '000000000000000000000000'
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -1048,7 +1048,7 @@ describe('API', () => {
 		it('不正なフォルダIDで怒られる', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/drive/folders/update', {
-				folder_id: 'kyoppie'
+				folderId: 'kyoppie'
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -1059,7 +1059,7 @@ describe('API', () => {
 			const me = await insertSakurako();
 			const hima = await insertHimawari();
 			const res = await request('/messaging/messages/create', {
-				user_id: hima._id.toString(),
+				userId: hima._id.toString(),
 				text: 'Hey hey ひまわり'
 			}, me);
 			res.should.have.status(200);
@@ -1070,7 +1070,7 @@ describe('API', () => {
 		it('自分自身にはメッセージを送信できない', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/messaging/messages/create', {
-				user_id: me._id.toString(),
+				userId: me._id.toString(),
 				text: 'Yo'
 			}, me);
 			res.should.have.status(400);
@@ -1079,7 +1079,7 @@ describe('API', () => {
 		it('存在しないユーザーにはメッセージを送信できない', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/messaging/messages/create', {
-				user_id: '000000000000000000000000',
+				userId: '000000000000000000000000',
 				text: 'Yo'
 			}, me);
 			res.should.have.status(400);
@@ -1088,7 +1088,7 @@ describe('API', () => {
 		it('不正なユーザーIDで怒られる', async(async () => {
 			const me = await insertSakurako();
 			const res = await request('/messaging/messages/create', {
-				user_id: 'kyoppie',
+				userId: 'kyoppie',
 				text: 'Yo'
 			}, me);
 			res.should.have.status(400);
@@ -1098,7 +1098,7 @@ describe('API', () => {
 			const me = await insertSakurako();
 			const hima = await insertHimawari();
 			const res = await request('/messaging/messages/create', {
-				user_id: hima._id.toString()
+				userId: hima._id.toString()
 			}, me);
 			res.should.have.status(400);
 		}));
@@ -1107,7 +1107,7 @@ describe('API', () => {
 			const me = await insertSakurako();
 			const hima = await insertHimawari();
 			const res = await request('/messaging/messages/create', {
-				user_id: hima._id.toString(),
+				userId: hima._id.toString(),
 				text: '!'.repeat(1001)
 			}, me);
 			res.should.have.status(400);
@@ -1118,7 +1118,7 @@ describe('API', () => {
 		it('認証セッションを作成できる', async(async () => {
 			const app = await insertApp();
 			const res = await request('/auth/session/generate', {
-				app_secret: app.secret
+				appSecret: app.secret
 			});
 			res.should.have.status(200);
 			res.body.should.be.a('object');
@@ -1126,14 +1126,14 @@ describe('API', () => {
 			res.body.should.have.property('url');
 		}));
 
-		it('app_secret 無しで怒られる', async(async () => {
+		it('appSecret 無しで怒られる', async(async () => {
 			const res = await request('/auth/session/generate', {});
 			res.should.have.status(400);
 		}));
 
-		it('誤った app secret で怒られる', async(async () => {
+		it('誤った appSecret で怒られる', async(async () => {
 			const res = await request('/auth/session/generate', {
-				app_secret: 'kyoppie'
+				appSecret: 'kyoppie'
 			});
 			res.should.have.status(400);
 		}));
@@ -1159,14 +1159,14 @@ function deepAssign(destination, ...sources) {
 function insertSakurako(opts) {
 	return db.get('users').insert(deepAssign({
 		username: 'sakurako',
-		username_lower: 'sakurako',
+		usernameLower: 'sakurako',
 		account: {
 			keypair: '-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAtdTG9rlFWjNqhgbg2V6X5XF1WpQXZS3KNXykEWl2UAiMyfVV\nBvf3zQP0dDEdNtcqdPJgis03bpiHCzQusc/YLyHYB0m+TJXsxJatb8cqUogOFeE4\ngQ4Dc5kAT6gLh/d4yz03EIg9bizX07EiGWnZqWxb+21ypqsPxST64sAtG9f5O/G4\nXe2m3cSbfAAvEUP1Ig1LUNyJB4jhM60w1cQic/qO8++sk/+GoX9g71X+i4NArGv+\n1c11acDIIPGAAQpFeYVeGaKakNDNp8RtJJp8R8FLwJXZ4/gATBnScCiHUSrGfRly\nYyR0w/BNlQ6/NijAdB9pR5csPvyIPkx1gauZewIDAQABAoIBAQCwWf/mhuY2h6uG\n9eDZsZ7Mj2/sO7k9Dl4R5iMSKCDxmnlB3slqitExa+aJUqEs8R5icjkkJcjfYNuJ\nCEFJf3YCsGZfGyyQBtCuEh2ATcBEb2SJ3/f3YuoCEaB1oVwdsOzc4TAovpol4yQo\nUqHp1/mdElVb01jhQQN4h1c02IJnfzvfU1C8szBni+Etfd+MxqGfv006DY3KOEb3\nlCrCS3GmooJW2Fjj7q1kCcaEQbMB1/aQHLXd1qe3KJOzXh3Voxsp/jEH0hvp2TII\nfY9UK+b7mA+xlvXwKuTkHVaZm0ylg0nbembS8MF4GfFMujinSexvLrVKaQhdMFoF\nvBLxHYHRAoGBANfNVYJYeCDPFNLmak5Xg33Rfvc2II8UmrZOVdhOWs8ZK0pis9e+\nPo2MKtTzrzipXI2QXv5w7kO+LJWNDva+xRlW8Wlj9Dde9QdQ7Y8+dk7SJgf24DzM\n023elgX5DvTeLODjStk6SMPRL0FmGovUqAAA8ZeHtJzkIr1HROWnQiwnAoGBANez\nhFwKnVoQu0RpBz/i4W0RKIxOwltN2zmlN8KjJPhSy00A7nBUfKLRbcwiSHE98Yi/\nUrXwMwR5QeD2ngngRppddJnpiRfjNjnsaqeqNtpO8AxB3XjpCC5zmHUMFHKvPpDj\n1zU/F44li0YjKcMBebZy9PbfAjrIgJfxhPo/oXiNAoGAfx6gaTjOAp2ZaaZ7Jozc\nkyft/5et1DrR6+P3I4T8bxQncRj1UXfqhxzzOiAVrm3tbCKIIp/JarRCtRGzp9u2\nZPfXGzra6CcSdW3Rkli7/jBCYNynOIl7XjQI8ZnFmq6phwu80ntH07mMeZy4tHff\nQqlLpvQ0i1rDr/Wkexdsnm8CgYBgxha9ILoF/Xm3MJPjEsxmnYsen/tM8XpIu5pv\nxbhBfQvfKWrQlOcyOVnUexEbVVo3KvdVz0VkXW60GpE/BxNGEGXO49rxD6x1gl87\nh/+CJGZIaYiOxaY5CP2+jcPizEL6yG32Yq8TxD5fIkmLRu8vbxX+aIFclDY1dVNe\n3wt3xQKBgGEL0EjwRch+P2V+YHAhbETPrEqJjHRWT95pIdF9XtC8fasSOVH81cLX\nXXsX1FTvOJNwG9Nk8rQjYJXGTb2O/2unaazlYUwxKwVpwuGzz/vhH/roHZBAkIVT\njvpykpn9QMezEdpzj5BEv01QzSYBPzIh5myrpoJIoSW7py7zFG3h\n-----END RSA PRIVATE KEY-----\n',
 			token: '!00000000000000000000000000000000',
 			password: '$2a$08$FnHXg3tP.M/kINWgQSXNqeoBsiVrkj.ecXX8mW9rfBzMRkibYfjYy', // HimawariDaisuki06160907
 			profile: {},
 			settings: {},
-			client_settings: {}
+			clientSettings: {}
 		}
 	}, opts));
 }
@@ -1174,20 +1174,20 @@ function insertSakurako(opts) {
 function insertHimawari(opts) {
 	return db.get('users').insert(deepAssign({
 		username: 'himawari',
-		username_lower: 'himawari',
+		usernameLower: 'himawari',
 		account: {
 			keypair: '-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEAtdTG9rlFWjNqhgbg2V6X5XF1WpQXZS3KNXykEWl2UAiMyfVV\nBvf3zQP0dDEdNtcqdPJgis03bpiHCzQusc/YLyHYB0m+TJXsxJatb8cqUogOFeE4\ngQ4Dc5kAT6gLh/d4yz03EIg9bizX07EiGWnZqWxb+21ypqsPxST64sAtG9f5O/G4\nXe2m3cSbfAAvEUP1Ig1LUNyJB4jhM60w1cQic/qO8++sk/+GoX9g71X+i4NArGv+\n1c11acDIIPGAAQpFeYVeGaKakNDNp8RtJJp8R8FLwJXZ4/gATBnScCiHUSrGfRly\nYyR0w/BNlQ6/NijAdB9pR5csPvyIPkx1gauZewIDAQABAoIBAQCwWf/mhuY2h6uG\n9eDZsZ7Mj2/sO7k9Dl4R5iMSKCDxmnlB3slqitExa+aJUqEs8R5icjkkJcjfYNuJ\nCEFJf3YCsGZfGyyQBtCuEh2ATcBEb2SJ3/f3YuoCEaB1oVwdsOzc4TAovpol4yQo\nUqHp1/mdElVb01jhQQN4h1c02IJnfzvfU1C8szBni+Etfd+MxqGfv006DY3KOEb3\nlCrCS3GmooJW2Fjj7q1kCcaEQbMB1/aQHLXd1qe3KJOzXh3Voxsp/jEH0hvp2TII\nfY9UK+b7mA+xlvXwKuTkHVaZm0ylg0nbembS8MF4GfFMujinSexvLrVKaQhdMFoF\nvBLxHYHRAoGBANfNVYJYeCDPFNLmak5Xg33Rfvc2II8UmrZOVdhOWs8ZK0pis9e+\nPo2MKtTzrzipXI2QXv5w7kO+LJWNDva+xRlW8Wlj9Dde9QdQ7Y8+dk7SJgf24DzM\n023elgX5DvTeLODjStk6SMPRL0FmGovUqAAA8ZeHtJzkIr1HROWnQiwnAoGBANez\nhFwKnVoQu0RpBz/i4W0RKIxOwltN2zmlN8KjJPhSy00A7nBUfKLRbcwiSHE98Yi/\nUrXwMwR5QeD2ngngRppddJnpiRfjNjnsaqeqNtpO8AxB3XjpCC5zmHUMFHKvPpDj\n1zU/F44li0YjKcMBebZy9PbfAjrIgJfxhPo/oXiNAoGAfx6gaTjOAp2ZaaZ7Jozc\nkyft/5et1DrR6+P3I4T8bxQncRj1UXfqhxzzOiAVrm3tbCKIIp/JarRCtRGzp9u2\nZPfXGzra6CcSdW3Rkli7/jBCYNynOIl7XjQI8ZnFmq6phwu80ntH07mMeZy4tHff\nQqlLpvQ0i1rDr/Wkexdsnm8CgYBgxha9ILoF/Xm3MJPjEsxmnYsen/tM8XpIu5pv\nxbhBfQvfKWrQlOcyOVnUexEbVVo3KvdVz0VkXW60GpE/BxNGEGXO49rxD6x1gl87\nh/+CJGZIaYiOxaY5CP2+jcPizEL6yG32Yq8TxD5fIkmLRu8vbxX+aIFclDY1dVNe\n3wt3xQKBgGEL0EjwRch+P2V+YHAhbETPrEqJjHRWT95pIdF9XtC8fasSOVH81cLX\nXXsX1FTvOJNwG9Nk8rQjYJXGTb2O/2unaazlYUwxKwVpwuGzz/vhH/roHZBAkIVT\njvpykpn9QMezEdpzj5BEv01QzSYBPzIh5myrpoJIoSW7py7zFG3h\n-----END RSA PRIVATE KEY-----\n',
 			token: '!00000000000000000000000000000001',
 			password: '$2a$08$OPESxR2RE/ZijjGanNKk6ezSqGFitqsbZqTjWUZPLhORMKxHCbc4O', // ilovesakurako
 			profile: {},
 			settings: {},
-			client_settings: {}
+			clientSettings: {}
 		}
 	}, opts));
 }
 
 function insertDriveFile(opts) {
-	return db.get('drive_files.files').insert({
+	return db.get('driveFiles.files').insert({
 		length: opts.datasize,
 		filename: 'strawberry-pasta.png',
 		metadata: opts
@@ -1195,9 +1195,9 @@ function insertDriveFile(opts) {
 }
 
 function insertDriveFolder(opts) {
-	return db.get('drive_folders').insert(deepAssign({
+	return db.get('driveFolders').insert(deepAssign({
 		name: 'my folder',
-		parent_id: null
+		parentId: null
 	}, opts));
 }
 

From 73cffeadd002729bc5194e4367b4668a34e59053 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sat, 31 Mar 2018 22:22:11 +0900
Subject: [PATCH 06/28] Fix

---
 test/api.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/api.js b/test/api.js
index 5b3b8e38de..9627308362 100644
--- a/test/api.js
+++ b/test/api.js
@@ -17,7 +17,7 @@ const should = _chai.should();
 
 _chai.use(chaiHttp);
 
-const server = require('../built/server/api/server');
+const server = require('../built/server/api');
 const db = require('../built/db/mongodb').default;
 
 const async = fn => (done) => {

From b7870cb732cabcaedc657098711f30345fcfc502 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sun, 1 Apr 2018 11:05:56 +0900
Subject: [PATCH 07/28] Fix

---
 test/text.js | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/test/text.js b/test/text.js
index 4f739cc1b1..514a91f83c 100644
--- a/test/text.js
+++ b/test/text.js
@@ -4,8 +4,8 @@
 
 const assert = require('assert');
 
-const analyze = require('../built/server/api/common/text').default;
-const syntaxhighlighter = require('../built/server/api/common/text/core/syntax-highlighter').default;
+const analyze = require('../built/common/text').default;
+const syntaxhighlighter = require('../built/common/text/core/syntax-highlighter').default;
 
 describe('Text', () => {
 	it('can be analyzed', () => {

From 975dd842d83e622b2411fe1e60b2c3e02ef982bb Mon Sep 17 00:00:00 2001
From: Akihiko Odaki <nekomanma@pixiv.co.jp>
Date: Sun, 1 Apr 2018 12:24:29 +0900
Subject: [PATCH 08/28] Implement Activity Streams representation of user

---
 src/crypto_key.d.ts       |  1 +
 src/models/user.ts        | 55 -----------------------------------
 src/server/activitypub.ts | 60 +++++++++++++++++++++++++++++++++++++++
 src/server/index.ts       |  2 ++
 4 files changed, 63 insertions(+), 55 deletions(-)
 create mode 100644 src/server/activitypub.ts

diff --git a/src/crypto_key.d.ts b/src/crypto_key.d.ts
index 28ac2f9683..48efef2980 100644
--- a/src/crypto_key.d.ts
+++ b/src/crypto_key.d.ts
@@ -1 +1,2 @@
+export function extractPublic(keypair: String): String;
 export function generate(): String;
diff --git a/src/models/user.ts b/src/models/user.ts
index 4fbfdec907..d228766e3c 100644
--- a/src/models/user.ts
+++ b/src/models/user.ts
@@ -275,61 +275,6 @@ export const pack = (
 	resolve(_user);
 });
 
-/**
- * Pack a user for ActivityPub
- *
- * @param user target
- * @return Packed user
- */
-export const packForAp = (
-	user: string | mongo.ObjectID | IUser
-) => new Promise<any>(async (resolve, reject) => {
-
-	let _user: any;
-
-	const fields = {
-		// something
-	};
-
-	// Populate the user if 'user' is ID
-	if (mongo.ObjectID.prototype.isPrototypeOf(user)) {
-		_user = await User.findOne({
-			_id: user
-		}, { fields });
-	} else if (typeof user === 'string') {
-		_user = await User.findOne({
-			_id: new mongo.ObjectID(user)
-		}, { fields });
-	} else {
-		_user = deepcopy(user);
-	}
-
-	if (!_user) return reject('invalid user arg.');
-
-	const userUrl = `${config.url}/@@${_user._id}`;
-
-	resolve({
-		"@context": ["https://www.w3.org/ns/activitystreams", {
-			"@language": "ja"
-		}],
-		"type": "Person",
-		"id": userUrl,
-		"following": `${userUrl}/following.json`,
-		"followers": `${userUrl}/followers.json`,
-		"liked": `${userUrl}/liked.json`,
-		"inbox": `${userUrl}/inbox.json`,
-		"outbox": `${userUrl}/outbox.json`,
-		"sharedInbox": `${config.url}/inbox`,
-		"url": `${config.url}/@${_user.username}`,
-		"preferredUsername": _user.username,
-		"name": _user.name,
-		"summary": _user.description,
-		"icon": [
-			`${config.drive_url}/${_user.avatarId}`
-		]
-	});
-});
-
 /*
 function img(url) {
 	return {
diff --git a/src/server/activitypub.ts b/src/server/activitypub.ts
new file mode 100644
index 0000000000..6cc31de7b5
--- /dev/null
+++ b/src/server/activitypub.ts
@@ -0,0 +1,60 @@
+import config from '../conf';
+import { extractPublic } from '../crypto_key';
+import parseAcct from '../common/user/parse-acct';
+import User, { ILocalAccount } from '../models/user';
+const express = require('express');
+
+const app = express();
+
+app.get('/@:user', async (req, res, next) => {
+	const accepted = req.accepts(['html', 'application/activity+json', 'application/ld+json']);
+	if (!['application/activity+json', 'application/ld+json'].includes(accepted)) {
+		return next();
+	}
+
+	const { username, host } = parseAcct(req.params.user);
+	if (host !== null) {
+		return res.send(422);
+	}
+
+	const user = await User.findOne({
+		usernameLower: username.toLowerCase(),
+		host: null
+	});
+	if (user === null) {
+		return res.send(404);
+	}
+
+	const id = `${config.url}/@${user.username}`;
+
+	if (username !== user.username) {
+		return res.redirect(id);
+	}
+
+	res.json({
+		'@context': [
+			'https://www.w3.org/ns/activitystreams',
+			'https://w3id.org/security/v1'
+		],
+		type: 'Person',
+		id,
+		preferredUsername: user.username,
+		name: user.name,
+		summary: user.description,
+		icon: user.avatarId && {
+			type: 'Image',
+			url: `${config.drive_url}/${user.avatarId}`
+		},
+		image: user.bannerId && {
+			type: 'Image',
+			url: `${config.drive_url}/${user.bannerId}`
+		},
+		publicKey: {
+			type: 'Key',
+			owner: id,
+			publicKeyPem: extractPublic((user.account as ILocalAccount).keypair)
+		}
+	});
+});
+
+export default app;
diff --git a/src/server/index.ts b/src/server/index.ts
index fe22d9c9b3..92d46d46a2 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -9,6 +9,7 @@ import * as express from 'express';
 import * as morgan from 'morgan';
 import Accesses from 'accesses';
 
+import activityPub from './activitypub';
 import log from './log-request';
 import config from '../conf';
 
@@ -53,6 +54,7 @@ app.use((req, res, next) => {
  */
 app.use('/api', require('./api'));
 app.use('/files', require('./file'));
+app.use(activityPub);
 app.use(require('./web'));
 
 function createServer() {

From 63c3c3dfddb88fd537d1b89f9d9057d0664791d7 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sun, 1 Apr 2018 12:33:26 +0900
Subject: [PATCH 09/28] Use sendStatus instead of send

---
 src/server/activitypub.ts | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

diff --git a/src/server/activitypub.ts b/src/server/activitypub.ts
index 6cc31de7b5..abcee43d3e 100644
--- a/src/server/activitypub.ts
+++ b/src/server/activitypub.ts
@@ -1,8 +1,9 @@
+import * as express from 'express';
+
 import config from '../conf';
 import { extractPublic } from '../crypto_key';
 import parseAcct from '../common/user/parse-acct';
 import User, { ILocalAccount } from '../models/user';
-const express = require('express');
 
 const app = express();
 
@@ -14,7 +15,7 @@ app.get('/@:user', async (req, res, next) => {
 
 	const { username, host } = parseAcct(req.params.user);
 	if (host !== null) {
-		return res.send(422);
+		return res.sendStatus(422);
 	}
 
 	const user = await User.findOne({
@@ -22,7 +23,7 @@ app.get('/@:user', async (req, res, next) => {
 		host: null
 	});
 	if (user === null) {
-		return res.send(404);
+		return res.sendStatus(404);
 	}
 
 	const id = `${config.url}/@${user.username}`;

From c0ad36193c0a06d37fa102353a9f3a59042a317c Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sun, 1 Apr 2018 12:37:53 +0900
Subject: [PATCH 10/28] :v:

---
 src/client/docs/api/gulpfile.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/client/docs/api/gulpfile.ts b/src/client/docs/api/gulpfile.ts
index 16066b0d2e..4b962fe0c6 100644
--- a/src/client/docs/api/gulpfile.ts
+++ b/src/client/docs/api/gulpfile.ts
@@ -101,7 +101,7 @@ gulp.task('doc:api:endpoints', async () => {
 		}
 		//console.log(files);
 		files.forEach(file => {
-			const ep = yaml.safeLoad(fs.readFileSync(file, 'utf-8'));
+			const ep: any = yaml.safeLoad(fs.readFileSync(file, 'utf-8'));
 			const vars = {
 				endpoint: ep.endpoint,
 				url: {

From d636de090d11125caa85c4663309ac37ac44a0d5 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sun, 1 Apr 2018 12:41:19 +0900
Subject: [PATCH 11/28] Fix test

---
 test/text.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/test/text.js b/test/text.js
index 514a91f83c..1c034d6338 100644
--- a/test/text.js
+++ b/test/text.js
@@ -4,7 +4,7 @@
 
 const assert = require('assert');
 
-const analyze = require('../built/common/text').default;
+const analyze = require('../built/common/text/parse').default;
 const syntaxhighlighter = require('../built/common/text/core/syntax-highlighter').default;
 
 describe('Text', () => {

From d23de60b8b13620693116f0236890f6cb28c4fae Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sun, 1 Apr 2018 12:43:59 +0900
Subject: [PATCH 12/28] Disable needless header

---
 src/server/activitypub.ts | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/server/activitypub.ts b/src/server/activitypub.ts
index abcee43d3e..a48a8e643b 100644
--- a/src/server/activitypub.ts
+++ b/src/server/activitypub.ts
@@ -6,6 +6,7 @@ import parseAcct from '../common/user/parse-acct';
 import User, { ILocalAccount } from '../models/user';
 
 const app = express();
+app.disable('x-powered-by');
 
 app.get('/@:user', async (req, res, next) => {
 	const accepted = req.accepts(['html', 'application/activity+json', 'application/ld+json']);

From edfba6eed3e4c6202f13ed4f4e3018dd39acc5c1 Mon Sep 17 00:00:00 2001
From: Akihiko Odaki <nekomanma@pixiv.co.jp>
Date: Sun, 1 Apr 2018 14:10:22 +0900
Subject: [PATCH 13/28] Mark host parameter of /api/users/show nullable

---
 src/server/api/endpoints/users/show.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/server/api/endpoints/users/show.ts b/src/server/api/endpoints/users/show.ts
index fd51d386b8..3095d55f19 100644
--- a/src/server/api/endpoints/users/show.ts
+++ b/src/server/api/endpoints/users/show.ts
@@ -65,7 +65,7 @@ module.exports = (params, me) => new Promise(async (res, rej) => {
 	if (usernameErr) return rej('invalid username param');
 
 	// Get 'host' parameter
-	const [host, hostErr] = $(params.host).optional.string().$;
+	const [host, hostErr] = $(params.host).nullable.optional.string().$;
 	if (hostErr) return rej('invalid host param');
 
 	if (userId === undefined && typeof username !== 'string') {

From 9a71a8b917858ad357fad65c9398be07e2a22184 Mon Sep 17 00:00:00 2001
From: Akihiko Odaki <nekomanma@pixiv.co.jp>
Date: Sun, 1 Apr 2018 14:12:07 +0900
Subject: [PATCH 14/28] Implement WebFinger

---
 src/server/index.ts     |  2 ++
 src/server/webfinger.ts | 47 +++++++++++++++++++++++++++++++++++++++++
 2 files changed, 49 insertions(+)
 create mode 100644 src/server/webfinger.ts

diff --git a/src/server/index.ts b/src/server/index.ts
index 92d46d46a2..1874790116 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -10,6 +10,7 @@ import * as morgan from 'morgan';
 import Accesses from 'accesses';
 
 import activityPub from './activitypub';
+import webFinger from './webfinger';
 import log from './log-request';
 import config from '../conf';
 
@@ -55,6 +56,7 @@ app.use((req, res, next) => {
 app.use('/api', require('./api'));
 app.use('/files', require('./file'));
 app.use(activityPub);
+app.use(webFinger);
 app.use(require('./web'));
 
 function createServer() {
diff --git a/src/server/webfinger.ts b/src/server/webfinger.ts
new file mode 100644
index 0000000000..864bb4af52
--- /dev/null
+++ b/src/server/webfinger.ts
@@ -0,0 +1,47 @@
+import config from '../conf';
+import parseAcct from '../common/user/parse-acct';
+import User from '../models/user';
+const express = require('express');
+
+const app = express();
+
+app.get('/.well-known/webfinger', async (req, res) => {
+	if (typeof req.query.resource !== 'string') {
+		return res.sendStatus(400);
+	}
+
+	const resourceLower = req.query.resource.toLowerCase();
+	const webPrefix = config.url.toLowerCase() + '/@';
+	let acctLower;
+
+	if (resourceLower.startsWith(webPrefix)) {
+		acctLower = resourceLower.slice(webPrefix.length);
+	} else if (resourceLower.startsWith('acct:')) {
+		acctLower = resourceLower.slice('acct:'.length);
+	} else {
+		acctLower = resourceLower;
+	}
+
+	const parsedAcctLower = parseAcct(acctLower);
+	if (![null, config.host.toLowerCase()].includes(parsedAcctLower.host)) {
+		return res.sendStatus(422);
+	}
+
+	const user = await User.findOne({ usernameLower: parsedAcctLower.username, host: null });
+	if (user === null) {
+		return res.sendStatus(404);
+	}
+
+	return res.json({
+		subject: `acct:${user.username}@${config.host}`,
+		links: [
+			{
+				rel: 'self',
+				type: 'application/activity+json',
+				href: `${config.url}/@${user.username}`
+			}
+		]
+	});
+});
+
+export default app;

From b42a915172534fb1bbe66de412335d539fbc76b0 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sun, 1 Apr 2018 15:58:32 +0900
Subject: [PATCH 15/28] [wip] dark mode

---
 src/client/app/desktop/views/components/ui.header.vue | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/src/client/app/desktop/views/components/ui.header.vue b/src/client/app/desktop/views/components/ui.header.vue
index 7e337d2ae5..448d04d261 100644
--- a/src/client/app/desktop/views/components/ui.header.vue
+++ b/src/client/app/desktop/views/components/ui.header.vue
@@ -95,7 +95,7 @@ export default Vue.extend({
 </script>
 
 <style lang="stylus" scoped>
-.header
+root(isDark)
 	position -webkit-sticky
 	position sticky
 	top 0
@@ -112,7 +112,7 @@ export default Vue.extend({
 			z-index 1000
 			width 100%
 			height 48px
-			background #f7f7f7
+			background isDark ? #313543 : #f7f7f7
 
 		> .main
 			z-index 1001
@@ -169,4 +169,10 @@ export default Vue.extend({
 						> .mk-ui-header-search
 							display none
 
+.header[data-is-darkmode]
+	root(true)
+
+.header
+	root(false)
+
 </style>

From fabf233478ad79488cd95b1fcfb511a0c5d348bb Mon Sep 17 00:00:00 2001
From: Akihiko Odaki <nekomanma@pixiv.co.jp>
Date: Sun, 1 Apr 2018 15:58:49 +0900
Subject: [PATCH 16/28] Implement inbox

---
 package.json                                  |  1 +
 .../remote/activitypub/resolve-person.ts      |  4 ++
 src/models/user.ts                            |  4 ++
 src/server/activitypub/inbox.ts               | 42 +++++++++++++++++++
 src/server/activitypub/index.ts               | 12 ++++++
 .../{activitypub.ts => activitypub/user.ts}   | 12 +++---
 6 files changed, 69 insertions(+), 6 deletions(-)
 create mode 100644 src/server/activitypub/inbox.ts
 create mode 100644 src/server/activitypub/index.ts
 rename src/server/{activitypub.ts => activitypub/user.ts} (79%)

diff --git a/package.json b/package.json
index 4275c1c1c3..14c89927ee 100644
--- a/package.json
+++ b/package.json
@@ -134,6 +134,7 @@
 		"hard-source-webpack-plugin": "0.6.4",
 		"highlight.js": "9.12.0",
 		"html-minifier": "3.5.12",
+		"http-signature": "^1.2.0",
 		"inquirer": "5.2.0",
 		"is-root": "2.0.0",
 		"is-url": "1.2.4",
diff --git a/src/common/remote/activitypub/resolve-person.ts b/src/common/remote/activitypub/resolve-person.ts
index c7c131b0ea..999a37eea1 100644
--- a/src/common/remote/activitypub/resolve-person.ts
+++ b/src/common/remote/activitypub/resolve-person.ts
@@ -62,6 +62,10 @@ export default async (value, usernameLower, hostLower, acctLower) => {
 		host: toUnicode(finger.subject.replace(/^.*?@/, '')),
 		hostLower,
 		account: {
+			publicKey: {
+				id: object.publicKey.id,
+				publicKeyPem: object.publicKey.publicKeyPem
+			},
 			uri: object.id,
 		},
 	});
diff --git a/src/models/user.ts b/src/models/user.ts
index 02e6a570b9..9588c45153 100644
--- a/src/models/user.ts
+++ b/src/models/user.ts
@@ -71,6 +71,10 @@ export type ILocalAccount = {
 
 export type IRemoteAccount = {
 	uri: string;
+	publicKey: {
+		id: string;
+		publicKeyPem: string;
+	};
 };
 
 export type IUser = {
diff --git a/src/server/activitypub/inbox.ts b/src/server/activitypub/inbox.ts
new file mode 100644
index 0000000000..0d4af7c492
--- /dev/null
+++ b/src/server/activitypub/inbox.ts
@@ -0,0 +1,42 @@
+import * as bodyParser from 'body-parser';
+import * as express from 'express';
+import { parseRequest, verifySignature } from 'http-signature';
+import User, { IRemoteAccount } from '../../models/user';
+import queue from '../../queue';
+
+const app = express();
+app.disable('x-powered-by');
+app.use(bodyParser.json());
+
+app.get('/@:user/inbox', async (req, res) => {
+	let parsed;
+
+	try {
+		parsed = parseRequest(req);
+	} catch (exception) {
+		return res.sendStatus(401);
+	}
+
+	const user = await User.findOne({
+		host: { $ne: null },
+		account: { publicKey: { id: parsed.keyId } }
+	});
+
+	if (user === null) {
+		return res.sendStatus(401);
+	}
+
+	if (!verifySignature(parsed, (user.account as IRemoteAccount).publicKey.publicKeyPem)) {
+		return res.sendStatus(401);
+	}
+
+	queue.create('http', {
+		type: 'performActivityPub',
+		actor: user._id,
+		outbox: req.body,
+	}).save();
+
+	return res.sendStatus(200);
+});
+
+export default app;
diff --git a/src/server/activitypub/index.ts b/src/server/activitypub/index.ts
new file mode 100644
index 0000000000..07ff407a76
--- /dev/null
+++ b/src/server/activitypub/index.ts
@@ -0,0 +1,12 @@
+import * as express from 'express';
+
+import user from './user';
+import inbox from './inbox';
+
+const app = express();
+app.disable('x-powered-by');
+
+app.use(user);
+app.use(inbox);
+
+export default app;
diff --git a/src/server/activitypub.ts b/src/server/activitypub/user.ts
similarity index 79%
rename from src/server/activitypub.ts
rename to src/server/activitypub/user.ts
index a48a8e643b..488de93a92 100644
--- a/src/server/activitypub.ts
+++ b/src/server/activitypub/user.ts
@@ -1,16 +1,15 @@
 import * as express from 'express';
-
-import config from '../conf';
-import { extractPublic } from '../crypto_key';
-import parseAcct from '../common/user/parse-acct';
-import User, { ILocalAccount } from '../models/user';
+import config from '../../conf';
+import { extractPublic } from '../../crypto_key';
+import parseAcct from '../../common/user/parse-acct';
+import User, { ILocalAccount } from '../../models/user';
 
 const app = express();
 app.disable('x-powered-by');
 
 app.get('/@:user', async (req, res, next) => {
 	const accepted = req.accepts(['html', 'application/activity+json', 'application/ld+json']);
-	if (!['application/activity+json', 'application/ld+json'].includes(accepted)) {
+	if (!(['application/activity+json', 'application/ld+json'] as Array<any>).includes(accepted)) {
 		return next();
 	}
 
@@ -40,6 +39,7 @@ app.get('/@:user', async (req, res, next) => {
 		],
 		type: 'Person',
 		id,
+		inbox: `${id}/inbox`,
 		preferredUsername: user.username,
 		name: user.name,
 		summary: user.description,

From 54776545b6bb297b3af19767bed98b2634ba3046 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sun, 1 Apr 2018 16:04:23 +0900
Subject: [PATCH 17/28] Use dot notation

---
 src/server/activitypub/inbox.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/server/activitypub/inbox.ts b/src/server/activitypub/inbox.ts
index 0d4af7c492..f76e750d22 100644
--- a/src/server/activitypub/inbox.ts
+++ b/src/server/activitypub/inbox.ts
@@ -19,7 +19,7 @@ app.get('/@:user/inbox', async (req, res) => {
 
 	const user = await User.findOne({
 		host: { $ne: null },
-		account: { publicKey: { id: parsed.keyId } }
+		'account.publicKey.id': parsed.keyId
 	});
 
 	if (user === null) {

From d36f5376e38f30fe12fd3459aa833def230e5302 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sun, 1 Apr 2018 16:46:33 +0900
Subject: [PATCH 18/28] Fix

---
 src/server/activitypub/inbox.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/server/activitypub/inbox.ts b/src/server/activitypub/inbox.ts
index f76e750d22..b4761d9972 100644
--- a/src/server/activitypub/inbox.ts
+++ b/src/server/activitypub/inbox.ts
@@ -8,7 +8,7 @@ const app = express();
 app.disable('x-powered-by');
 app.use(bodyParser.json());
 
-app.get('/@:user/inbox', async (req, res) => {
+app.post('/@:user/inbox', async (req, res) => {
 	let parsed;
 
 	try {

From ec76918ce4cff5579a88e55f1ff2e661e1aaf69c Mon Sep 17 00:00:00 2001
From: "greenkeeper[bot]" <greenkeeper[bot]@users.noreply.github.com>
Date: Sun, 1 Apr 2018 08:40:47 +0000
Subject: [PATCH 19/28] fix(package): update bootstrap-vue to version
 2.0.0-rc.4

Closes #1349
---
 package.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/package.json b/package.json
index 14c89927ee..d5fa1f3cbb 100644
--- a/package.json
+++ b/package.json
@@ -89,7 +89,7 @@
 		"autwh": "0.0.1",
 		"bcryptjs": "2.4.3",
 		"body-parser": "1.18.2",
-		"bootstrap-vue": "2.0.0-rc.1",
+		"bootstrap-vue": "2.0.0-rc.4",
 		"cafy": "3.2.1",
 		"chai": "4.1.2",
 		"chai-http": "4.0.0",

From 63e8050094c03a61a4a7532c4eadf9586ab7a909 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sun, 1 Apr 2018 18:07:29 +0900
Subject: [PATCH 20/28] Add missing property

---
 src/models/post.ts | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/models/post.ts b/src/models/post.ts
index 6c853e4f81..4daad306d6 100644
--- a/src/models/post.ts
+++ b/src/models/post.ts
@@ -30,6 +30,7 @@ export type IPost = {
 	repostId: mongo.ObjectID;
 	poll: any; // todo
 	text: string;
+	tags: string[];
 	textHtml: string;
 	cw: string;
 	userId: mongo.ObjectID;

From 12a251c7d6538bcaadf6ff00ea82ad994a276725 Mon Sep 17 00:00:00 2001
From: Akihiko Odaki <nekomanma@pixiv.co.jp>
Date: Sun, 1 Apr 2018 18:12:51 +0900
Subject: [PATCH 21/28] Implement post Activity Streams

---
 src/common/remote/activitypub/context.ts |  5 ++
 src/models/post.ts                       |  1 +
 src/server/activitypub/index.ts          |  2 +
 src/server/activitypub/post.ts           | 85 ++++++++++++++++++++++++
 src/server/activitypub/user.ts           |  6 +-
 5 files changed, 95 insertions(+), 4 deletions(-)
 create mode 100644 src/common/remote/activitypub/context.ts
 create mode 100644 src/server/activitypub/post.ts

diff --git a/src/common/remote/activitypub/context.ts b/src/common/remote/activitypub/context.ts
new file mode 100644
index 0000000000..b56f727ae7
--- /dev/null
+++ b/src/common/remote/activitypub/context.ts
@@ -0,0 +1,5 @@
+export default [
+	'https://www.w3.org/ns/activitystreams',
+	'https://w3id.org/security/v1',
+	{ Hashtag: 'as:Hashtag' }
+];
diff --git a/src/models/post.ts b/src/models/post.ts
index 6c853e4f81..64c46c2635 100644
--- a/src/models/post.ts
+++ b/src/models/post.ts
@@ -47,6 +47,7 @@ export type IPost = {
 		heading: number;
 		speed: number;
 	};
+	tags: string[];
 };
 
 /**
diff --git a/src/server/activitypub/index.ts b/src/server/activitypub/index.ts
index 07ff407a76..6618c291f7 100644
--- a/src/server/activitypub/index.ts
+++ b/src/server/activitypub/index.ts
@@ -1,11 +1,13 @@
 import * as express from 'express';
 
+import post from './post';
 import user from './user';
 import inbox from './inbox';
 
 const app = express();
 app.disable('x-powered-by');
 
+app.use(post);
 app.use(user);
 app.use(inbox);
 
diff --git a/src/server/activitypub/post.ts b/src/server/activitypub/post.ts
new file mode 100644
index 0000000000..4fb3a1319b
--- /dev/null
+++ b/src/server/activitypub/post.ts
@@ -0,0 +1,85 @@
+import * as express from 'express';
+import context from '../../common/remote/activitypub/context';
+import parseAcct from '../../common/user/parse-acct';
+import config from '../../conf';
+import DriveFile from '../../models/drive-file';
+import Post from '../../models/post';
+import User from '../../models/user';
+
+const app = express();
+app.disable('x-powered-by');
+
+app.get('/@:user/:post', async (req, res, next) => {
+	const accepted = req.accepts(['html', 'application/activity+json', 'application/ld+json']);
+	if (!(['application/activity+json', 'application/ld+json'] as Array<any>).includes(accepted)) {
+		return next();
+	}
+
+	const { username, host } = parseAcct(req.params.user);
+	if (host !== null) {
+		return res.sendStatus(422);
+	}
+
+	const user = await User.findOne({
+		usernameLower: username.toLowerCase(),
+		host: null
+	});
+	if (user === null) {
+		return res.sendStatus(404);
+	}
+
+	const post = await Post.findOne({
+		_id: req.params.post,
+		userId: user._id
+	});
+	if (post === null) {
+		return res.sendStatus(404);
+	}
+
+	const asyncFiles = DriveFile.find({ _id: { $in: post.mediaIds } });
+	let inReplyTo;
+
+	if (post.replyId) {
+		const inReplyToPost = await Post.findOne({
+			_id: post.replyId,
+		});
+
+		if (inReplyToPost !== null) {
+			const inReplyToUser = await User.findOne({
+				_id: post.userId,
+			});
+
+			if (inReplyToUser !== null) {
+				inReplyTo = `${config.url}@${inReplyToUser.username}/${inReplyToPost._id}`;
+			}
+		}
+	} else {
+		inReplyTo = null;
+	}
+
+	const attributedTo = `${config.url}/@${user.username}`;
+
+	res.json({
+		'@context': context,
+		id: `${attributedTo}/${post._id}`,
+		type: 'Note',
+		attributedTo,
+		content: post.textHtml,
+		published: post.createdAt.toISOString(),
+		to: 'https://www.w3.org/ns/activitystreams#Public',
+		cc: `${attributedTo}/followers`,
+		inReplyTo,
+		attachment: (await asyncFiles).map(({ _id, contentType }) => ({
+			type: 'Document',
+			mediaType: contentType,
+			url: `${config.drive_url}/${_id}`
+		})),
+		tag: post.tags.map(tag => ({
+			type: 'Hashtag',
+			href: `${config.url}/search?q=#${encodeURIComponent(tag)}`,
+			name: '#' + tag
+		}))
+	});
+});
+
+export default app;
diff --git a/src/server/activitypub/user.ts b/src/server/activitypub/user.ts
index 488de93a92..ef365c2078 100644
--- a/src/server/activitypub/user.ts
+++ b/src/server/activitypub/user.ts
@@ -1,6 +1,7 @@
 import * as express from 'express';
 import config from '../../conf';
 import { extractPublic } from '../../crypto_key';
+import context from '../../common/remote/activitypub/context';
 import parseAcct from '../../common/user/parse-acct';
 import User, { ILocalAccount } from '../../models/user';
 
@@ -33,10 +34,7 @@ app.get('/@:user', async (req, res, next) => {
 	}
 
 	res.json({
-		'@context': [
-			'https://www.w3.org/ns/activitystreams',
-			'https://w3id.org/security/v1'
-		],
+		'@context': context,
 		type: 'Person',
 		id,
 		inbox: `${id}/inbox`,

From c83bb3b8ab59ea8b6a5042b55b859076cdfa127e Mon Sep 17 00:00:00 2001
From: Akihiko Odaki <nekomanma@pixiv.co.jp>
Date: Sun, 1 Apr 2018 18:16:47 +0900
Subject: [PATCH 22/28] Respond with 202 for inbox request

---
 src/server/activitypub/inbox.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/server/activitypub/inbox.ts b/src/server/activitypub/inbox.ts
index b4761d9972..9151297487 100644
--- a/src/server/activitypub/inbox.ts
+++ b/src/server/activitypub/inbox.ts
@@ -36,7 +36,7 @@ app.post('/@:user/inbox', async (req, res) => {
 		outbox: req.body,
 	}).save();
 
-	return res.sendStatus(200);
+	return res.status(202).end();
 });
 
 export default app;

From fea2e549ba42359a1e32618f489beda50e649504 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sun, 1 Apr 2018 18:17:04 +0900
Subject: [PATCH 23/28] Resolve conflict

---
 src/models/post.ts | 1 -
 1 file changed, 1 deletion(-)

diff --git a/src/models/post.ts b/src/models/post.ts
index e7b54180ff..4daad306d6 100644
--- a/src/models/post.ts
+++ b/src/models/post.ts
@@ -48,7 +48,6 @@ export type IPost = {
 		heading: number;
 		speed: number;
 	};
-	tags: string[];
 };
 
 /**

From 9a90d8a7b4f874c8ad2c60c41de6d3669f8799c4 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sun, 1 Apr 2018 18:20:17 +0900
Subject: [PATCH 24/28] Fix type annotation

---
 src/server/activitypub/post.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/server/activitypub/post.ts b/src/server/activitypub/post.ts
index 4fb3a1319b..261d7ca4a5 100644
--- a/src/server/activitypub/post.ts
+++ b/src/server/activitypub/post.ts
@@ -11,7 +11,7 @@ app.disable('x-powered-by');
 
 app.get('/@:user/:post', async (req, res, next) => {
 	const accepted = req.accepts(['html', 'application/activity+json', 'application/ld+json']);
-	if (!(['application/activity+json', 'application/ld+json'] as Array<any>).includes(accepted)) {
+	if (!(['application/activity+json', 'application/ld+json'] as any[]).includes(accepted)) {
 		return next();
 	}
 

From 0cb6fbea8c8cf451b33fde510a1008ecd4884e80 Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sun, 1 Apr 2018 18:28:10 +0900
Subject: [PATCH 25/28] Refactor: Better variable name

---
 src/server/activitypub/post.ts | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/server/activitypub/post.ts b/src/server/activitypub/post.ts
index 261d7ca4a5..bdfce0606d 100644
--- a/src/server/activitypub/post.ts
+++ b/src/server/activitypub/post.ts
@@ -36,7 +36,7 @@ app.get('/@:user/:post', async (req, res, next) => {
 		return res.sendStatus(404);
 	}
 
-	const asyncFiles = DriveFile.find({ _id: { $in: post.mediaIds } });
+	const promisedFiles = DriveFile.find({ _id: { $in: post.mediaIds } });
 	let inReplyTo;
 
 	if (post.replyId) {
@@ -69,7 +69,7 @@ app.get('/@:user/:post', async (req, res, next) => {
 		to: 'https://www.w3.org/ns/activitystreams#Public',
 		cc: `${attributedTo}/followers`,
 		inReplyTo,
-		attachment: (await asyncFiles).map(({ _id, contentType }) => ({
+		attachment: (await promisedFiles).map(({ _id, contentType }) => ({
 			type: 'Document',
 			mediaType: contentType,
 			url: `${config.drive_url}/${_id}`

From 1f1417a0f7505276af8d4b5d81edb44c7648b499 Mon Sep 17 00:00:00 2001
From: Akihiko Odaki <nekomanma@pixiv.co.jp>
Date: Sun, 1 Apr 2018 19:18:36 +0900
Subject: [PATCH 26/28] Implement outbox

---
 .../activitypub/{ => renderer}/context.ts     |  0
 .../remote/activitypub/renderer/document.ts   |  7 +++
 .../remote/activitypub/renderer/hashtag.ts    |  7 +++
 .../remote/activitypub/renderer/image.ts      |  6 +++
 src/common/remote/activitypub/renderer/key.ts |  9 ++++
 .../remote/activitypub/renderer/note.ts       | 44 ++++++++++++++++
 .../renderer/ordered-collection.ts            |  6 +++
 .../remote/activitypub/renderer/person.ts     | 20 ++++++++
 src/server/activitypub/index.ts               |  6 ++-
 src/server/activitypub/outbox.ts              | 45 ++++++++++++++++
 src/server/activitypub/post.ts                | 51 ++-----------------
 src/server/activitypub/user.ts                | 36 +++----------
 12 files changed, 161 insertions(+), 76 deletions(-)
 rename src/common/remote/activitypub/{ => renderer}/context.ts (100%)
 create mode 100644 src/common/remote/activitypub/renderer/document.ts
 create mode 100644 src/common/remote/activitypub/renderer/hashtag.ts
 create mode 100644 src/common/remote/activitypub/renderer/image.ts
 create mode 100644 src/common/remote/activitypub/renderer/key.ts
 create mode 100644 src/common/remote/activitypub/renderer/note.ts
 create mode 100644 src/common/remote/activitypub/renderer/ordered-collection.ts
 create mode 100644 src/common/remote/activitypub/renderer/person.ts
 create mode 100644 src/server/activitypub/outbox.ts

diff --git a/src/common/remote/activitypub/context.ts b/src/common/remote/activitypub/renderer/context.ts
similarity index 100%
rename from src/common/remote/activitypub/context.ts
rename to src/common/remote/activitypub/renderer/context.ts
diff --git a/src/common/remote/activitypub/renderer/document.ts b/src/common/remote/activitypub/renderer/document.ts
new file mode 100644
index 0000000000..4a456416a9
--- /dev/null
+++ b/src/common/remote/activitypub/renderer/document.ts
@@ -0,0 +1,7 @@
+import config from '../../../../conf';
+
+export default ({ _id, contentType }) => ({
+	type: 'Document',
+	mediaType: contentType,
+	url: `${config.drive_url}/${_id}`
+});
diff --git a/src/common/remote/activitypub/renderer/hashtag.ts b/src/common/remote/activitypub/renderer/hashtag.ts
new file mode 100644
index 0000000000..ad42700204
--- /dev/null
+++ b/src/common/remote/activitypub/renderer/hashtag.ts
@@ -0,0 +1,7 @@
+import config from '../../../../conf';
+
+export default tag => ({
+	type: 'Hashtag',
+	href: `${config.url}/search?q=#${encodeURIComponent(tag)}`,
+	name: '#' + tag
+});
diff --git a/src/common/remote/activitypub/renderer/image.ts b/src/common/remote/activitypub/renderer/image.ts
new file mode 100644
index 0000000000..345fbbec59
--- /dev/null
+++ b/src/common/remote/activitypub/renderer/image.ts
@@ -0,0 +1,6 @@
+import config from '../../../../conf';
+
+export default ({ _id }) => ({
+	type: 'Image',
+	url: `${config.drive_url}/${_id}`
+});
diff --git a/src/common/remote/activitypub/renderer/key.ts b/src/common/remote/activitypub/renderer/key.ts
new file mode 100644
index 0000000000..7148c59745
--- /dev/null
+++ b/src/common/remote/activitypub/renderer/key.ts
@@ -0,0 +1,9 @@
+import config from '../../../../conf';
+import { extractPublic } from '../../../../crypto_key';
+import { ILocalAccount } from '../../../../models/user';
+
+export default ({ username, account }) => ({
+	type: 'Key',
+	owner: `${config.url}/@${username}`,
+	publicKeyPem: extractPublic((account as ILocalAccount).keypair)
+});
diff --git a/src/common/remote/activitypub/renderer/note.ts b/src/common/remote/activitypub/renderer/note.ts
new file mode 100644
index 0000000000..2fe20b2136
--- /dev/null
+++ b/src/common/remote/activitypub/renderer/note.ts
@@ -0,0 +1,44 @@
+import renderDocument from './document';
+import renderHashtag from './hashtag';
+import config from '../../../../conf';
+import DriveFile from '../../../../models/drive-file';
+import Post from '../../../../models/post';
+import User from '../../../../models/user';
+
+export default async (user, post) => {
+	const promisedFiles = DriveFile.find({ _id: { $in: post.mediaIds } });
+	let inReplyTo;
+
+	if (post.replyId) {
+		const inReplyToPost = await Post.findOne({
+			_id: post.replyId,
+		});
+
+		if (inReplyToPost !== null) {
+			const inReplyToUser = await User.findOne({
+				_id: post.userId,
+			});
+
+			if (inReplyToUser !== null) {
+				inReplyTo = `${config.url}@${inReplyToUser.username}/${inReplyToPost._id}`;
+			}
+		}
+	} else {
+		inReplyTo = null;
+	}
+
+	const attributedTo = `${config.url}/@${user.username}`;
+
+	return {
+		id: `${attributedTo}/${post._id}`,
+		type: 'Note',
+		attributedTo,
+		content: post.textHtml,
+		published: post.createdAt.toISOString(),
+		to: 'https://www.w3.org/ns/activitystreams#Public',
+		cc: `${attributedTo}/followers`,
+		inReplyTo,
+		attachment: (await promisedFiles).map(renderDocument),
+		tag: post.tags.map(renderHashtag)
+	};
+};
diff --git a/src/common/remote/activitypub/renderer/ordered-collection.ts b/src/common/remote/activitypub/renderer/ordered-collection.ts
new file mode 100644
index 0000000000..2ca0f77354
--- /dev/null
+++ b/src/common/remote/activitypub/renderer/ordered-collection.ts
@@ -0,0 +1,6 @@
+export default (id, totalItems, orderedItems) => ({
+	id,
+	type: 'OrderedCollection',
+	totalItems,
+	orderedItems
+});
diff --git a/src/common/remote/activitypub/renderer/person.ts b/src/common/remote/activitypub/renderer/person.ts
new file mode 100644
index 0000000000..7303b30385
--- /dev/null
+++ b/src/common/remote/activitypub/renderer/person.ts
@@ -0,0 +1,20 @@
+import renderImage from './image';
+import renderKey from './key';
+import config from '../../../../conf';
+
+export default user => {
+	const id = `${config.url}/@${user.username}`;
+
+	return {
+		type: 'Person',
+		id,
+		inbox: `${id}/inbox`,
+		outbox: `${id}/outbox`,
+		preferredUsername: user.username,
+		name: user.name,
+		summary: user.description,
+		icon: user.avatarId && renderImage({ _id: user.avatarId }),
+		image: user.bannerId && renderImage({ _id: user.bannerId }),
+		publicKey: renderKey(user)
+	};
+};
diff --git a/src/server/activitypub/index.ts b/src/server/activitypub/index.ts
index 6618c291f7..c81024d15f 100644
--- a/src/server/activitypub/index.ts
+++ b/src/server/activitypub/index.ts
@@ -1,14 +1,16 @@
 import * as express from 'express';
 
-import post from './post';
 import user from './user';
 import inbox from './inbox';
+import outbox from './outbox';
+import post from './post';
 
 const app = express();
 app.disable('x-powered-by');
 
-app.use(post);
 app.use(user);
 app.use(inbox);
+app.use(outbox);
+app.use(post);
 
 export default app;
diff --git a/src/server/activitypub/outbox.ts b/src/server/activitypub/outbox.ts
new file mode 100644
index 0000000000..c5a42ae0a9
--- /dev/null
+++ b/src/server/activitypub/outbox.ts
@@ -0,0 +1,45 @@
+import * as express from 'express';
+import context from '../../common/remote/activitypub/renderer/context';
+import renderNote from '../../common/remote/activitypub/renderer/note';
+import renderOrderedCollection from '../../common/remote/activitypub/renderer/ordered-collection';
+import parseAcct from '../../common/user/parse-acct';
+import config from '../../conf';
+import Post from '../../models/post';
+import User from '../../models/user';
+
+const app = express();
+app.disable('x-powered-by');
+
+app.get('/@:user/outbox', async (req, res) => {
+	const { username, host } = parseAcct(req.params.user);
+	if (host !== null) {
+		return res.sendStatus(422);
+	}
+
+	const user = await User.findOne({
+		usernameLower: username.toLowerCase(),
+		host: null
+	});
+	if (user === null) {
+		return res.sendStatus(404);
+	}
+
+	const id = `${config.url}/@${user.username}/inbox`;
+
+	if (username !== user.username) {
+		return res.redirect(id);
+	}
+
+	const posts = await Post.find({ userId: user._id }, {
+		limit: 20,
+		sort: { _id: -1 }
+	});
+
+	const renderedPosts = await Promise.all(posts.map(post => renderNote(user, post)));
+	const rendered = renderOrderedCollection(id, user.postsCount, renderedPosts);
+	rendered['@context'] = context;
+
+	res.json(rendered);
+});
+
+export default app;
diff --git a/src/server/activitypub/post.ts b/src/server/activitypub/post.ts
index bdfce0606d..6644563d8c 100644
--- a/src/server/activitypub/post.ts
+++ b/src/server/activitypub/post.ts
@@ -1,8 +1,7 @@
 import * as express from 'express';
-import context from '../../common/remote/activitypub/context';
+import context from '../../common/remote/activitypub/renderer/context';
+import render from '../../common/remote/activitypub/renderer/note';
 import parseAcct from '../../common/user/parse-acct';
-import config from '../../conf';
-import DriveFile from '../../models/drive-file';
 import Post from '../../models/post';
 import User from '../../models/user';
 
@@ -36,50 +35,10 @@ app.get('/@:user/:post', async (req, res, next) => {
 		return res.sendStatus(404);
 	}
 
-	const promisedFiles = DriveFile.find({ _id: { $in: post.mediaIds } });
-	let inReplyTo;
+	const rendered = await render(user, post);
+	rendered['@context'] = context;
 
-	if (post.replyId) {
-		const inReplyToPost = await Post.findOne({
-			_id: post.replyId,
-		});
-
-		if (inReplyToPost !== null) {
-			const inReplyToUser = await User.findOne({
-				_id: post.userId,
-			});
-
-			if (inReplyToUser !== null) {
-				inReplyTo = `${config.url}@${inReplyToUser.username}/${inReplyToPost._id}`;
-			}
-		}
-	} else {
-		inReplyTo = null;
-	}
-
-	const attributedTo = `${config.url}/@${user.username}`;
-
-	res.json({
-		'@context': context,
-		id: `${attributedTo}/${post._id}`,
-		type: 'Note',
-		attributedTo,
-		content: post.textHtml,
-		published: post.createdAt.toISOString(),
-		to: 'https://www.w3.org/ns/activitystreams#Public',
-		cc: `${attributedTo}/followers`,
-		inReplyTo,
-		attachment: (await promisedFiles).map(({ _id, contentType }) => ({
-			type: 'Document',
-			mediaType: contentType,
-			url: `${config.drive_url}/${_id}`
-		})),
-		tag: post.tags.map(tag => ({
-			type: 'Hashtag',
-			href: `${config.url}/search?q=#${encodeURIComponent(tag)}`,
-			name: '#' + tag
-		}))
-	});
+	res.json(rendered);
 });
 
 export default app;
diff --git a/src/server/activitypub/user.ts b/src/server/activitypub/user.ts
index ef365c2078..d43a9793d4 100644
--- a/src/server/activitypub/user.ts
+++ b/src/server/activitypub/user.ts
@@ -1,9 +1,9 @@
 import * as express from 'express';
 import config from '../../conf';
-import { extractPublic } from '../../crypto_key';
-import context from '../../common/remote/activitypub/context';
+import context from '../../common/remote/activitypub/renderer/context';
+import render from '../../common/remote/activitypub/renderer/person';
 import parseAcct from '../../common/user/parse-acct';
-import User, { ILocalAccount } from '../../models/user';
+import User from '../../models/user';
 
 const app = express();
 app.disable('x-powered-by');
@@ -27,34 +27,14 @@ app.get('/@:user', async (req, res, next) => {
 		return res.sendStatus(404);
 	}
 
-	const id = `${config.url}/@${user.username}`;
-
 	if (username !== user.username) {
-		return res.redirect(id);
+		return res.redirect(`${config.url}/@${user.username}`);
 	}
 
-	res.json({
-		'@context': context,
-		type: 'Person',
-		id,
-		inbox: `${id}/inbox`,
-		preferredUsername: user.username,
-		name: user.name,
-		summary: user.description,
-		icon: user.avatarId && {
-			type: 'Image',
-			url: `${config.drive_url}/${user.avatarId}`
-		},
-		image: user.bannerId && {
-			type: 'Image',
-			url: `${config.drive_url}/${user.bannerId}`
-		},
-		publicKey: {
-			type: 'Key',
-			owner: id,
-			publicKeyPem: extractPublic((user.account as ILocalAccount).keypair)
-		}
-	});
+	const rendered = render(user);
+	rendered['@context'] = context;
+
+	res.json(rendered);
 });
 
 export default app;

From a3cef6e9b59e2dc51d6cc2762f8ef9700c9ddc0d Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Sun, 1 Apr 2018 19:43:27 +0900
Subject: [PATCH 27/28] Update README.md

---
 README.md | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/README.md b/README.md
index f7d67247a0..4c0506709d 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ Misskey
 [![][dependencies-badge]][dependencies-link]
 [![][himawari-badge]][himasaku]
 [![][sakurako-badge]][himasaku]
-[![][agpl-3.0-badge]][AGPL-3.0]
+[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com)
 
 > Lead Maintainer: [syuilo][syuilo-link]
 
@@ -50,6 +50,8 @@ If you want to donate to Misskey, please see [this](./docs/donate.ja.md).
 
 Misskey is an open-source software licensed under [GNU AGPLv3](LICENSE).
 
+[![][agpl-3.0-badge]][AGPL-3.0]
+
 [agpl-3.0]:           https://www.gnu.org/licenses/agpl-3.0.en.html
 [agpl-3.0-badge]:     https://img.shields.io/badge/license-AGPL--3.0-444444.svg?style=flat-square
 [travis-link]:        https://travis-ci.org/syuilo/misskey

From 109199e445bb782854189eee9727341889aeaa4d Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Sun, 1 Apr 2018 19:53:35 +0900
Subject: [PATCH 28/28] Clean up

---
 tools/letsencrypt/get-cert.sh                 | 12 ---
 tools/migration/node.1509958623.use-gridfs.js | 71 ---------------
 ...change-gridfs-metadata-name-to-filename.js | 50 -----------
 tools/migration/node.1510056272.issue_882.js  | 47 ----------
 tools/migration/node.2017-11-08.js            | 88 -------------------
 tools/migration/node.2017-12-11.js            | 71 ---------------
 tools/migration/node.2017-12-22.hiseikika.js  | 67 --------------
 tools/migration/node.2018-03-13.othello.js    | 46 ----------
 .../shell.1487734995.user-profile.js          | 18 ----
 .../shell.1489951459.like-to-reactions.js     | 22 -----
 .../shell.1509507382.reply_to-to-reply.js     |  5 --
 11 files changed, 497 deletions(-)
 delete mode 100644 tools/letsencrypt/get-cert.sh
 delete mode 100644 tools/migration/node.1509958623.use-gridfs.js
 delete mode 100644 tools/migration/node.1510016282.change-gridfs-metadata-name-to-filename.js
 delete mode 100644 tools/migration/node.1510056272.issue_882.js
 delete mode 100644 tools/migration/node.2017-11-08.js
 delete mode 100644 tools/migration/node.2017-12-11.js
 delete mode 100644 tools/migration/node.2017-12-22.hiseikika.js
 delete mode 100644 tools/migration/node.2018-03-13.othello.js
 delete mode 100644 tools/migration/shell.1487734995.user-profile.js
 delete mode 100644 tools/migration/shell.1489951459.like-to-reactions.js
 delete mode 100644 tools/migration/shell.1509507382.reply_to-to-reply.js

diff --git a/tools/letsencrypt/get-cert.sh b/tools/letsencrypt/get-cert.sh
deleted file mode 100644
index d44deb1443..0000000000
--- a/tools/letsencrypt/get-cert.sh
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/bin/sh
-
-certbot certonly --standalone\
-  -d $1\
-  -d api.$1\
-  -d auth.$1\
-  -d docs.$1\
-  -d ch.$1\
-  -d stats.$1\
-  -d status.$1\
-  -d dev.$1\
-  -d file.$2\
diff --git a/tools/migration/node.1509958623.use-gridfs.js b/tools/migration/node.1509958623.use-gridfs.js
deleted file mode 100644
index a9d2b12e95..0000000000
--- a/tools/migration/node.1509958623.use-gridfs.js
+++ /dev/null
@@ -1,71 +0,0 @@
-// for Node.js interpret
-
-const { default: db } = require('../../built/db/mongodb')
-const { default: DriveFile, getGridFSBucket } = require('../../built/api/models/drive-file')
-const { Duplex } = require('stream')
-const { default: zip } = require('@prezzemolo/zip')
-
-const writeToGridFS = (bucket, buffer, ...rest) => new Promise((resolve, reject) => {
-	const writeStream = bucket.openUploadStreamWithId(...rest)
-
-	const dataStream = new Duplex()
-	dataStream.push(buffer)
-	dataStream.push(null)
-
-	writeStream.once('finish', resolve)
-	writeStream.on('error', reject)
-
-	dataStream.pipe(writeStream)
-})
-
-const migrateToGridFS = async (doc) => {
-	const id = doc._id
-	const buffer = doc.data ? doc.data.buffer : Buffer.from([0x00]) // アップロードのバグなのか知らないけどなぜか data が存在しない drive_file ドキュメントがまれにあることがわかったので
-	const created_at = doc.created_at
-	const name = doc.name
-	const type = doc.type
-
-	delete doc._id
-	delete doc.created_at
-	delete doc.datasize
-	delete doc.hash
-	delete doc.data
-	delete doc.name
-	delete doc.type
-
-	const bucket = await getGridFSBucket()
-	const added = await writeToGridFS(bucket, buffer, id, name, { contentType: type, metadata: doc })
-
-	const result = await DriveFile.update(id, {
-		$set: {
-			uploadDate: created_at
-		}
-	})
-
-	return added && result.ok === 1
-}
-
-async function main() {
-	const count = await db.get('drive_files').count({});
-
-	console.log(`there are ${count} files.`)
-
-	const dop = Number.parseInt(process.argv[2]) || 5
-	const idop = ((count - (count % dop)) / dop) + 1
-
-	return zip(
-		1,
-		async (time) => {
-			console.log(`${time} / ${idop}`)
-			const doc = await db.get('drive_files').find({}, { limit: dop, skip: time * dop })
-			return Promise.all(doc.map(migrateToGridFS))
-		},
-		idop
-	).then(a => {
-		const rv = []
-		a.forEach(e => rv.push(...e))
-		return rv
-	})
-}
-
-main().then(console.dir).catch(console.error)
diff --git a/tools/migration/node.1510016282.change-gridfs-metadata-name-to-filename.js b/tools/migration/node.1510016282.change-gridfs-metadata-name-to-filename.js
deleted file mode 100644
index d7b2a6eff4..0000000000
--- a/tools/migration/node.1510016282.change-gridfs-metadata-name-to-filename.js
+++ /dev/null
@@ -1,50 +0,0 @@
-// for Node.js interpret
-/**
- * change usage of GridFS filename
- * see commit fb422b4d603c53a70712caba55b35a48a8c2e619
- */
-
-const { default: DriveFile } = require('../../built/api/models/drive-file')
-
-async function applyNewChange (doc) {
-	const result = await DriveFile.update(doc._id, {
-		$set: {
-			filename: doc.metadata.name
-		},
-		$unset: {
-			'metadata.name': ''
-		}
-	})
-	return result.ok === 1
-}
-
-async function main () {
-	const query = {
-		'metadata.name': {
-			$exists: true
-		}
-	}
-
-	const count = await DriveFile.count(query)
-
-	const dop = Number.parseInt(process.argv[2]) || 5
-	const idop = ((count - (count % dop)) / dop) + 1
-
-	return zip(
-		1,
-		async (time) => {
-			console.log(`${time} / ${idop}`)
-			const doc = await DriveFile.find(query, {
-				limit: dop, skip: time * dop
-			})
-			return Promise.all(doc.map(applyNewChange))
-		},
-		idop
-	).then(a => {
-		const rv = []
-		a.forEach(e => rv.push(...e))
-		return rv
-	})
-}
-
-main().then(console.dir).catch(console.error)
diff --git a/tools/migration/node.1510056272.issue_882.js b/tools/migration/node.1510056272.issue_882.js
deleted file mode 100644
index 302ef3de65..0000000000
--- a/tools/migration/node.1510056272.issue_882.js
+++ /dev/null
@@ -1,47 +0,0 @@
-// for Node.js interpret
-
-const { default: DriveFile } = require('../../built/api/models/drive-file')
-const { default: zip } = require('@prezzemolo/zip')
-
-const migrate = async (doc) => {
-	const result = await DriveFile.update(doc._id, {
-		$set: {
-			contentType: doc.metadata.type
-		},
-		$unset: {
-			'metadata.type': ''
-		}
-	})
-	return result.ok === 1
-}
-
-async function main() {
-	const query = {
-		'metadata.type': {
-			$exists: true
-		}
-	}
-
-	const count = await DriveFile.count(query);
-
-	const dop = Number.parseInt(process.argv[2]) || 5
-	const idop = ((count - (count % dop)) / dop) + 1
-
-	return zip(
-		1,
-		async (time) => {
-			console.log(`${time} / ${idop}`)
-			const doc = await DriveFile.find(query, {
-				limit: dop, skip: time * dop
-			})
-			return Promise.all(doc.map(migrate))
-		},
-		idop
-	).then(a => {
-		const rv = []
-		a.forEach(e => rv.push(...e))
-		return rv
-	})
-}
-
-main().then(console.dir).catch(console.error)
diff --git a/tools/migration/node.2017-11-08.js b/tools/migration/node.2017-11-08.js
deleted file mode 100644
index 196a5a90c8..0000000000
--- a/tools/migration/node.2017-11-08.js
+++ /dev/null
@@ -1,88 +0,0 @@
-const uuid = require('uuid');
-const { default: User } = require('../../built/api/models/user')
-const { default: zip } = require('@prezzemolo/zip')
-
-const home = {
-	left: [
-		'profile',
-		'calendar',
-		'activity',
-		'rss-reader',
-		'trends',
-		'photo-stream',
-		'version'
-	],
-	right: [
-		'broadcast',
-		'notifications',
-		'user-recommendation',
-		'recommended-polls',
-		'server',
-		'donation',
-		'nav',
-		'tips'
-	]
-};
-
-const migrate = async (doc) => {
-
-	//#region Construct home data
-	const homeData = [];
-
-	home.left.forEach(widget => {
-		homeData.push({
-			name: widget,
-			id: uuid(),
-			place: 'left',
-			data: {}
-		});
-	});
-
-	home.right.forEach(widget => {
-		homeData.push({
-			name: widget,
-			id: uuid(),
-			place: 'right',
-			data: {}
-		});
-	});
-	//#endregion
-
-	const result = await User.update(doc._id, {
-		$unset: {
-			data: ''
-		},
-		$set: {
-			'settings': {},
-			'client_settings.home': homeData,
-			'client_settings.show_donation': false
-		}
-	})
-
-	return result.ok === 1
-}
-
-async function main() {
-	const count = await User.count();
-
-	console.log(`there are ${count} users.`)
-
-	const dop = Number.parseInt(process.argv[2]) || 5
-	const idop = ((count - (count % dop)) / dop) + 1
-
-	return zip(
-		1,
-		async (time) => {
-			console.log(`${time} / ${idop}`)
-			const docs = await User.find({}, { limit: dop, skip: time * dop })
-			return Promise.all(docs.map(migrate))
-		},
-		idop
-	).then(a => {
-		const rv = []
-		a.forEach(e => rv.push(...e))
-		return rv
-	})
-}
-
-main().then(console.dir).catch(console.error)
diff --git a/tools/migration/node.2017-12-11.js b/tools/migration/node.2017-12-11.js
deleted file mode 100644
index b9686b8b4d..0000000000
--- a/tools/migration/node.2017-12-11.js
+++ /dev/null
@@ -1,71 +0,0 @@
-// for Node.js interpret
-
-const { default: DriveFile, getGridFSBucket } = require('../../built/api/models/drive-file')
-const { default: zip } = require('@prezzemolo/zip')
-
-const _gm = require('gm');
-const gm = _gm.subClass({
-	imageMagick: true
-});
-
-const migrate = doc => new Promise(async (res, rej) => {
-	const bucket = await getGridFSBucket();
-
-	const readable = bucket.openDownloadStream(doc._id);
-
-	gm(readable)
-		.setFormat('ppm')
-		.resize(1, 1)
-		.toBuffer(async (err, buffer) => {
-			if (err) {
-				console.error(err);
-				res(false);
-				return;
-			}
-			const r = buffer.readUInt8(buffer.length - 3);
-			const g = buffer.readUInt8(buffer.length - 2);
-			const b = buffer.readUInt8(buffer.length - 1);
-
-			const result = await DriveFile.update(doc._id, {
-				$set: {
-					'metadata.properties.average_color': [r, g, b]
-				}
-			})
-
-			res(result.ok === 1);
-		});
-});
-
-async function main() {
-	const query = {
-		contentType: {
-			$in: [
-				'image/png',
-				'image/jpeg'
-			]
-		}
-	}
-
-	const count = await DriveFile.count(query);
-
-	const dop = Number.parseInt(process.argv[2]) || 5
-	const idop = ((count - (count % dop)) / dop) + 1
-
-	return zip(
-		1,
-		async (time) => {
-			console.log(`${time} / ${idop}`)
-			const doc = await DriveFile.find(query, {
-				limit: dop, skip: time * dop
-			})
-			return Promise.all(doc.map(migrate))
-		},
-		idop
-	).then(a => {
-		const rv = []
-		a.forEach(e => rv.push(...e))
-		return rv
-	})
-}
-
-main().then(console.dir).catch(console.error)
diff --git a/tools/migration/node.2017-12-22.hiseikika.js b/tools/migration/node.2017-12-22.hiseikika.js
deleted file mode 100644
index ff8294c8d1..0000000000
--- a/tools/migration/node.2017-12-22.hiseikika.js
+++ /dev/null
@@ -1,67 +0,0 @@
-// for Node.js interpret
-
-const { default: Post } = require('../../built/api/models/post')
-const { default: zip } = require('@prezzemolo/zip')
-
-const migrate = async (post) => {
-	const x = {};
-	if (post.reply_id != null) {
-		const reply = await Post.findOne({
-			_id: post.reply_id
-		});
-		x['_reply.user_id'] = reply.user_id;
-	}
-	if (post.repost_id != null) {
-		const repost = await Post.findOne({
-			_id: post.repost_id
-		});
-		x['_repost.user_id'] = repost.user_id;
-	}
-	if (post.reply_id != null || post.repost_id != null) {
-		const result = await Post.update(post._id, {
-			$set: x,
-		});
-		return result.ok === 1;
-	} else {
-		return true;
-	}
-}
-
-async function main() {
-	const query = {
-		$or: [{
-			reply_id: {
-				$exists: true,
-				$ne: null
-			}
-		}, {
-			repost_id: {
-				$exists: true,
-				$ne: null
-			}
-		}]
-	}
-
-	const count = await Post.count(query);
-
-	const dop = Number.parseInt(process.argv[2]) || 5
-	const idop = ((count - (count % dop)) / dop) + 1
-
-	return zip(
-		1,
-		async (time) => {
-			console.log(`${time} / ${idop}`)
-			const doc = await Post.find(query, {
-				limit: dop, skip: time * dop
-			})
-			return Promise.all(doc.map(migrate))
-		},
-		idop
-	).then(a => {
-		const rv = []
-		a.forEach(e => rv.push(...e))
-		return rv
-	})
-}
-
-main().then(console.dir).catch(console.error)
diff --git a/tools/migration/node.2018-03-13.othello.js b/tools/migration/node.2018-03-13.othello.js
deleted file mode 100644
index 4598f8d832..0000000000
--- a/tools/migration/node.2018-03-13.othello.js
+++ /dev/null
@@ -1,46 +0,0 @@
-// for Node.js interpret
-
-const { default: Othello } = require('../../built/api/models/othello-game')
-const { default: zip } = require('@prezzemolo/zip')
-
-const migrate = async (doc) => {
-	const x = {};
-
-	doc.logs.forEach(log => {
-		log.color = log.color == 'black';
-	});
-
-	const result = await Othello.update(doc._id, {
-		$set: {
-			logs: doc.logs
-		}
-	});
-
-	return result.ok === 1;
-}
-
-async function main() {
-
-	const count = await Othello.count({});
-
-	const dop = Number.parseInt(process.argv[2]) || 5
-	const idop = ((count - (count % dop)) / dop) + 1
-
-	return zip(
-		1,
-		async (time) => {
-			console.log(`${time} / ${idop}`)
-			const doc = await Othello.find({}, {
-				limit: dop, skip: time * dop
-			})
-			return Promise.all(doc.map(migrate))
-		},
-		idop
-	).then(a => {
-		const rv = []
-		a.forEach(e => rv.push(...e))
-		return rv
-	})
-}
-
-main().then(console.dir).catch(console.error)
diff --git a/tools/migration/shell.1487734995.user-profile.js b/tools/migration/shell.1487734995.user-profile.js
deleted file mode 100644
index e6666319e1..0000000000
--- a/tools/migration/shell.1487734995.user-profile.js
+++ /dev/null
@@ -1,18 +0,0 @@
-db.users.find({}).forEach(function(user) {
-	print(user._id);
-	db.users.update({ _id: user._id }, {
-		$rename: {
-			bio: 'description'
-		},
-		$unset: {
-			location: '',
-			birthday: ''
-		},
-		$set: {
-			profile: {
-				location: user.location || null,
-				birthday: user.birthday || null
-			}
-		}
-	}, false, false);
-});
diff --git a/tools/migration/shell.1489951459.like-to-reactions.js b/tools/migration/shell.1489951459.like-to-reactions.js
deleted file mode 100644
index 962a0f00ef..0000000000
--- a/tools/migration/shell.1489951459.like-to-reactions.js
+++ /dev/null
@@ -1,22 +0,0 @@
-db.users.update({}, {
-	$unset: {
-		likes_count: 1,
-		liked_count: 1
-	}
-}, false, true)
-
-db.likes.renameCollection('post_reactions')
-
-db.post_reactions.update({}, {
-	$set: {
-		reaction: 'like'
-	}
-}, false, true)
-
-db.posts.update({}, {
-	$rename: {
-		likes_count: 'reaction_counts.like'
-	}
-}, false, true);
-
-db.notifications.remove({})
diff --git a/tools/migration/shell.1509507382.reply_to-to-reply.js b/tools/migration/shell.1509507382.reply_to-to-reply.js
deleted file mode 100644
index ceb272ebc9..0000000000
--- a/tools/migration/shell.1509507382.reply_to-to-reply.js
+++ /dev/null
@@ -1,5 +0,0 @@
-db.posts.update({}, {
-	$rename: {
-		reply_to_id: 'reply_id'
-	}
-}, false, true);