diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2019-07-19 03:38:05 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2019-07-19 03:38:05 +0900 |
| commit | cd5b24d4eb494a4e9279348639e30b28bcdaa9f9 (patch) | |
| tree | abc65a9511b5affbcfd2ff063c8eda2c6251c637 /src | |
| parent | Merge branch 'develop' (diff) | |
| parent | 11.26.0 (diff) | |
| download | sharkey-cd5b24d4eb494a4e9279348639e30b28bcdaa9f9.tar.gz sharkey-cd5b24d4eb494a4e9279348639e30b28bcdaa9f9.tar.bz2 sharkey-cd5b24d4eb494a4e9279348639e30b28bcdaa9f9.zip | |
Merge branch 'develop'
Diffstat (limited to 'src')
56 files changed, 739 insertions, 75 deletions
diff --git a/src/client/app/admin/views/moderators.vue b/src/client/app/admin/views/moderators.vue index bf7d951fc7..8ceab02d97 100644 --- a/src/client/app/admin/views/moderators.vue +++ b/src/client/app/admin/views/moderators.vue @@ -12,6 +12,31 @@ </ui-horizon-group> </section> </ui-card> + + <ui-card> + <template #title>{{ $t('logs.title') }}</template> + <section class="fit-top"> + <sequential-entrance animation="entranceFromTop" delay="25"> + <div v-for="log in logs" :key="log.id" class=""> + <ui-horizon-group inputs> + <ui-input :value="log.user | acct" type="text" readonly> + <span>{{ $t('logs.moderator') }}</span> + </ui-input> + <ui-input :value="log.type" type="text" readonly> + <span>{{ $t('logs.type') }}</span> + </ui-input> + <ui-input :value="log.createdAt | date" type="text" readonly> + <span>{{ $t('logs.at') }}</span> + </ui-input> + </ui-horizon-group> + <ui-textarea :value="JSON.stringify(log.info, null, 4)" readonly> + <span>{{ $t('logs.info') }}</span> + </ui-textarea> + </div> + </sequential-entrance> + <ui-button v-if="existMoreLogs" @click="fetchLogs">{{ $t('@.load-more') }}</ui-button> + </section> + </ui-card> </div> </template> @@ -26,10 +51,17 @@ export default Vue.extend({ data() { return { username: '', - changing: false + changing: false, + logs: [], + untilLogId: null, + existMoreLogs: false }; }, + created() { + this.fetchLogs(); + }, + methods: { async add() { this.changing = true; @@ -74,6 +106,22 @@ export default Vue.extend({ this.changing = false; }, + + fetchLogs() { + this.$root.api('admin/show-moderation-logs', { + untilId: this.untilId, + limit: 10 + 1 + }).then(logs => { + if (logs.length == 10 + 1) { + logs.pop(); + this.existMoreLogs = true; + } else { + this.existMoreLogs = false; + } + this.logs = this.logs.concat(logs); + this.untilLogId = this.logs[this.logs.length - 1].id; + }); + }, } }); </script> diff --git a/src/client/app/admin/views/users.user.vue b/src/client/app/admin/views/users.user.vue index 929fc8f4b3..9c3db2d6c2 100644 --- a/src/client/app/admin/views/users.user.vue +++ b/src/client/app/admin/views/users.user.vue @@ -5,7 +5,7 @@ <mk-avatar class="avatar" :user="user" :disable-link="true"/> </a> </div> - <div> + <div @click="click(user.id)"> <header> <b><mk-user-name :user="user"/></b> <span class="username">@{{ user | acct }}</span> @@ -32,7 +32,7 @@ import { faSnowflake } from '@fortawesome/free-regular-svg-icons'; export default Vue.extend({ i18n: i18n('admin/views/users.vue'), - props: ['user'], + props: ['user', 'click'], data() { return { faSnowflake, faMicrophoneSlash @@ -44,7 +44,7 @@ export default Vue.extend({ <style lang="stylus" scoped> .kofvwchc display flex - padding 16px 0 + padding 16px border-top solid 1px var(--faceDivider) > div:first-child @@ -55,6 +55,7 @@ export default Vue.extend({ > div:last-child flex 1 + cursor pointer padding-left 16px @media (max-width 500px) @@ -80,4 +81,15 @@ export default Vue.extend({ > .is-suspended margin 0 0 0 .5em color #4dabf7 + + &:hover + color var(--primaryForeground) + background var(--primary) + text-decoration none + border-radius 3px + + &:active + color var(--primaryForeground) + background var(--primaryDarken10) + border-radius 3px </style> diff --git a/src/client/app/admin/views/users.vue b/src/client/app/admin/views/users.vue index fd9f0dd8b2..92b23749ff 100644 --- a/src/client/app/admin/views/users.vue +++ b/src/client/app/admin/views/users.vue @@ -8,7 +8,7 @@ </ui-input> <ui-button @click="showUser"><fa :icon="faSearch"/> {{ $t('lookup') }}</ui-button> - <div class="user" v-if="user"> + <div ref="user" class="user" v-if="user" :key="user.id"> <x-user :user="user"/> <div class="actions"> <ui-button v-if="user.host != null" @click="updateRemoteUser"><fa :icon="faSync"/> {{ $t('update-remote-user') }}</ui-button> @@ -54,8 +54,16 @@ <option value="remote">{{ $t('users.origin.remote') }}</option> </ui-select> </ui-horizon-group> + <ui-horizon-group searchboxes> + <ui-input v-model="searchUsername" type="text" spellcheck="false" @input="fetchUsers(true)"> + <span>{{ $t('username') }}</span> + </ui-input> + <ui-input v-model="searchHost" type="text" spellcheck="false" @input="fetchUsers(true)" :disabled="origin === 'local'"> + <span>{{ $t('host') }}</span> + </ui-input> + </ui-horizon-group> <sequential-entrance animation="entranceFromTop" delay="25"> - <x-user v-for="user in users" :user='user' :key="user.id"/> + <x-user v-for="user in users" :key="user.id" :user='user' :click="showUserOnClick"/> </sequential-entrance> <ui-button v-if="existMore" @click="fetchUsers">{{ $t('@.load-more') }}</ui-button> </section> @@ -85,6 +93,8 @@ export default Vue.extend({ sort: '+createdAt', state: 'all', origin: 'local', + searchUsername: '', + searchHost: '', limit: 10, offset: 0, users: [], @@ -107,6 +117,7 @@ export default Vue.extend({ }, origin() { + if (this.origin === 'local') this.searchHost = ''; this.users = []; this.offset = 0; this.fetchUsers(); @@ -157,6 +168,15 @@ export default Vue.extend({ this.target = ''; }, + async showUserOnClick(userId: string) { + this.$root.api('admin/show-user', { userId: userId }).then(info => { + this.user = info; + this.$nextTick(() => { + this.$refs.user.scrollIntoView(); + }); + }); + }, + /** 処理対象ユーザーの情報を更新する */ async refreshUser() { this.$root.api('admin/show-user', { userId: this.user.id }).then(info => { @@ -308,13 +328,16 @@ export default Vue.extend({ return !confirm.canceled; }, - fetchUsers() { + fetchUsers(truncate?: boolean) { + if (truncate) this.offset = 0; this.$root.api('admin/show-users', { state: this.state, origin: this.origin, sort: this.sort, offset: this.offset, - limit: this.limit + 1 + limit: this.limit + 1, + username: this.searchUsername, + hostname: this.searchHost }).then(users => { if (users.length == this.limit + 1) { users.pop(); @@ -322,7 +345,7 @@ export default Vue.extend({ } else { this.existMore = false; } - this.users = this.users.concat(users); + this.users = truncate ? users : this.users.concat(users); this.offset += this.limit; }); } diff --git a/src/client/app/common/scripts/collect-page-vars.ts b/src/client/app/common/scripts/collect-page-vars.ts index 4c40d5d88e..a4096fb2c2 100644 --- a/src/client/app/common/scripts/collect-page-vars.ts +++ b/src/client/app/common/scripts/collect-page-vars.ts @@ -32,6 +32,12 @@ export function collectPageVars(content) { type: 'number', value: 0 }); + } else if (x.type === 'radioButton') { + pageVars.push({ + name: x.name, + type: 'string', + value: x.default || '' + }); } else if (x.children) { collect(x.children); } diff --git a/src/client/app/common/scripts/get-face.ts b/src/client/app/common/scripts/get-face.ts index b523948bd3..19f2bdb064 100644 --- a/src/client/app/common/scripts/get-face.ts +++ b/src/client/app/common/scripts/get-face.ts @@ -4,7 +4,8 @@ const faces = [ '🐡( \'-\' 🐡 )フグパンチ!!!!', '✌️(´・_・`)✌️', '(。>﹏<。)', - '(Δ・x・Δ)' + '(Δ・x・Δ)', + '(コ`・ヘ・´ケ)' ]; export default () => faces[Math.floor(Math.random() * faces.length)]; diff --git a/src/client/app/common/views/components/mention.vue b/src/client/app/common/views/components/mention.vue index f212fd3ca5..4e9f9e90d6 100644 --- a/src/client/app/common/views/components/mention.vue +++ b/src/client/app/common/views/components/mention.vue @@ -1,11 +1,17 @@ <template> -<router-link class="ldlomzub" :to="`/${ canonical }`" v-user-preview="canonical"> +<router-link class="ldlomzub" :to="url" v-user-preview="canonical" v-if="url.startsWith('/')"> <span class="me" v-if="isMe">{{ $t('@.you') }}</span> <span class="main"> <span class="username">@{{ username }}</span> <span class="host" :class="{ fade: $store.state.settings.contrastedAcct }" v-if="(host != localHost) || $store.state.settings.showFullAcct">@{{ toUnicode(host) }}</span> </span> </router-link> +<a class="ldlomzub" :href="url" target="_blank" rel="noopener" v-else> + <span class="main"> + <span class="username">@{{ username }}</span> + <span class="host" :class="{ fade: $store.state.settings.contrastedAcct }">@{{ toUnicode(host) }}</span> + </span> +</a> </template> <script lang="ts"> @@ -32,6 +38,15 @@ export default Vue.extend({ }; }, computed: { + url(): string { + switch (this.host) { + case 'twitter.com': + case 'github.com': + return `https://${this.host}/${this.username}`; + default: + return `/${this.canonical}`; + } + }, canonical(): string { return this.host === localHost ? `@${this.username}` : `@${this.username}@${toUnicode(this.host)}`; }, diff --git a/src/client/app/common/views/components/misskey-flavored-markdown.vue b/src/client/app/common/views/components/misskey-flavored-markdown.vue index 64496f9c84..963efd9ab8 100644 --- a/src/client/app/common/views/components/misskey-flavored-markdown.vue +++ b/src/client/app/common/views/components/misskey-flavored-markdown.vue @@ -30,6 +30,7 @@ export default Vue.extend({ border-radius 4px >>> .quote + display block margin 8px padding 6px 0 6px 12px color var(--mfmQuote) diff --git a/src/client/app/common/views/components/page/page.block.vue b/src/client/app/common/views/components/page/page.block.vue index 1c421fc2c0..56d1822013 100644 --- a/src/client/app/common/views/components/page/page.block.vue +++ b/src/client/app/common/views/components/page/page.block.vue @@ -16,10 +16,11 @@ import XIf from './page.if.vue'; import XTextarea from './page.textarea.vue'; import XPost from './page.post.vue'; import XCounter from './page.counter.vue'; +import XRadioButton from './page.radio-button.vue'; export default Vue.extend({ components: { - XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter + XText, XSection, XImage, XButton, XNumberInput, XTextInput, XTextareaInput, XTextarea, XPost, XSwitch, XIf, XCounter, XRadioButton }, props: { diff --git a/src/client/app/common/views/components/page/page.radio-button.vue b/src/client/app/common/views/components/page/page.radio-button.vue new file mode 100644 index 0000000000..27c11bebad --- /dev/null +++ b/src/client/app/common/views/components/page/page.radio-button.vue @@ -0,0 +1,37 @@ +<template> +<div> + <div>{{ script.interpolate(value.title) }}</div> + <ui-radio v-for="x in value.values" v-model="v" :value="x" :key="x">{{ x }}</ui-radio> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: { + value: { + required: true + }, + script: { + required: true + } + }, + + data() { + return { + v: this.value.default, + }; + }, + + watch: { + v() { + this.script.aiScript.updatePageVar(this.value.name, this.v); + this.script.eval(); + } + } +}); +</script> + +<style lang="stylus" scoped> +</style> diff --git a/src/client/app/common/views/components/poll-editor.vue b/src/client/app/common/views/components/poll-editor.vue index f7a4d3af8c..49940134c7 100644 --- a/src/client/app/common/views/components/poll-editor.vue +++ b/src/client/app/common/views/components/poll-editor.vue @@ -26,13 +26,19 @@ <option value="after">{{ $t('after') }}</option> </ui-select> <section v-if="expiration === 'at'"> - <ui-input v-model="atDate" type="date">{{ $t('deadline-date') }}</ui-input> - <ui-input v-model="atTime" type="time">{{ $t('deadline-time') }}</ui-input> + <ui-input v-model="atDate" type="date"> + <template #title>{{ $t('deadline-date') }}</template> + </ui-input> + <ui-input v-model="atTime" type="time"> + <template #title>{{ $t('deadline-time') }}</template> + </ui-input> </section> <section v-if="expiration === 'after'"> - <ui-input v-model="after" type="number">{{ $t('interval') }}</ui-input> + <ui-input v-model="after" type="number"> + <template #title>{{ $t('interval') }}</template> + </ui-input> <ui-select v-model="unit"> - <template #label>{{ $t('unit') }}</template> + <template #title>{{ $t('unit') }}</template> <option value="second">{{ $t('second') }}</option> <option value="minute">{{ $t('minute') }}</option> <option value="hour">{{ $t('hour') }}</option> diff --git a/src/client/app/common/views/components/reaction-picker.vue b/src/client/app/common/views/components/reaction-picker.vue index 970d430069..ff534d37ce 100644 --- a/src/client/app/common/views/components/reaction-picker.vue +++ b/src/client/app/common/views/components/reaction-picker.vue @@ -276,6 +276,7 @@ export default Vue.extend({ font-size 14px color var(--popupFg) border-bottom solid var(--lineWidth) var(--faceDivider) + line-height 20px > .buttons padding 4px 4px 8px 4px diff --git a/src/client/app/common/views/components/settings/app-type.vue b/src/client/app/common/views/components/settings/app-type.vue index 90ff28803b..d163f1e746 100644 --- a/src/client/app/common/views/components/settings/app-type.vue +++ b/src/client/app/common/views/components/settings/app-type.vue @@ -29,8 +29,25 @@ export default Vue.extend({ computed: { appTypeForce: { get() { return this.$store.state.device.appTypeForce; }, - set(value) { this.$store.commit('device/set', { key: 'appTypeForce', value }); } + set(value) { + this.$store.commit('device/set', { key: 'appTypeForce', value }); + this.reload(); + } }, }, + + methods: { + reload() { + this.$root.dialog({ + type: 'warning', + text: this.$t('@.reload-to-apply-the-setting'), + showCancelButton: true + }).then(({ canceled }) => { + if (!canceled) { + location.reload(); + } + }); + }, + } }); </script> diff --git a/src/client/app/common/views/components/settings/profile.vue b/src/client/app/common/views/components/settings/profile.vue index a22fd6df98..edfc5a9edf 100644 --- a/src/client/app/common/views/components/settings/profile.vue +++ b/src/client/app/common/views/components/settings/profile.vue @@ -51,6 +51,26 @@ <template #desc v-if="bannerUploading">{{ $t('uploading') }}<mk-ellipsis/></template> </ui-input> + <div class="fields"> + <header>{{ $t('profile-metadata') }}</header> + <ui-horizon-group> + <ui-input v-model="fieldName0">{{ $t('metadata-label') }}</ui-input> + <ui-input v-model="fieldValue0">{{ $t('metadata-content') }}</ui-input> + </ui-horizon-group> + <ui-horizon-group> + <ui-input v-model="fieldName1">{{ $t('metadata-label') }}</ui-input> + <ui-input v-model="fieldValue1">{{ $t('metadata-content') }}</ui-input> + </ui-horizon-group> + <ui-horizon-group> + <ui-input v-model="fieldName2">{{ $t('metadata-label') }}</ui-input> + <ui-input v-model="fieldValue2">{{ $t('metadata-content') }}</ui-input> + </ui-horizon-group> + <ui-horizon-group> + <ui-input v-model="fieldName3">{{ $t('metadata-label') }}</ui-input> + <ui-input v-model="fieldValue3">{{ $t('metadata-content') }}</ui-input> + </ui-horizon-group> + </div> + <ui-button @click="save(true)"><fa :icon="faSave"/> {{ $t('save') }}</ui-button> </ui-form> </section> @@ -189,6 +209,17 @@ export default Vue.extend({ this.isLocked = this.$store.state.i.isLocked; this.carefulBot = this.$store.state.i.carefulBot; this.autoAcceptFollowed = this.$store.state.i.autoAcceptFollowed; + + if (this.$store.state.i.fields) { + this.fieldName0 = this.$store.state.i.fields[0].name; + this.fieldValue0 = this.$store.state.i.fields[0].value; + this.fieldName1 = this.$store.state.i.fields[1].name; + this.fieldValue1 = this.$store.state.i.fields[1].value; + this.fieldName2 = this.$store.state.i.fields[2].name; + this.fieldValue2 = this.$store.state.i.fields[2].value; + this.fieldName3 = this.$store.state.i.fields[3].name; + this.fieldValue3 = this.$store.state.i.fields[3].value; + } }, methods: { @@ -237,6 +268,13 @@ export default Vue.extend({ }, save(notify) { + const fields = [ + { name: this.fieldName0, value: this.fieldValue0 }, + { name: this.fieldName1, value: this.fieldValue1 }, + { name: this.fieldName2, value: this.fieldValue2 }, + { name: this.fieldName3, value: this.fieldValue3 }, + ]; + this.saving = true; this.$root.api('i/update', { @@ -247,6 +285,7 @@ export default Vue.extend({ birthday: this.birthday || null, avatarId: this.avatarId || undefined, bannerId: this.bannerId || undefined, + fields, isCat: !!this.isCat, isBot: !!this.isBot, isLocked: !!this.isLocked, @@ -265,6 +304,29 @@ export default Vue.extend({ text: this.$t('saved') }); } + }).catch(err => { + this.saving = false; + switch(err.id) { + case 'f419f9f8-2f4d-46b1-9fb4-49d3a2fd7191': + this.$root.dialog({ + type: 'error', + title: this.$t('unable-to-process'), + text: this.$t('avatar-not-an-image') + }); + break; + case '75aedb19-2afd-4e6d-87fc-67941256fa60': + this.$root.dialog({ + type: 'error', + title: this.$t('unable-to-process'), + text: this.$t('banner-not-an-image') + }); + break; + default: + this.$root.dialog({ + type: 'error', + text: this.$t('unable-to-process') + }); + } }); }, @@ -366,4 +428,11 @@ export default Vue.extend({ height 72px margin auto +.fields + > header + padding 8px 0px + font-weight bold + > div + padding-left 16px + </style> diff --git a/src/client/app/common/views/components/settings/settings.vue b/src/client/app/common/views/components/settings/settings.vue index 281524979e..401d9423ae 100644 --- a/src/client/app/common/views/components/settings/settings.vue +++ b/src/client/app/common/views/components/settings/settings.vue @@ -143,13 +143,17 @@ <ui-input v-model="webSearchEngine">{{ $t('@._settings.web-search-engine') }} <template #desc>{{ $t('@._settings.web-search-engine-desc') }}</template> </ui-input> + <ui-button @click="save('webSearchEngine', webSearchEngine)"><fa :icon="faSave"/> {{ $t('@._settings.save') }}</ui-button> </section> <section v-if="!$root.isMobile"> <header>{{ $t('@._settings.paste') }}</header> <ui-input v-model="pastedFileName">{{ $t('@._settings.pasted-file-name') }} - <template #desc>{{ $t('@._settings.pasted-file-name-desc') }}</template> + <template v-if="pastedFileName === this.$store.state.settings.pastedFileName" #desc>{{ $t('@._settings.pasted-file-name-desc') }}</template> + <template v-else #desc>{{ pastedFileNamePreview() }}</template> </ui-input> + <ui-button @click="save('pastedFileName', pastedFileName)"><fa :icon="faSave"/> {{ $t('@._settings.save') }}</ui-button> + <ui-switch v-model="pasteDialog">{{ $t('@._settings.paste-dialog') }} <template #desc>{{ $t('@._settings.paste-dialog-desc') }}</template> </ui-switch> @@ -289,6 +293,8 @@ import XNotification from './notification.vue'; import { url, version } from '../../../../config'; import checkForUpdate from '../../../scripts/check-for-update'; +import { formatTimeString } from '../../../../../../misc/format-time-string'; +import { faSave } from '@fortawesome/free-regular-svg-icons'; export default Vue.extend({ i18n: i18n(), @@ -319,8 +325,11 @@ export default Vue.extend({ return { meta: null, version, + webSearchEngine: this.$store.state.settings.webSearchEngine, + pastedFileName : this.$store.state.settings.pastedFileName, latestVersion: undefined, - checkingForUpdate: false + checkingForUpdate: false, + faSave }; }, computed: { @@ -419,16 +428,6 @@ export default Vue.extend({ set(value) { this.$store.dispatch('settings/set', { key: 'defaultNoteVisibility', value }); } }, - webSearchEngine: { - get() { return this.$store.state.settings.webSearchEngine; }, - set(value) { this.$store.dispatch('settings/set', { key: 'webSearchEngine', value }); } - }, - - pastedFileName: { - get() { return this.$store.state.settings.pastedFileName; }, - set(value) { this.$store.dispatch('settings/set', { key: 'pastedFileName', value }); } - }, - pasteDialog: { get() { return this.$store.state.settings.pasteDialog; }, set(value) { this.$store.dispatch('settings/set', { key: 'pasteDialog', value }); } @@ -565,6 +564,17 @@ export default Vue.extend({ } }); }, + save(key, value) { + this.$store.dispatch('settings/set', { + key, + value + }).then(() => { + this.$root.dialog({ + type: 'success', + text: this.$t('@._settings.saved') + }) + }); + }, customizeHome() { location.href = '/?customize'; }, @@ -600,7 +610,10 @@ export default Vue.extend({ const sound = new Audio(`${url}/assets/message.mp3`); sound.volume = this.$store.state.device.soundVolume; sound.play(); - } + }, + pastedFileNamePreview() { + return `${formatTimeString(new Date(), this.pastedFileName).replace(/{{number}}/g, `1`)}.png` + }, } }); </script> diff --git a/src/client/app/common/views/components/signup.vue b/src/client/app/common/views/components/signup.vue index 421d09a4dd..893f6575fb 100644 --- a/src/client/app/common/views/components/signup.vue +++ b/src/client/app/common/views/components/signup.vue @@ -43,7 +43,7 @@ </i18n> </ui-switch> <div v-if="meta.enableRecaptcha" class="g-recaptcha" :data-sitekey="meta.recaptchaSiteKey" style="margin: 16px 0;"></div> - <ui-button type="submit" :disabled="!(meta.ToSUrl ? ToSAgreement : true) || passwordRetypeState == 'not-match'">{{ $t('create') }}</ui-button> + <ui-button type="submit" :disabled=" submitting || !(meta.ToSUrl ? ToSAgreement : true) || passwordRetypeState == 'not-match'">{{ $t('create') }}</ui-button> </template> </form> </template> @@ -70,6 +70,7 @@ export default Vue.extend({ passwordStrength: '', passwordRetypeState: null, meta: {}, + submitting: false, ToSAgreement: false } }, @@ -145,6 +146,9 @@ export default Vue.extend({ }, onSubmit() { + if (this.submitting) return; + this.submitting = true; + this.$root.api('signup', { username: this.username, password: this.password, @@ -159,6 +163,8 @@ export default Vue.extend({ location.href = '/'; }); }).catch(() => { + this.submitting = false; + this.$root.dialog({ type: 'error', text: this.$t('some-error') diff --git a/src/client/app/common/views/components/url-preview.vue b/src/client/app/common/views/components/url-preview.vue index 476c671e77..80aae5999d 100644 --- a/src/client/app/common/views/components/url-preview.vue +++ b/src/client/app/common/views/components/url-preview.vue @@ -66,6 +66,7 @@ export default Vue.extend({ (this.url.substr(local.length) === '/') || this.url.substr(local.length).startsWith('/@') || this.url.substr(local.length).startsWith('/notes/') || + this.url.substr(local.length).startsWith('/tags/') || this.url.substr(local.length).startsWith('/pages/'); return { local, diff --git a/src/client/app/common/views/components/url.vue b/src/client/app/common/views/components/url.vue index b1ca3f285c..3a304ad6e7 100644 --- a/src/client/app/common/views/components/url.vue +++ b/src/client/app/common/views/components/url.vue @@ -28,6 +28,7 @@ export default Vue.extend({ (this.url.substr(local.length) === '/') || this.url.substr(local.length).startsWith('/@') || this.url.substr(local.length).startsWith('/notes/') || + this.url.substr(local.length).startsWith('/tags/') || this.url.substr(local.length).startsWith('/pages/')); return { local, diff --git a/src/client/app/common/views/pages/page-editor/els/page-editor.el.radio-button.vue b/src/client/app/common/views/pages/page-editor/els/page-editor.el.radio-button.vue new file mode 100644 index 0000000000..3401c46f47 --- /dev/null +++ b/src/client/app/common/views/pages/page-editor/els/page-editor.el.radio-button.vue @@ -0,0 +1,53 @@ +<template> +<x-container @remove="() => $emit('remove')" :draggable="true"> + <template #header><fa :icon="faBolt"/> {{ $t('blocks.radioButton') }}</template> + + <section style="padding: 0 16px 16px 16px;"> + <ui-input v-model="value.name"><template #prefix><fa :icon="faMagic"/></template><span>{{ $t('blocks._radioButton.name') }}</span></ui-input> + <ui-input v-model="value.title"><span>{{ $t('blocks._radioButton.title') }}</span></ui-input> + <ui-textarea v-model="values"><span>{{ $t('blocks._radioButton.values') }}</span></ui-textarea> + <ui-input v-model="value.default"><span>{{ $t('blocks._radioButton.default') }}</span></ui-input> + </section> +</x-container> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { faBolt, faMagic } from '@fortawesome/free-solid-svg-icons'; +import i18n from '../../../../../i18n'; +import XContainer from '../page-editor.container.vue'; + +export default Vue.extend({ + i18n: i18n('pages'), + + components: { + XContainer + }, + + props: { + value: { + required: true + }, + }, + + data() { + return { + values: '', + faBolt, faMagic + }; + }, + + watch: { + values() { + Vue.set(this.value, 'values', this.values.split('\n')); + } + }, + + created() { + if (this.value.name == null) Vue.set(this.value, 'name', ''); + if (this.value.title == null) Vue.set(this.value, 'title', ''); + if (this.value.values == null) Vue.set(this.value, 'values', []); + this.values = this.value.values.join('\n'); + }, +}); +</script> diff --git a/src/client/app/common/views/pages/page-editor/page-editor.blocks.vue b/src/client/app/common/views/pages/page-editor/page-editor.blocks.vue index c5f3419e7b..4d7293231f 100644 --- a/src/client/app/common/views/pages/page-editor/page-editor.blocks.vue +++ b/src/client/app/common/views/pages/page-editor/page-editor.blocks.vue @@ -19,10 +19,11 @@ import XSwitch from './els/page-editor.el.switch.vue'; import XIf from './els/page-editor.el.if.vue'; import XPost from './els/page-editor.el.post.vue'; import XCounter from './els/page-editor.el.counter.vue'; +import XRadioButton from './els/page-editor.el.radio-button.vue'; export default Vue.extend({ components: { - XDraggable, XSection, XText, XImage, XButton, XTextarea, XTextInput, XTextareaInput, XNumberInput, XSwitch, XIf, XPost, XCounter + XDraggable, XSection, XText, XImage, XButton, XTextarea, XTextInput, XTextareaInput, XNumberInput, XSwitch, XIf, XPost, XCounter, XRadioButton }, props: { diff --git a/src/client/app/common/views/pages/page-editor/page-editor.vue b/src/client/app/common/views/pages/page-editor/page-editor.vue index ade7d86991..0162915c38 100644 --- a/src/client/app/common/views/pages/page-editor/page-editor.vue +++ b/src/client/app/common/views/pages/page-editor/page-editor.vue @@ -342,6 +342,7 @@ export default Vue.extend({ label: this.$t('input-blocks'), items: [ { value: 'button', text: this.$t('blocks.button') }, + { value: 'radioButton', text: this.$t('blocks.radioButton') }, { value: 'textInput', text: this.$t('blocks.textInput') }, { value: 'textareaInput', text: this.$t('blocks.textareaInput') }, { value: 'numberInput', text: this.$t('blocks.numberInput') }, diff --git a/src/client/app/desktop/api/update-avatar.ts b/src/client/app/desktop/api/update-avatar.ts index a095491b69..6b88b51ef1 100644 --- a/src/client/app/desktop/api/update-avatar.ts +++ b/src/client/app/desktop/api/update-avatar.ts @@ -83,6 +83,21 @@ export default ($root: any) => { }); return i; + }).catch(err => { + switch (err.id) { + case 'f419f9f8-2f4d-46b1-9fb4-49d3a2fd7191': + $root.dialog({ + type: 'error', + title: locale['desktop']['unable-to-process'], + text: locale['desktop']['invalid-filetype'] + }); + break; + default: + $root.dialog({ + type: 'error', + text: locale['desktop']['unable-to-process'] + }); + } }); }; diff --git a/src/client/app/desktop/api/update-banner.ts b/src/client/app/desktop/api/update-banner.ts index c23a325364..09632b1941 100644 --- a/src/client/app/desktop/api/update-banner.ts +++ b/src/client/app/desktop/api/update-banner.ts @@ -83,6 +83,21 @@ export default ($root: any) => { }); return i; + }).catch(err => { + switch (err.id) { + case '75aedb19-2afd-4e6d-87fc-67941256fa60': + $root.dialog({ + type: 'error', + title: locale['desktop']['unable-to-process'], + text: locale['desktop']['invalid-filetype'] + }); + break; + default: + $root.dialog({ + type: 'error', + text: locale['desktop']['unable-to-process'] + }); + } }); }; diff --git a/src/client/app/desktop/views/components/drive.vue b/src/client/app/desktop/views/components/drive.vue index a78c0040c3..ff4ff18e6e 100644 --- a/src/client/app/desktop/views/components/drive.vue +++ b/src/client/app/desktop/views/components/drive.vue @@ -22,19 +22,19 @@ > <div class="selection" ref="selection"></div> <div class="contents" ref="contents"> - <div class="folders" ref="foldersContainer" v-if="folders.length > 0"> + <div class="folders" ref="foldersContainer" v-if="folders.length > 0 || moreFolders"> <x-folder v-for="folder in folders" :key="folder.id" class="folder" :folder="folder"/> <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> <div class="padding" v-for="n in 16"></div> <ui-button v-if="moreFolders">{{ $t('@.load-more') }}</ui-button> </div> - <div class="files" ref="filesContainer" v-if="files.length > 0"> + <div class="files" ref="filesContainer" v-if="files.length > 0 || moreFiles"> <x-file v-for="file in files" :key="file.id" class="file" :file="file"/> <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> <div class="padding" v-for="n in 16"></div> <ui-button v-if="moreFiles" @click="fetchMoreFiles">{{ $t('@.load-more') }}</ui-button> </div> - <div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching"> + <div class="empty" v-if="files.length == 0 && !moreFiles && folders.length == 0 && !moreFolders && !fetching"> <p v-if="draghover">{{ $t('empty-draghover') }}</p> <p v-if="!draghover && folder == null"><strong>{{ $t('empty-drive') }}</strong><br/>{{ $t('empty-drive-description') }}</p> <p v-if="!draghover && folder != null">{{ $t('empty-folder') }}</p> diff --git a/src/client/app/mobile/views/components/drive.vue b/src/client/app/mobile/views/components/drive.vue index 2613cfff99..f24c8492e5 100644 --- a/src/client/app/mobile/views/components/drive.vue +++ b/src/client/app/mobile/views/components/drive.vue @@ -25,17 +25,17 @@ <template v-if="folder.filesCount > 0">{{ folder.filesCount }} {{ $t('file-count') }}</template> </p> </div> - <div class="folders" v-if="folders.length > 0"> + <div class="folders" v-if="folders.length > 0 || moreFolders"> <x-folder class="folder" v-for="folder in folders" :key="folder.id" :folder="folder"/> <p v-if="moreFolders">{{ $t('@.load-more') }}</p> </div> - <div class="files" v-if="files.length > 0"> + <div class="files" v-if="files.length > 0 || moreFiles"> <x-file class="file" v-for="file in files" :key="file.id" :file="file"/> <button class="more" v-if="moreFiles" @click="fetchMoreFiles"> {{ fetchingMoreFiles ? this.$t('@.loading') : this.$t('@.load-more') }} </button> </div> - <div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching"> + <div class="empty" v-if="files.length == 0 && !moreFiles && folders.length == 0 && !moreFolders && !fetching"> <p v-if="folder == null">{{ $t('nothing-in-drive') }}</p> <p v-if="folder != null">{{ $t('folder-is-empty') }}</p> </div> diff --git a/src/db/postgre.ts b/src/db/postgre.ts index 638d5720b7..16cfbd2b2f 100644 --- a/src/db/postgre.ts +++ b/src/db/postgre.ts @@ -47,6 +47,7 @@ import { UserSecurityKey } from '../models/entities/user-security-key'; import { AttestationChallenge } from '../models/entities/attestation-challenge'; import { Page } from '../models/entities/page'; import { PageLike } from '../models/entities/page-like'; +import { ModerationLog } from '../models/entities/moderation-log'; const sqlLogger = dbLogger.createSubLogger('sql', 'white', false); @@ -124,6 +125,7 @@ export const entities = [ RegistrationTicket, MessagingMessage, Signin, + ModerationLog, ReversiGame, ReversiMatching, ...charts as any diff --git a/src/models/entities/moderation-log.ts b/src/models/entities/moderation-log.ts new file mode 100644 index 0000000000..33d3d683ae --- /dev/null +++ b/src/models/entities/moderation-log.ts @@ -0,0 +1,32 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { User } from './user'; +import { id } from '../id'; + +@Entity() +export class ModerationLog { + @PrimaryColumn(id()) + public id: string; + + @Column('timestamp with time zone', { + comment: 'The created date of the ModerationLog.' + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + @Column('varchar', { + length: 128, + }) + public type: string; + + @Column('jsonb') + public info: Record<string, any>; +} diff --git a/src/models/index.ts b/src/models/index.ts index 888fd53f36..388bdc8f6f 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -42,6 +42,7 @@ import { UserSecurityKey } from './entities/user-security-key'; import { HashtagRepository } from './repositories/hashtag'; import { PageRepository } from './repositories/page'; import { PageLikeRepository } from './repositories/page-like'; +import { ModerationLogRepository } from './repositories/moderation-logs'; export const Apps = getCustomRepository(AppRepository); export const Notes = getCustomRepository(NoteRepository); @@ -86,3 +87,4 @@ export const ReversiMatchings = getCustomRepository(ReversiMatchingRepository); export const Logs = getRepository(Log); export const Pages = getCustomRepository(PageRepository); export const PageLikes = getCustomRepository(PageLikeRepository); +export const ModerationLogs = getCustomRepository(ModerationLogRepository); diff --git a/src/models/repositories/abuse-user-report.ts b/src/models/repositories/abuse-user-report.ts index 61d0d6e229..bff64c770c 100644 --- a/src/models/repositories/abuse-user-report.ts +++ b/src/models/repositories/abuse-user-report.ts @@ -14,6 +14,7 @@ export class AbuseUserReportRepository extends Repository<AbuseUserReport> { return await awaitAll({ id: report.id, createdAt: report.createdAt, + comment: report.comment, reporterId: report.reporterId, userId: report.userId, reporter: Users.pack(report.reporter || report.reporterId, null, { diff --git a/src/models/repositories/moderation-logs.ts b/src/models/repositories/moderation-logs.ts new file mode 100644 index 0000000000..d6e04795bb --- /dev/null +++ b/src/models/repositories/moderation-logs.ts @@ -0,0 +1,31 @@ +import { EntityRepository, Repository } from 'typeorm'; +import { Users } from '..'; +import { ModerationLog } from '../entities/moderation-log'; +import { ensure } from '../../prelude/ensure'; +import { awaitAll } from '../../prelude/await-all'; + +@EntityRepository(ModerationLog) +export class ModerationLogRepository extends Repository<ModerationLog> { + public async pack( + src: ModerationLog['id'] | ModerationLog, + ) { + const log = typeof src === 'object' ? src : await this.findOne(src).then(ensure); + + return await awaitAll({ + id: log.id, + createdAt: log.createdAt, + type: log.type, + info: log.info, + userId: log.userId, + user: Users.pack(log.user || log.userId, null, { + detail: true + }), + }); + } + + public packMany( + reports: any[], + ) { + return Promise.all(reports.map(x => this.pack(x))); + } +} diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts index 4e85fd7b93..a04b87f77c 100644 --- a/src/models/repositories/user.ts +++ b/src/models/repositories/user.ts @@ -148,6 +148,7 @@ export class UserRepository extends Repository<User> { description: profile!.description, location: profile!.location, birthday: profile!.birthday, + fields: profile!.fields, followersCount: user.followersCount, followingCount: user.followingCount, notesCount: user.notesCount, diff --git a/src/remote/activitypub/renderer/person.ts b/src/remote/activitypub/renderer/person.ts index efe52cdefb..d4c018fb78 100644 --- a/src/remote/activitypub/renderer/person.ts +++ b/src/remote/activitypub/renderer/person.ts @@ -21,13 +21,24 @@ export async function renderPerson(user: ILocalUser) { ]); const attachment: { - type: string, + type: 'PropertyValue', name: string, value: string, - verified_at?: string, identifier?: IIdentifier }[] = []; + if (profile.fields) { + for (const field of profile.fields) { + attachment.push({ + type: 'PropertyValue', + name: field.name, + value: (field.value != null && field.value.match(/^https?:/)) + ? `<a href="${new URL(field.value).href}" rel="me nofollow noopener" target="_blank">${new URL(field.value).href}</a>` + : field.value + }); + } + } + if (profile.twitter) { attachment.push({ type: 'PropertyValue', diff --git a/src/server/api/common/signin.ts b/src/server/api/common/signin.ts index 0f4ee4ca11..37229ad1bc 100644 --- a/src/server/api/common/signin.ts +++ b/src/server/api/common/signin.ts @@ -2,6 +2,9 @@ import * as Koa from 'koa'; import config from '../../../config'; import { ILocalUser } from '../../../models/entities/user'; +import { Signins } from '../../../models'; +import { genId } from '../../../misc/gen-id'; +import { publishMainStream } from '../../../services/stream'; export default function(ctx: Koa.BaseContext, user: ILocalUser, redirect = false) { if (redirect) { @@ -24,4 +27,19 @@ export default function(ctx: Koa.BaseContext, user: ILocalUser, redirect = false ctx.body = { i: user.token }; ctx.status = 200; } + + (async () => { + // Append signin history + const record = await Signins.save({ + id: genId(), + createdAt: new Date(), + userId: user.id, + ip: ctx.ip, + headers: ctx.headers, + success: true + }); + + // Publish signin event + publishMainStream(user.id, 'signin', await Signins.pack(record)); + })(); } diff --git a/src/server/api/endpoints/admin/emoji/add.ts b/src/server/api/endpoints/admin/emoji/add.ts index 5ba00afde8..8c21b1c73e 100644 --- a/src/server/api/endpoints/admin/emoji/add.ts +++ b/src/server/api/endpoints/admin/emoji/add.ts @@ -4,6 +4,7 @@ import { detectUrlMine } from '../../../../../misc/detect-url-mine'; import { Emojis } from '../../../../../models'; import { genId } from '../../../../../misc/gen-id'; import { getConnection } from 'typeorm'; +import { insertModerationLog } from '../../../../../services/insert-moderation-log'; export const meta = { desc: { @@ -31,7 +32,7 @@ export const meta = { } }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { const type = await detectUrlMine(ps.url); const emoji = await Emojis.save({ @@ -46,6 +47,10 @@ export default define(meta, async (ps) => { await getConnection().queryResultCache!.remove(['meta_emojis']); + insertModerationLog(me, 'addEmoji', { + emojiId: emoji.id + }); + return { id: emoji.id }; diff --git a/src/server/api/endpoints/admin/emoji/remove.ts b/src/server/api/endpoints/admin/emoji/remove.ts index 3ebf933bc6..92c5f5f8c6 100644 --- a/src/server/api/endpoints/admin/emoji/remove.ts +++ b/src/server/api/endpoints/admin/emoji/remove.ts @@ -3,6 +3,7 @@ import define from '../../../define'; import { ID } from '../../../../../misc/cafy-id'; import { Emojis } from '../../../../../models'; import { getConnection } from 'typeorm'; +import { insertModerationLog } from '../../../../../services/insert-moderation-log'; export const meta = { desc: { @@ -21,7 +22,7 @@ export const meta = { } }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { const emoji = await Emojis.findOne(ps.id); if (emoji == null) throw new Error('emoji not found'); @@ -29,4 +30,8 @@ export default define(meta, async (ps) => { await Emojis.delete(emoji.id); await getConnection().queryResultCache!.remove(['meta_emojis']); + + insertModerationLog(me, 'removeEmoji', { + emoji: emoji + }); }); diff --git a/src/server/api/endpoints/admin/queue/clear.ts b/src/server/api/endpoints/admin/queue/clear.ts index f0fd00f1ad..03c1ae8463 100644 --- a/src/server/api/endpoints/admin/queue/clear.ts +++ b/src/server/api/endpoints/admin/queue/clear.ts @@ -1,5 +1,6 @@ import define from '../../../define'; import { destroy } from '../../../../../queue'; +import { insertModerationLog } from '../../../../../services/insert-moderation-log'; export const meta = { tags: ['admin'], @@ -10,8 +11,8 @@ export const meta = { params: {} }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { destroy(); - return; + insertModerationLog(me, 'clearQueue'); }); diff --git a/src/server/api/endpoints/admin/show-moderation-logs.ts b/src/server/api/endpoints/admin/show-moderation-logs.ts new file mode 100644 index 0000000000..bc67b3e55b --- /dev/null +++ b/src/server/api/endpoints/admin/show-moderation-logs.ts @@ -0,0 +1,35 @@ +import $ from 'cafy'; +import { ID } from '../../../../misc/cafy-id'; +import define from '../../define'; +import { ModerationLogs } from '../../../../models'; +import { makePaginationQuery } from '../../common/make-pagination-query'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, + + params: { + limit: { + validator: $.optional.num.range(1, 100), + default: 10 + }, + + sinceId: { + validator: $.optional.type(ID), + }, + + untilId: { + validator: $.optional.type(ID), + }, + } +}; + +export default define(meta, async (ps) => { + const query = makePaginationQuery(ModerationLogs.createQueryBuilder('report'), ps.sinceId, ps.untilId); + + const reports = await query.take(ps.limit!).getMany(); + + return await ModerationLogs.packMany(reports); +}); diff --git a/src/server/api/endpoints/admin/show-users.ts b/src/server/api/endpoints/admin/show-users.ts index 8733d87a38..89e0cf1e2a 100644 --- a/src/server/api/endpoints/admin/show-users.ts +++ b/src/server/api/endpoints/admin/show-users.ts @@ -49,6 +49,16 @@ export const meta = { 'remote', ]), default: 'local' + }, + + username: { + validator: $.optional.str, + default: null + }, + + hostname: { + validator: $.optional.str, + default: null } } }; @@ -70,6 +80,14 @@ export default define(meta, async (ps, me) => { case 'remote': query.andWhere('user.host IS NOT NULL'); break; } + if (ps.username) { + query.andWhere('user.usernameLower like :username', { username: ps.username.toLowerCase() + '%' }); + } + + if (ps.hostname) { + query.andWhere('user.host like :hostname', { hostname: '%' + ps.hostname.toLowerCase() + '%' }); + } + switch (ps.sort) { case '+follower': query.orderBy('user.followersCount', 'DESC'); break; case '-follower': query.orderBy('user.followersCount', 'ASC'); break; diff --git a/src/server/api/endpoints/admin/silence-user.ts b/src/server/api/endpoints/admin/silence-user.ts index 83aa88012a..8cc84aa1cc 100644 --- a/src/server/api/endpoints/admin/silence-user.ts +++ b/src/server/api/endpoints/admin/silence-user.ts @@ -2,6 +2,7 @@ import $ from 'cafy'; import { ID } from '../../../../misc/cafy-id'; import define from '../../define'; import { Users } from '../../../../models'; +import { insertModerationLog } from '../../../../services/insert-moderation-log'; export const meta = { desc: { @@ -25,7 +26,7 @@ export const meta = { } }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { const user = await Users.findOne(ps.userId as string); if (user == null) { @@ -39,4 +40,8 @@ export default define(meta, async (ps) => { await Users.update(user.id, { isSilenced: true }); + + insertModerationLog(me, 'silence', { + targetId: user.id, + }); }); diff --git a/src/server/api/endpoints/admin/suspend-user.ts b/src/server/api/endpoints/admin/suspend-user.ts index fa4d378708..6ba0d91505 100644 --- a/src/server/api/endpoints/admin/suspend-user.ts +++ b/src/server/api/endpoints/admin/suspend-user.ts @@ -4,6 +4,8 @@ import define from '../../define'; import deleteFollowing from '../../../../services/following/delete'; import { Users, Followings } from '../../../../models'; import { User } from '../../../../models/entities/user'; +import { insertModerationLog } from '../../../../services/insert-moderation-log'; +import { doPostSuspend } from '../../../../services/suspend-user'; export const meta = { desc: { @@ -27,7 +29,7 @@ export const meta = { } }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { const user = await Users.findOne(ps.userId as string); if (user == null) { @@ -46,7 +48,14 @@ export default define(meta, async (ps) => { isSuspended: true }); - unFollowAll(user); + insertModerationLog(me, 'suspend', { + targetId: user.id, + }); + + (async () => { + await doPostSuspend(user).catch(e => {}); + await unFollowAll(user).catch(e => {}); + })(); }); async function unFollowAll(follower: User) { diff --git a/src/server/api/endpoints/admin/unsilence-user.ts b/src/server/api/endpoints/admin/unsilence-user.ts index f9b173366b..607c9b699a 100644 --- a/src/server/api/endpoints/admin/unsilence-user.ts +++ b/src/server/api/endpoints/admin/unsilence-user.ts @@ -2,6 +2,7 @@ import $ from 'cafy'; import { ID } from '../../../../misc/cafy-id'; import define from '../../define'; import { Users } from '../../../../models'; +import { insertModerationLog } from '../../../../services/insert-moderation-log'; export const meta = { desc: { @@ -25,7 +26,7 @@ export const meta = { } }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { const user = await Users.findOne(ps.userId as string); if (user == null) { @@ -35,4 +36,8 @@ export default define(meta, async (ps) => { await Users.update(user.id, { isSilenced: false }); + + insertModerationLog(me, 'unsilence', { + targetId: user.id, + }); }); diff --git a/src/server/api/endpoints/admin/unsuspend-user.ts b/src/server/api/endpoints/admin/unsuspend-user.ts index 08dae034d3..237585e276 100644 --- a/src/server/api/endpoints/admin/unsuspend-user.ts +++ b/src/server/api/endpoints/admin/unsuspend-user.ts @@ -2,6 +2,8 @@ import $ from 'cafy'; import { ID } from '../../../../misc/cafy-id'; import define from '../../define'; import { Users } from '../../../../models'; +import { insertModerationLog } from '../../../../services/insert-moderation-log'; +import { doPostUnsuspend } from '../../../../services/unsuspend-user'; export const meta = { desc: { @@ -25,7 +27,7 @@ export const meta = { } }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { const user = await Users.findOne(ps.userId as string); if (user == null) { @@ -35,4 +37,10 @@ export default define(meta, async (ps) => { await Users.update(user.id, { isSuspended: false }); + + insertModerationLog(me, 'unsuspend', { + targetId: user.id, + }); + + doPostUnsuspend(user); }); diff --git a/src/server/api/endpoints/admin/update-meta.ts b/src/server/api/endpoints/admin/update-meta.ts index 8e98d203ff..834faa42b9 100644 --- a/src/server/api/endpoints/admin/update-meta.ts +++ b/src/server/api/endpoints/admin/update-meta.ts @@ -2,6 +2,7 @@ import $ from 'cafy'; import define from '../../define'; import { getConnection } from 'typeorm'; import { Meta } from '../../../../models/entities/meta'; +import { insertModerationLog } from '../../../../services/insert-moderation-log'; export const meta = { desc: { @@ -401,7 +402,7 @@ export const meta = { } }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { const set = {} as Partial<Meta>; if (ps.announcements) { @@ -653,4 +654,6 @@ export default define(meta, async (ps) => { await transactionalEntityManager.save(Meta, set); } }); + + insertModerationLog(me, 'updateMeta'); }); diff --git a/src/server/api/endpoints/admin/vacuum.ts b/src/server/api/endpoints/admin/vacuum.ts index 6990706282..4921e228e5 100644 --- a/src/server/api/endpoints/admin/vacuum.ts +++ b/src/server/api/endpoints/admin/vacuum.ts @@ -1,6 +1,7 @@ import $ from 'cafy'; import define from '../../define'; import { getConnection } from 'typeorm'; +import { insertModerationLog } from '../../../../services/insert-moderation-log'; export const meta = { tags: ['admin'], @@ -18,7 +19,7 @@ export const meta = { } }; -export default define(meta, async (ps) => { +export default define(meta, async (ps, me) => { const params: string[] = []; if (ps.full) { @@ -30,4 +31,6 @@ export default define(meta, async (ps) => { } getConnection().query('VACUUM ' + params.join(' ')); + + insertModerationLog(me, 'vacuum', ps); }); diff --git a/src/server/api/endpoints/federation/instances.ts b/src/server/api/endpoints/federation/instances.ts index 3c4e0037d6..bc0eb9a1d7 100644 --- a/src/server/api/endpoints/federation/instances.ts +++ b/src/server/api/endpoints/federation/instances.ts @@ -43,12 +43,12 @@ export default define(meta, async (ps, me) => { switch (ps.sort) { case '+notes': query.orderBy('instance.notesCount', 'DESC'); break; case '-notes': query.orderBy('instance.notesCount', 'ASC'); break; - case '+usersCount': query.orderBy('instance.usersCount', 'DESC'); break; - case '-usersCount': query.orderBy('instance.usersCount', 'ASC'); break; - case '+followingCount': query.orderBy('instance.followingCount', 'DESC'); break; - case '-followingCount': query.orderBy('instance.followingCount', 'ASC'); break; - case '+followersCount': query.orderBy('instance.followersCount', 'DESC'); break; - case '-followersCount': query.orderBy('instance.followersCount', 'ASC'); break; + case '+users': query.orderBy('instance.usersCount', 'DESC'); break; + case '-users': query.orderBy('instance.usersCount', 'ASC'); break; + case '+following': query.orderBy('instance.followingCount', 'DESC'); break; + case '-following': query.orderBy('instance.followingCount', 'ASC'); break; + case '+followers': query.orderBy('instance.followersCount', 'DESC'); break; + case '-followers': query.orderBy('instance.followersCount', 'ASC'); break; case '+caughtAt': query.orderBy('instance.caughtAt', 'DESC'); break; case '-caughtAt': query.orderBy('instance.caughtAt', 'ASC'); break; case '+lastCommunicatedAt': query.orderBy('instance.lastCommunicatedAt', 'DESC'); break; diff --git a/src/server/api/endpoints/hashtags/users.ts b/src/server/api/endpoints/hashtags/users.ts index 59210f4604..28a78ff8e6 100644 --- a/src/server/api/endpoints/hashtags/users.ts +++ b/src/server/api/endpoints/hashtags/users.ts @@ -59,7 +59,7 @@ export const meta = { export default define(meta, async (ps, me) => { const query = Users.createQueryBuilder('user') - .where(':tag = ANY(user.tags)', { tag: ps.tag }); + .where(':tag = ANY(user.tags)', { tag: ps.tag.toLowerCase() }); const recent = new Date(Date.now() - (1000 * 60 * 60 * 24 * 5)); diff --git a/src/server/api/endpoints/i/delete-account.ts b/src/server/api/endpoints/i/delete-account.ts index 8ec85c9f41..b4950cb1fb 100644 --- a/src/server/api/endpoints/i/delete-account.ts +++ b/src/server/api/endpoints/i/delete-account.ts @@ -3,6 +3,7 @@ import * as bcrypt from 'bcryptjs'; import define from '../../define'; import { Users, UserProfiles } from '../../../../models'; import { ensure } from '../../../../prelude/ensure'; +import { doPostSuspend } from '../../../../services/suspend-user'; export const meta = { requireCredential: true, @@ -26,5 +27,8 @@ export default define(meta, async (ps, user) => { throw new Error('incorrect password'); } + // 物理削除する前にDelete activityを送信する + await doPostSuspend(user).catch(e => {}); + await Users.delete(user.id); }); diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts index a454cdb940..149081e50b 100644 --- a/src/server/api/endpoints/i/update.ts +++ b/src/server/api/endpoints/i/update.ts @@ -77,6 +77,13 @@ export const meta = { } }, + fields: { + validator: $.optional.arr($.object()).range(1, 4), + desc: { + 'ja-JP': 'プロフィール補足情報' + } + }, + isLocked: { validator: $.optional.bool, desc: { @@ -226,6 +233,14 @@ export default define(meta, async (ps, user, app) => { profileUpdates.pinnedPageId = null; } + if (ps.fields) { + profileUpdates.fields = ps.fields + .filter(x => typeof x.name === 'string' && x.name !== '' && typeof x.value === 'string' && x.value !== '') + .map(x => { + return { name: x.name, value: x.value }; + }); + } + //#region emojis/tags let emojis = [] as string[]; diff --git a/src/server/api/private/signin.ts b/src/server/api/private/signin.ts index eb267aa604..de0e35f500 100644 --- a/src/server/api/private/signin.ts +++ b/src/server/api/private/signin.ts @@ -1,7 +1,6 @@ import * as Koa from 'koa'; import * as bcrypt from 'bcryptjs'; import * as speakeasy from 'speakeasy'; -import { publishMainStream } from '../../../services/stream'; import signin from '../common/signin'; import config from '../../../config'; import { Users, Signins, UserProfiles, UserSecurityKeys, AttestationChallenges } from '../../../models'; @@ -53,34 +52,30 @@ export default async (ctx: Koa.BaseContext) => { // Compare password const same = await bcrypt.compare(password, profile.password!); - async function fail(status?: number, failure?: {error: string}) { + async function fail(status?: number, failure?: { error: string }) { // Append signin history - const record = await Signins.save({ + await Signins.save({ id: genId(), createdAt: new Date(), userId: user.id, ip: ctx.ip, headers: ctx.headers, - success: !!(status || failure) + success: false }); - // Publish signin event - publishMainStream(user.id, 'signin', await Signins.pack(record)); - - if (status && failure) { - ctx.throw(status, failure); - } + ctx.throw(status || 500, failure || { error: 'someting happened' }); } if (!profile.twoFactorEnabled) { if (same) { signin(ctx, user); + return; } else { await fail(403, { error: 'incorrect password' }); + return; } - return; } if (token) { @@ -169,6 +164,7 @@ export default async (ctx: Koa.BaseContext) => { if (isValid) { signin(ctx, user); + return; } else { await fail(403, { error: 'invalid challenge data' @@ -191,6 +187,7 @@ export default async (ctx: Koa.BaseContext) => { await fail(403, { error: 'no keys found' }); + return; } // 32 byte challenge @@ -219,6 +216,5 @@ export default async (ctx: Koa.BaseContext) => { ctx.status = 200; return; } - - await fail(); + // never get here }; diff --git a/src/server/api/private/signup.ts b/src/server/api/private/signup.ts index ca197a6611..026fe7485b 100644 --- a/src/server/api/private/signup.ts +++ b/src/server/api/private/signup.ts @@ -5,7 +5,7 @@ import generateUserToken from '../common/generate-native-user-token'; import config from '../../../config'; import { fetchMeta } from '../../../misc/fetch-meta'; import * as recaptcha from 'recaptcha-promise'; -import { Users, RegistrationTickets } from '../../../models'; +import { Users, Signins, RegistrationTickets } from '../../../models'; import { genId } from '../../../misc/gen-id'; import { usersChart } from '../../../services/chart'; import { User } from '../../../models/entities/user'; @@ -104,6 +104,13 @@ export default async (ctx: Koa.BaseContext) => { // Start transaction await getConnection().transaction(async transactionalEntityManager => { + const exist = await transactionalEntityManager.findOne(User, { + usernameLower: username.toLowerCase(), + host: null + }); + + if (exist) throw 'already registered'; + account = await transactionalEntityManager.save(new User({ id: genId(), createdAt: new Date(), @@ -130,6 +137,16 @@ export default async (ctx: Koa.BaseContext) => { usersChart.update(account, true); + // Append signin history + await Signins.save({ + id: genId(), + createdAt: new Date(), + userId: account.id, + ip: ctx.ip, + headers: ctx.headers, + success: true + }); + const res = await Users.pack(account, account, { detail: true, includeSecrets: true diff --git a/src/server/proxy/proxy-media.ts b/src/server/proxy/proxy-media.ts index eadfab54a3..415eef6c34 100644 --- a/src/server/proxy/proxy-media.ts +++ b/src/server/proxy/proxy-media.ts @@ -33,7 +33,7 @@ export async function proxyMedia(ctx: Koa.BaseContext) { }; } - ctx.set('Content-Type', type); + ctx.set('Content-Type', image.type); ctx.set('Cache-Control', 'max-age=31536000, immutable'); ctx.body = image.data; } catch (e) { diff --git a/src/server/web/index.ts b/src/server/web/index.ts index 8cf6a75208..6c41bbde46 100644 --- a/src/server/web/index.ts +++ b/src/server/web/index.ts @@ -156,11 +156,17 @@ router.get('/@:user', async (ctx, next) => { if (user != null) { const profile = await UserProfiles.findOne(user.id).then(ensure); const meta = await fetchMeta(); + const me = profile.fields + ? profile.fields + .filter(filed => filed.value != null && filed.value.match(/^https?:/)) + .map(field => field.value) + : []; + await ctx.render('user', { - user, profile, + user, profile, me, instanceName: meta.name || 'Misskey' }); - ctx.set('Cache-Control', 'public, max-age=180'); + ctx.set('Cache-Control', 'public, max-age=30'); } else { // リモートユーザーなので await next(); diff --git a/src/server/web/views/base.pug b/src/server/web/views/base.pug index 733a306d56..16bea853e7 100644 --- a/src/server/web/views/base.pug +++ b/src/server/web/views/base.pug @@ -44,3 +44,4 @@ html <svg viewBox="0 0 50 50"> <path fill=#fb4e4e d="M25.251,6.461c-10.318,0-18.683,8.365-18.683,18.683h4.068c0-8.071,6.543-14.615,14.615-14.615V6.461z" /> </svg> + block content diff --git a/src/server/web/views/user.pug b/src/server/web/views/user.pug index 9b257afb7b..6ff86b09be 100644 --- a/src/server/web/views/user.pug +++ b/src/server/web/views/user.pug @@ -36,3 +36,8 @@ block meta link(rel='alternate' href=user.uri type='application/activity+json') if profile.url link(rel='alternate' href=profile.url type='text/html') + +block content + div#me + each m in me + a(rel='me' href=`${m}`) #{m} diff --git a/src/services/insert-moderation-log.ts b/src/services/insert-moderation-log.ts new file mode 100644 index 0000000000..33dab97259 --- /dev/null +++ b/src/services/insert-moderation-log.ts @@ -0,0 +1,13 @@ +import { ILocalUser } from '../models/entities/user'; +import { ModerationLogs } from '../models'; +import { genId } from '../misc/gen-id'; + +export async function insertModerationLog(moderator: ILocalUser, type: string, info?: Record<string, any>) { + await ModerationLogs.save({ + id: genId(), + createdAt: new Date(), + userId: moderator.id, + type: type, + info: info || {} + }); +} diff --git a/src/services/suspend-user.ts b/src/services/suspend-user.ts new file mode 100644 index 0000000000..a85188acbe --- /dev/null +++ b/src/services/suspend-user.ts @@ -0,0 +1,34 @@ +import renderDelete from '../remote/activitypub/renderer/delete'; +import { renderActivity } from '../remote/activitypub/renderer'; +import { deliver } from '../queue'; +import config from '../config'; +import { User } from '../models/entities/user'; +import { Users, Followings } from '../models'; +import { Not, IsNull } from 'typeorm'; + +export async function doPostSuspend(user: User) { + if (Users.isLocalUser(user)) { + // 知り得る全SharedInboxにDelete配信 + const content = renderActivity(renderDelete(`${config.url}/users/${user.id}`, user)); + + const queue: string[] = []; + + const followings = await Followings.find({ + where: [ + { followerSharedInbox: Not(IsNull()) }, + { followeeSharedInbox: Not(IsNull()) } + ], + select: ['followerSharedInbox', 'followeeSharedInbox'] + }); + + const inboxes = followings.map(x => x.followerSharedInbox || x.followeeSharedInbox); + + for (const inbox of inboxes) { + if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + } + + for (const inbox of queue) { + deliver(user as any, content, inbox); + } + } +} diff --git a/src/services/unsuspend-user.ts b/src/services/unsuspend-user.ts new file mode 100644 index 0000000000..6cab375821 --- /dev/null +++ b/src/services/unsuspend-user.ts @@ -0,0 +1,35 @@ +import renderDelete from '../remote/activitypub/renderer/delete'; +import renderUndo from '../remote/activitypub/renderer/undo'; +import { renderActivity } from '../remote/activitypub/renderer'; +import { deliver } from '../queue'; +import config from '../config'; +import { User } from '../models/entities/user'; +import { Users, Followings } from '../models'; +import { Not, IsNull } from 'typeorm'; + +export async function doPostUnsuspend(user: User) { + if (Users.isLocalUser(user)) { + // 知り得る全SharedInboxにUndo Delete配信 + const content = renderActivity(renderUndo(renderDelete(`${config.url}/users/${user.id}`, user), user)); + + const queue: string[] = []; + + const followings = await Followings.find({ + where: [ + { followerSharedInbox: Not(IsNull()) }, + { followeeSharedInbox: Not(IsNull()) } + ], + select: ['followerSharedInbox', 'followeeSharedInbox'] + }); + + const inboxes = followings.map(x => x.followerSharedInbox || x.followeeSharedInbox); + + for (const inbox of inboxes) { + if (inbox != null && !queue.includes(inbox)) queue.push(inbox); + } + + for (const inbox of queue) { + deliver(user as any, content, inbox); + } + } +} |