Commit fdb2b6d2 authored by Angelina Filippova's avatar Angelina Filippova

Merge branch 'develop' into 'feature/cache-invalidation'

# Conflicts:
#   CHANGELOG.md
#   src/views/settings/components/Inputs.vue
#   src/views/settings/components/tabs.js
parents ea46a819 7a6b9f2e
Pipeline #29115 passed with stages
in 5 minutes and 52 seconds
......@@ -19,6 +19,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add ability to manually evict and ban URLs from the Pleroma MediaProxy cache
- Add Invalidation settings on MediaProxy tab
- Ability to configure S3 settings on Upload tab
- Show number of open reports in Sidebar Menu
- Add confirmation message when deleting a user
### Changed
......@@ -32,6 +34,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Ability to add custom values in Pleroma.Upload.Filter.Mogrify setting
- Change types of the following settings: ':groups', ':replace', ':federated_timeline_removal', ':reject', ':match_actor'. Update functions that parses and wraps settings data according to this change.
- Move rendering Crontab setting from a separate component to EditableKeyword component
- Show only those MRF settings that have been enabled in MRF Policies setting
- Move Auto Linker settings to Link Formatter Tab as its configuration was moved to :pleroma, Pleroma.Formatter
### Fixed
......@@ -43,6 +47,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Added
- Link settings that enable registrations and invites
- Ability to upload logo, background, default user avatar, instance thumbnail, and NSFW hiding images
### Changed
......
import { getToken } from '@/utils/auth'
import { baseName } from './utils'
const UPLOAD_URL = '/api/v1/media'
export function uploadMedia({ formData, authHost }) {
const url = baseName(authHost) + UPLOAD_URL
return fetch(url, {
body: formData,
method: 'POST',
headers: authHeaders()
})
.then((data) => data.json())
}
const authHeaders = () => {
return { 'Authorization': `Bearer ${getToken()}` }
}
......@@ -228,6 +228,7 @@ export default {
revokeRightConfirmation: 'Are you sure you want to revoke {right} rights from all selected users?',
activateMultipleUsersConfirmation: 'Are you sure you want to activate accounts of all selected users?',
deactivateMultipleUsersConfirmation: 'Are you sure you want to deactivate accounts of all selected users?',
deleteUsersConfirmation: 'Are you sure you want to delete this account? This action cannot be undone.',
deleteMultipleUsersConfirmation: 'Are you sure you want to delete accounts of all selected users?',
addTagForMultipleUsersConfirmation: 'Are you sure you want to apply tag to all selected users?',
removeTagFromMultipleUsersConfirmation: 'Are you sure you want to remove tag from all selected users?',
......@@ -373,10 +374,10 @@ export default {
instance: 'Instance',
upload: 'Upload',
mailer: 'Mailer',
linkFormatter: 'Link Formatter',
logger: 'Logger',
activityPub: 'ActivityPub',
auth: 'Authentication',
autoLinker: 'Auto Linker',
captcha: 'Captcha',
frontend: 'Frontend',
http: 'HTTP',
......@@ -407,7 +408,10 @@ export default {
instanceReboot: 'Reboot Instance',
restartApp: 'You must restart the instance to apply settings',
restartSuccess: 'Instance rebooted successfully!',
removeSettingConfirmation: 'Are you sure you want to remove this setting\'s value from the database?'
removeSettingConfirmation: 'Are you sure you want to remove this setting\'s value from the database?',
changeImage: 'Change image',
uploadImage: 'Upload image',
remove: 'Remove'
},
invites: {
inviteTokens: 'Invite tokens',
......
......@@ -9,28 +9,6 @@ export const getBooleanValue = value => {
return value
}
export const checkPartialUpdate = (settings, updatedSettings, description) => {
return Object.keys(updatedSettings).reduce((acc, group) => {
acc[group] = Object.keys(updatedSettings[group]).reduce((acc, key) => {
if (!partialUpdate(group, key)) {
const updated = Object.keys(settings[group][key]).reduce((acc, settingName) => {
const setting = description
.find(element => element.group === group && element.key === key).children
.find(child => child.key === settingName)
const type = setting ? setting.type : ''
acc[settingName] = [type, settings[group][key][settingName]]
return acc
}, {})
acc[key] = updated
return acc
}
acc[key] = updatedSettings[group][key]
return acc
}, {})
return acc
}, {})
}
const getCurrentValue = (type, value, path) => {
if (type === 'state') {
return _.get(value, path)
......@@ -50,7 +28,7 @@ const getCurrentValue = (type, value, path) => {
}
const getValueWithoutKey = (key, [type, value]) => {
if (type === 'atom' && value.length > 1) {
if (prependWithСolon(type, value)) {
return `:${value}`
} else if (key === ':backends') {
const index = value.findIndex(el => el === ':ex_syslogger')
......@@ -158,8 +136,9 @@ const parseProxyUrl = value => {
return { socks5: false, host: null, port: null }
}
const partialUpdate = (group, key) => {
return !(group === ':auto_linker' && key === ':opts')
const prependWithСolon = (type, value) => {
return (type === 'atom' && value.length > 0) ||
(Array.isArray(type) && type.includes('boolean') && type.includes('atom') && typeof value === 'string')
}
export const processNested = (valueForState, valueForUpdatedSettings, group, parentKey, parents, settings, updatedSettings) => {
......@@ -246,7 +225,7 @@ const wrapValues = (settings, currentState) => {
))
) {
return { 'tuple': [setting, wrapValues(value, currentState)] }
} else if (type === 'atom' && value.length > 0) {
} else if (prependWithСolon(type, value)) {
return { 'tuple': [setting, `:${value}`] }
} else if (type.includes('tuple') && (type.includes('string') || type.includes('atom'))) {
return typeof value === 'string'
......
......@@ -2,12 +2,13 @@ import { changeState, fetchReports, createNote, deleteNote } from '@/api/reports
const reports = {
state: {
fetchedReports: [],
totalReportsCount: 0,
currentPage: 1,
fetchedReports: [],
loading: true,
openReportsCount: 0,
pageSize: 50,
stateFilter: '',
loading: true
totalReportsCount: 0
},
mutations: {
SET_LAST_REPORT_ID: (state, id) => {
......@@ -16,6 +17,9 @@ const reports = {
SET_LOADING: (state, status) => {
state.loading = status
},
SET_OPEN_REPORTS_COUNT: (state, total) => {
state.openReportsCount = total
},
SET_PAGE: (state, page) => {
state.currentPage = page
},
......@@ -30,7 +34,7 @@ const reports = {
}
},
actions: {
async ChangeReportState({ commit, getters, state }, reportsData) {
async ChangeReportState({ commit, dispatch, getters, state }, reportsData) {
changeState(reportsData, getters.authHost, getters.token)
const updatedReports = state.fetchedReports.map(report => {
......@@ -39,6 +43,7 @@ const reports = {
})
commit('SET_REPORTS', updatedReports)
dispatch('FetchOpenReportsCount')
},
ClearFetchedReports({ commit }) {
commit('SET_REPORTS', [])
......@@ -52,7 +57,14 @@ const reports = {
commit('SET_PAGE', page)
commit('SET_LOADING', false)
},
SetFilter({ commit }, filter) {
async FetchOpenReportsCount({ commit, getters, state }) {
commit('SET_LOADING', true)
const { data } = await fetchReports('open', state.currentPage, state.pageSize, getters.authHost, getters.token)
commit('SET_OPEN_REPORTS_COUNT', data.total)
commit('SET_LOADING', false)
},
SetReportsFilter({ commit }, filter) {
commit('SET_REPORTS_FILTER', filter)
},
CreateReportNote({ commit, getters, state, rootState }, { content, reportID }) {
......
import { fetchDescription, fetchSettings, removeSettings, updateSettings } from '@/api/settings'
import { checkPartialUpdate, formSearchObject, parseNonTuples, parseTuples, valueHasTuples, wrapUpdatedSettings } from './normalizers'
import { formSearchObject, parseNonTuples, parseTuples, valueHasTuples, wrapUpdatedSettings } from './normalizers'
import _ from 'lodash'
const settings = {
......@@ -101,9 +101,8 @@ const settings = {
commit('SET_ACTIVE_TAB', tab)
},
async SubmitChanges({ getters, commit, state }) {
const updatedData = checkPartialUpdate(state.settings, state.updatedSettings, state.description)
const configs = Object.keys(updatedData).reduce((acc, group) => {
return [...acc, ...wrapUpdatedSettings(group, updatedData[group], state.settings)]
const configs = Object.keys(state.updatedSettings).reduce((acc, group) => {
return [...acc, ...wrapUpdatedSettings(group, state.updatedSettings[group], state.settings)]
}, [])
await updateSettings(configs, getters.authHost, getters.token)
......
......@@ -193,11 +193,14 @@ const users = {
} catch (_e) {
return
}
const deletedUsersIds = users.map(deletedUser => deletedUser.id)
const updatedUsers = state.fetchedUsers.filter(user => !deletedUsersIds.includes(user.id))
commit('SET_USERS', updatedUsers)
const updatedUsers = users.map(user => {
return { ...user, deactivated: true }
})
commit('SWAP_USERS', updatedUsers)
dispatch('FetchUserProfile', { userId: _userId, godmode: false })
if (_userId) {
dispatch('FetchUserProfile', { userId: _userId, godmode: false })
}
dispatch('SuccessMessage')
},
async FetchUsers({ commit, dispatch, getters, state }, { page }) {
......
<template>
<span>
<svg-icon :icon-class="icon"/>
<span slot="title">{{ title }}</span>
<el-badge :value="count" type="primary" class="count-badge" />
</span>
</template>
<script>
export default {
name: 'MenuItem',
functional: true,
name: 'Item',
props: {
count: {
type: String,
default: null
},
icon: {
type: String,
default: ''
......@@ -11,19 +22,13 @@ export default {
type: String,
default: ''
}
},
render(h, context) {
const { icon, title } = context.props
const vnodes = []
if (icon) {
vnodes.push(<svg-icon icon-class={icon}/>)
}
if (title) {
vnodes.push(<span slot='title'>{(title)}</span>)
}
return vnodes
}
}
</script>
<style rel='stylesheet/scss' lang='scss' scoped>
.count-badge {
margin-left: 5px;
height: 48px;
}
</style>
......@@ -4,14 +4,21 @@
<template v-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">
<app-link :to="resolvePath(onlyOneChild.path)">
<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-noDropdown':!isNest}">
<item v-if="onlyOneChild.meta" :icon="onlyOneChild.meta.icon||item.meta.icon" :title="generateTitle(onlyOneChild.meta.title)" />
<item
v-if="onlyOneChild.meta"
:count="showCount(item) ? normalizedReportsCount : null"
:icon="onlyOneChild.meta.icon||item.meta.icon"
:title="generateTitle(onlyOneChild.meta.title)" />
</el-menu-item>
</app-link>
</template>
<el-submenu v-else ref="subMenu" :index="resolvePath(item.path)">
<template slot="title">
<item v-if="item.meta" :icon="item.meta.icon" :title="generateTitle(item.meta.title)" />
<item
v-if="item.meta"
:count="showCount(item) ? normalizedReportsCount : null"
:icon="item.meta.icon"
:title="generateTitle(item.meta.title)" />
</template>
<template v-for="child in item.children">
......@@ -26,7 +33,11 @@
<app-link v-else :to="resolvePath(child.path)" :key="child.name">
<el-menu-item :index="resolvePath(child.path)">
<item v-if="child.meta" :icon="child.meta.icon" :title="generateTitle(child.meta.title)" />
<item
v-if="child.meta"
:count="showCount(item) ? normalizedReportsCount : null"
:icon="child.meta.icon"
:title="generateTitle(child.meta.title)" />
</el-menu-item>
</app-link>
</template>
......@@ -43,6 +54,7 @@ import { isExternal } from '@/utils'
import Item from './Item'
import AppLink from './Link'
import FixiOSBug from './FixiOSBug'
import numeral from 'numeral'
export default {
name: 'SidebarItem',
......@@ -71,6 +83,9 @@ export default {
computed: {
invitesEnabled() {
return this.basePath === '/invites' ? this.$store.state.app.invitesEnabled : true
},
normalizedReportsCount() {
return numeral(this.$store.state.reports.openReportsCount).format('0a')
}
},
methods: {
......@@ -104,6 +119,9 @@ export default {
}
return path.resolve(this.basePath, routePath)
},
showCount(item) {
return item.path === '/reports'
},
isExternalLink(routePath) {
return isExternal(routePath)
},
......
......@@ -31,6 +31,9 @@ export default {
isCollapse() {
return !this.sidebar.opened
}
},
mounted() {
this.$store.dispatch('FetchOpenReportsCount')
}
}
</script>
......@@ -38,11 +38,11 @@ export default {
}
},
created() {
this.$store.dispatch('SetFilter', this.$data.filter)
this.$store.dispatch('SetReportsFilter', this.$data.filter)
},
methods: {
toggleFilters() {
this.$store.dispatch('SetFilter', this.$data.filter)
this.$store.dispatch('SetReportsFilter', this.$data.filter)
this.$store.dispatch('ClearFetchedReports')
this.$store.dispatch('FetchReports', 1)
}
......
......@@ -33,8 +33,16 @@
</el-tooltip>
</span>
<div class="input-row">
<image-upload-input
v-if="isImageUrl"
:data="data"
:setting-group="settingGroup"
:setting="setting"
:input-value="inputValue"
@change="update($event, settingGroup.group, settingGroup.key, settingParent, setting.key, setting.type, nested)"
/>
<el-input
v-if="setting.type === 'string' || (setting.type.includes('string') && setting.type.includes('atom'))"
v-else-if="setting.type === 'string' || (setting.type.includes('string') && setting.type.includes('atom'))"
:value="inputValue"
:placeholder="setting.suggestions ? setting.suggestions[0] : null"
:data-search="setting.key || setting.group"
......@@ -94,9 +102,9 @@
<template slot="prepend">:</template>
</el-input>
<!-- special inputs -->
<auto-linker-input v-if="settingGroup.group === ':auto_linker'" :data="data" :setting-group="settingGroup" :setting="setting"/>
<editable-keyword-input v-if="editableKeyword(setting.key, setting.type)" :data="keywordData" :setting-group="settingGroup" :setting="setting" :parents="settingParent"/>
<icons-input v-if="setting.key === ':icons'" :data="iconsData" :setting-group="settingGroup" :setting="setting"/>
<link-formatter-input v-if="booleanCombinedInput" :data="data" :setting-group="settingGroup" :setting="setting"/>
<mascots-input v-if="setting.key === ':mascots'" :data="keywordData" :setting-group="settingGroup" :setting="setting"/>
<proxy-url-input v-if="setting.key === ':proxy_url'" :data="data[setting.key]" :setting-group="settingGroup" :setting="setting" :parents="settingParent"/>
<prune-input v-if="setting.key === ':prune'" :data="data[setting.key]" :setting-group="settingGroup" :setting="setting"/>
......@@ -120,9 +128,10 @@
<script>
import i18n from '@/lang'
import {
AutoLinkerInput,
EditableKeywordInput,
IconsInput,
ImageUploadInput,
LinkFormatterInput,
MascotsInput,
ProxyUrlInput,
PruneInput,
......@@ -137,9 +146,10 @@ import marked from 'marked'
export default {
name: 'Inputs',
components: {
AutoLinkerInput,
EditableKeywordInput,
IconsInput,
ImageUploadInput,
LinkFormatterInput,
MascotsInput,
ProxyUrlInput,
PruneInput,
......@@ -203,6 +213,9 @@ export default {
}
},
computed: {
booleanCombinedInput() {
return Array.isArray(this.setting.type) && this.setting.type.includes('boolean')
},
canBeDeleted() {
const { group, key } = this.settingGroup
return _.get(this.$store.state.settings.db, [group, key]) &&
......@@ -267,7 +280,7 @@ export default {
':parsers',
':providers',
':method',
':rewrite_policy',
':policies',
'Pleroma.Web.Auth.Authenticator'
].includes(this.setting.key) ||
(this.settingGroup.key === 'Pleroma.Emails.Mailer' && this.setting.key === ':adapter')
......@@ -277,6 +290,9 @@ export default {
},
updatedSettings() {
return this.$store.state.settings.updatedSettings
},
isImageUrl() {
return [':background', ':logo', ':nsfwCensorImage', ':default_user_avatar', ':instance_thumbnail'].includes(this.setting.key)
}
},
methods: {
......
<template>
<div v-if="!loading" :class="isSidebarOpen" class="form-container">
<el-form :model="autoLinkerData" :label-position="labelPosition" :label-width="labelWidth">
<setting :setting-group="autoLinker" :data="autoLinkerData"/>
<el-form :model="linkFormatterData" :label-position="labelPosition" :label-width="labelWidth">
<setting :setting-group="linkFormatter" :data="linkFormatterData"/>
</el-form>
<div class="submit-button-container">
<el-button class="submit-button" type="primary" @click="onSubmit">Submit</el-button>
......@@ -16,17 +16,17 @@ import Setting from './Setting'
import _ from 'lodash'
export default {
name: 'AutoLinker',
name: 'LinkFormatter',
components: { Setting },
computed: {
...mapGetters([
'settings'
]),
autoLinker() {
return this.settings.description.find(setting => setting.key === ':opts')
linkFormatter() {
return this.settings.description.find(setting => setting.key === 'Pleroma.Formatter')
},
autoLinkerData() {
return _.get(this.settings.settings, [':auto_linker', ':opts']) || {}
linkFormatterData() {
return _.get(this.settings.settings, [':pleroma', 'Pleroma.Formatter']) || {}
},
isMobile() {
return this.$store.state.app.device === 'mobile'
......
<template>
<div v-if="!loading" :class="isSidebarOpen" class="form-container">
<div v-for="setting in mrfSettings" :key="setting.key">
<el-form :model="getSettingData(setting)" :label-position="labelPosition" :label-width="labelWidth">
<el-form v-if="showMrfPolicy(setting.key)" :model="getSettingData(setting)" :label-position="labelPosition" :label-width="labelWidth">
<setting :setting-group="setting" :data="getSettingData(setting)"/>
<el-divider v-if="setting" class="divider thick-line"/>
</el-form>
<el-divider v-if="setting" class="divider thick-line"/>
</div>
<div class="submit-button-container">
<el-button class="submit-button" type="primary" @click="onSubmit">Submit</el-button>
......@@ -70,6 +70,16 @@ export default {
type: 'success',
message: i18n.t('settings.success')
})
},
showMrfPolicy(key) {
const selectedMrfPolicies = _.get(this.settings.settings, [':pleroma', ':mrf', ':policies'])
const mappedPolicies = this.mrfSettings.reduce((acc, { key, related_policy }) => {
if (key !== ':mrf') {
acc[key] = related_policy
}
return acc
}, {})
return !Object.keys(mappedPolicies).includes(key) || selectedMrfPolicies.includes(mappedPolicies[key])
}
}
}
......
export { default as ActivityPub } from './ActivityPub'
export { default as Authentication } from './Authentication'
export { default as AutoLinker } from './AutoLinker'
export { default as Captcha } from './Captcha'
export { default as Esshd } from './Esshd'
export { default as Frontend } from './Frontend'
......@@ -8,6 +7,7 @@ export { default as Gopher } from './Gopher'
export { default as Http } from './Http'
export { default as Instance } from './Instance'
export { default as JobQueue } from './JobQueue'
export { default as LinkFormatter } from './LinkFormatter'
export { default as Logger } from './Logger'
export { default as Mailer } from './Mailer'
export { default as MediaProxy } from './MediaProxy'
......
<template>
<div class="image-upload-area">
<div class="input-row">
<div :style="dimensions" class="image-upload-wrapper">
<div :style="dimensions" class="image-upload-overlay">
<input
:aria-label="$t('settings.changeImage')"
class="input-file"
type="file"
accept=".jpg,.jpeg,.png"
@change="handleFiles" >
<div class="caption">
{{ $t('settings.changeImage') }}
</div>
<el-image
v-loading="loading"
:src="imageUrl(inputValue)"
:style="dimensions"
class="uploaded-image"
fit="cover" />
</div>
</div>
</div>
<div class="image-button-group">
<el-button class="upload-button" size="small">
{{ $t('settings.uploadImage') }}
<input
:aria-label="$t('settings.changeImage')"
class="input-file"
type="file"
accept=".jpg,.jpeg,.png"
@change="handleFiles">
</el-button>
<el-button v-if="!isDefault" type="danger" size="small" style="margin-left: 5px;" @click="removeFile()">
{{ $t('settings.remove') }}
</el-button>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import _ from 'lodash'
import { baseName } from '../../../../api/utils'
import { uploadMedia } from '../../../../api/mediaUpload'
export default {
name: 'ImageUploadInput',
props: {
inputValue: {
type: [String, Object],
default: function() {
return {}
}
},
setting: {
type: Object,
default: function() {
return {}
}
}
},
data() {
return {
loading: false
}
},
computed: {
...mapGetters([
'authHost'
]),
fullSize() {
if (_.includes([':background', ':nsfwCensorImage'], this.setting.key)) {
return true
}
return false
},
dimensions() {
return {
width: this.fullSize ? '100%' : '100px',
height: this.fullSize ? '250px' : '100px'
}
},
isDefault() {
return this.defaultImage === this.inputValue
},
defaultImage() {
return this.baseName + _.get(this.setting, 'suggestions[0]')
},