diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2018-04-01 19:57:36 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2018-04-01 19:57:36 +0900 |
| commit | fabda94932ebdd012b0772ce1e63ac515f546d84 (patch) | |
| tree | 1c5ffb9d42ebdb9f1830b331d3c1611b8ebc13ae /src | |
| parent | fix(package): update html-minifier to version 3.5.13 (diff) | |
| parent | Merge pull request #1354 from syuilo/greenkeeper/bootstrap-vue-2.0.0-rc.4 (diff) | |
| download | misskey-fabda94932ebdd012b0772ce1e63ac515f546d84.tar.gz misskey-fabda94932ebdd012b0772ce1e63ac515f546d84.tar.bz2 misskey-fabda94932ebdd012b0772ce1e63ac515f546d84.zip | |
Merge branch 'master' into greenkeeper/html-minifier-3.5.13
Diffstat (limited to 'src')
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; |