Merge branch 'develop'
This commit is contained in:
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -4,7 +4,8 @@ const faces = [
|
||||
'🐡( \'-\' 🐡 )フグパンチ!!!!',
|
||||
'✌️(´・_・`)✌️',
|
||||
'(。>﹏<。)',
|
||||
'(Δ・x・Δ)'
|
||||
'(Δ・x・Δ)',
|
||||
'(コ`・ヘ・´ケ)'
|
||||
];
|
||||
|
||||
export default () => faces[Math.floor(Math.random() * faces.length)];
|
||||
|
@ -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)}`;
|
||||
},
|
||||
|
@ -30,6 +30,7 @@ export default Vue.extend({
|
||||
border-radius 4px
|
||||
|
||||
>>> .quote
|
||||
display block
|
||||
margin 8px
|
||||
padding 6px 0 6px 12px
|
||||
color var(--mfmQuote)
|
||||
|
@ -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: {
|
||||
|
@ -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>
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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')
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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>
|
@ -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: {
|
||||
|
@ -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') },
|
||||
|
@ -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']
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -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']
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
32
src/models/entities/moderation-log.ts
Normal file
32
src/models/entities/moderation-log.ts
Normal file
@ -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>;
|
||||
}
|
@ -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);
|
||||
|
@ -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, {
|
||||
|
31
src/models/repositories/moderation-logs.ts
Normal file
31
src/models/repositories/moderation-logs.ts
Normal file
@ -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)));
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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',
|
||||
|
@ -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));
|
||||
})();
|
||||
}
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -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
|
||||
});
|
||||
});
|
||||
|
@ -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');
|
||||
});
|
||||
|
35
src/server/api/endpoints/admin/show-moderation-logs.ts
Normal file
35
src/server/api/endpoints/admin/show-moderation-logs.ts
Normal file
@ -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);
|
||||
});
|
@ -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;
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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');
|
||||
});
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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;
|
||||
|
@ -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));
|
||||
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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[];
|
||||
|
@ -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
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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) {
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
|
@ -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}
|
||||
|
13
src/services/insert-moderation-log.ts
Normal file
13
src/services/insert-moderation-log.ts
Normal file
@ -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 || {}
|
||||
});
|
||||
}
|
34
src/services/suspend-user.ts
Normal file
34
src/services/suspend-user.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
35
src/services/unsuspend-user.ts
Normal file
35
src/services/unsuspend-user.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user