summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2018-04-01 19:57:36 +0900
committerGitHub <noreply@github.com>2018-04-01 19:57:36 +0900
commitfabda94932ebdd012b0772ce1e63ac515f546d84 (patch)
tree1c5ffb9d42ebdb9f1830b331d3c1611b8ebc13ae /src
parentfix(package): update html-minifier to version 3.5.13 (diff)
parentMerge pull request #1354 from syuilo/greenkeeper/bootstrap-vue-2.0.0-rc.4 (diff)
downloadmisskey-fabda94932ebdd012b0772ce1e63ac515f546d84.tar.gz
misskey-fabda94932ebdd012b0772ce1e63ac515f546d84.tar.bz2
misskey-fabda94932ebdd012b0772ce1e63ac515f546d84.zip
Merge branch 'master' into greenkeeper/html-minifier-3.5.13
Diffstat (limited to 'src')
-rw-r--r--src/client/app/common/views/components/index.ts2
-rw-r--r--src/client/app/common/views/components/messaging-room.message.vue41
-rw-r--r--src/client/app/common/views/components/post-html.ts157
-rw-r--r--src/client/app/common/views/components/post-html.vue103
-rw-r--r--src/client/app/common/views/components/url.vue57
-rw-r--r--src/client/app/common/views/components/welcome-timeline.vue2
-rw-r--r--src/client/app/desktop/views/components/post-detail.sub.vue2
-rw-r--r--src/client/app/desktop/views/components/post-detail.vue53
-rw-r--r--src/client/app/desktop/views/components/posts.post.vue41
-rw-r--r--src/client/app/desktop/views/components/sub-post-content.vue2
-rw-r--r--src/client/app/desktop/views/components/ui.header.vue10
-rw-r--r--src/client/app/mobile/views/components/post-detail.vue37
-rw-r--r--src/client/app/mobile/views/components/post.vue43
-rw-r--r--src/client/app/mobile/views/components/sub-post-content.vue2
-rw-r--r--src/client/docs/api/gulpfile.ts2
-rw-r--r--src/common/remote/activitypub/renderer/context.ts5
-rw-r--r--src/common/remote/activitypub/renderer/document.ts7
-rw-r--r--src/common/remote/activitypub/renderer/hashtag.ts7
-rw-r--r--src/common/remote/activitypub/renderer/image.ts6
-rw-r--r--src/common/remote/activitypub/renderer/key.ts9
-rw-r--r--src/common/remote/activitypub/renderer/note.ts44
-rw-r--r--src/common/remote/activitypub/renderer/ordered-collection.ts6
-rw-r--r--src/common/remote/activitypub/renderer/person.ts20
-rw-r--r--src/common/remote/activitypub/resolve-person.ts4
-rw-r--r--src/common/text/parse/index.ts2
-rw-r--r--src/crypto_key.d.ts1
-rw-r--r--src/models/post.ts1
-rw-r--r--src/models/user.ts59
-rw-r--r--src/server/activitypub/inbox.ts42
-rw-r--r--src/server/activitypub/index.ts16
-rw-r--r--src/server/activitypub/outbox.ts45
-rw-r--r--src/server/activitypub/post.ts44
-rw-r--r--src/server/activitypub/user.ts40
-rw-r--r--src/server/api/endpoints/users/show.ts2
-rw-r--r--src/server/index.ts4
-rw-r--r--src/server/webfinger.ts47
36 files changed, 691 insertions, 274 deletions
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..d6481e13d0 100644
--- a/src/client/app/desktop/views/components/post-detail.vue
+++ b/src/client/app/desktop/views/components/post-detail.vue
@@ -27,18 +27,18 @@
</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>
<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,25 @@ export default Vue.extend({
},
title(): string {
return dateStringify(this.p.createdAt);
+ },
+ acct(): string {
+ return getAcct(this.post.user);
+ },
+ pAcct(): 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 +177,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/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>
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/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: {
diff --git a/src/common/remote/activitypub/renderer/context.ts b/src/common/remote/activitypub/renderer/context.ts
new file mode 100644
index 0000000000..b56f727ae7
--- /dev/null
+++ b/src/common/remote/activitypub/renderer/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/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/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/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;
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/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;
diff --git a/src/models/user.ts b/src/models/user.ts
index 4728682d67..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 = {
@@ -278,61 +282,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/inbox.ts b/src/server/activitypub/inbox.ts
new file mode 100644
index 0000000000..9151297487
--- /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.post('/@: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.status(202).end();
+});
+
+export default app;
diff --git a/src/server/activitypub/index.ts b/src/server/activitypub/index.ts
new file mode 100644
index 0000000000..c81024d15f
--- /dev/null
+++ b/src/server/activitypub/index.ts
@@ -0,0 +1,16 @@
+import * as express from 'express';
+
+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(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
new file mode 100644
index 0000000000..6644563d8c
--- /dev/null
+++ b/src/server/activitypub/post.ts
@@ -0,0 +1,44 @@
+import * as express from 'express';
+import context from '../../common/remote/activitypub/renderer/context';
+import render from '../../common/remote/activitypub/renderer/note';
+import parseAcct from '../../common/user/parse-acct';
+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 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 rendered = await render(user, post);
+ rendered['@context'] = context;
+
+ res.json(rendered);
+});
+
+export default app;
diff --git a/src/server/activitypub/user.ts b/src/server/activitypub/user.ts
new file mode 100644
index 0000000000..d43a9793d4
--- /dev/null
+++ b/src/server/activitypub/user.ts
@@ -0,0 +1,40 @@
+import * as express from 'express';
+import config from '../../conf';
+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 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'] 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);
+ }
+
+ if (username !== user.username) {
+ return res.redirect(`${config.url}/@${user.username}`);
+ }
+
+ const rendered = render(user);
+ rendered['@context'] = context;
+
+ res.json(rendered);
+});
+
+export default app;
diff --git a/src/server/api/endpoints/users/show.ts b/src/server/api/endpoints/users/show.ts
index 9cd8716fe5..f7b37193b8 100644
--- a/src/server/api/endpoints/users/show.ts
+++ b/src/server/api/endpoints/users/show.ts
@@ -26,7 +26,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') {
diff --git a/src/server/index.ts b/src/server/index.ts
index fe22d9c9b3..1874790116 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -9,6 +9,8 @@ import * as express from 'express';
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';
@@ -53,6 +55,8 @@ 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;