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

Add ability to evict and ban URLs from the Pleroma MediaProxy cache

Closes #122

See merge request pleroma/admin-fe!142
This commit is contained in:
Angelina Filippova 2020-07-27 22:36:12 +00:00
commit ca3745e237
19 changed files with 623 additions and 248 deletions

View file

@ -16,6 +16,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Support pagination of local emoji packs and files
- Add MRF Activity Expiration setting
- Add ability to disable multi-factor authentication for a user
- 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
@ -30,6 +32,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Remove ability to moderate users that don't have valid nickname
- Displays both labels and description in the header of group of settiings
- 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

View file

@ -0,0 +1,34 @@
import request from '@/utils/request'
import { getToken } from '@/utils/auth'
import { baseName } from './utils'
export async function listBannedUrls(page, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/media_proxy_caches?page=${page}`,
method: 'get',
headers: authHeaders(token)
})
}
export async function purgeUrls(urls, ban, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/media_proxy_caches/purge`,
method: 'post',
headers: authHeaders(token),
data: { urls, ban }
})
}
export async function removeBannedUrls(urls, authHost, token) {
return await request({
baseURL: baseName(authHost),
url: `/api/pleroma/admin/media_proxy_caches/delete`,
method: 'post',
headers: authHeaders(token),
data: { urls }
})
}
const authHeaders = (token) => token ? { 'Authorization': `Bearer ${getToken()}` } : {}

View file

@ -65,8 +65,11 @@ export default {
externalLink: 'External Link',
users: 'Users',
reports: 'Reports',
invites: 'Invites',
statuses: 'Statuses',
settings: 'Settings',
moderationLog: 'Moderation Log',
mediaProxyCache: 'MediaProxy Cache',
'emoji-packs': 'Emoji packs'
},
navbar: {
@ -89,6 +92,19 @@ export default {
pleromaFELoginFailed: 'Failed to login via PleromaFE, please login with username/password',
pleromaFELoginSucceed: 'Logged in via PleromaFE'
},
mediaProxyCache: {
mediaProxyCache: 'MediaProxy Cache',
ban: 'Ban',
url: 'URL',
evict: 'Evict',
evictedMessage: 'This URL was evicted',
actions: 'Actions',
remove: 'Remove from Cachex',
evictObjectsHeader: 'Evict object from the MediaProxy cache',
listBannedUrlsHeader: 'List of all banned MediaProxy URLs',
multipleInput: 'You can enter a single URL or several comma separated links',
removeSelected: 'Remove Selected'
},
documentation: {
documentation: 'Documentation',
github: 'Github Repository'

View file

@ -16,7 +16,7 @@ const settings = {
path: 'index',
component: () => import('@/views/settings/index'),
name: 'Settings',
meta: { title: 'Settings', icon: 'settings', noCache: true }
meta: { title: 'settings', icon: 'settings', noCache: true }
}
]
}
@ -30,7 +30,7 @@ const statuses = {
path: 'index',
component: () => import('@/views/statuses/index'),
name: 'Statuses',
meta: { title: 'Statuses', icon: 'form', noCache: true }
meta: { title: 'statuses', icon: 'form', noCache: true }
}
]
}
@ -44,7 +44,7 @@ const reports = {
path: 'index',
component: () => import('@/views/reports/index'),
name: 'Reports',
meta: { title: 'Reports', icon: 'documentation', noCache: true }
meta: { title: 'reports', icon: 'documentation', noCache: true }
}
]
}
@ -58,7 +58,7 @@ const invites = {
path: 'index',
component: () => import('@/views/invites/index'),
name: 'Invites',
meta: { title: 'Invites', icon: 'guide', noCache: true }
meta: { title: 'invites', icon: 'guide', noCache: true }
}
]
}
@ -72,7 +72,7 @@ const emojiPacks = {
path: 'index',
component: () => import('@/views/emojiPacks/index'),
name: 'Emoji Packs',
meta: { title: 'Emoji Packs', icon: 'eye-open', noCache: true }
meta: { title: 'emoji-packs', icon: 'eye-open', noCache: true }
}
]
}
@ -91,6 +91,20 @@ const moderationLog = {
]
}
const mediaProxyCacheDisabled = disabledFeatures.includes('media-proxy-cache')
const mediaProxyCache = {
path: '/media_proxy_cache',
component: Layout,
children: [
{
path: 'index',
component: () => import('@/views/mediaProxyCache/index'),
name: 'MediaProxy Cache',
meta: { title: 'mediaProxyCache', icon: 'example', noCache: true }
}
]
}
export const constantRouterMap = [
{
path: '/redirect',
@ -159,6 +173,7 @@ export const asyncRouterMap = [
...(invitesDisabled ? [] : [invites]),
...(emojiPacksDisabled ? [] : [emojiPacks]),
...(moderationLogDisabled ? [] : [moderationLog]),
...(mediaProxyCacheDisabled ? [] : [mediaProxyCache]),
...(settingsDisabled ? [] : [settings]),
{
path: '/users/:id',

View file

@ -5,6 +5,7 @@ import emojiPacks from './modules/emojiPacks'
import errorLog from './modules/errorLog'
import getters from './getters'
import invites from './modules/invites'
import mediaProxyCache from './modules/mediaProxyCache'
import moderationLog from './modules/moderationLog'
import peers from './modules/peers'
import permission from './modules/permission'
@ -24,8 +25,9 @@ const store = new Vuex.Store({
app,
errorLog,
emojiPacks,
moderationLog,
invites,
mediaProxyCache,
moderationLog,
peers,
permission,
relays,

View file

@ -0,0 +1,53 @@
import { listBannedUrls, purgeUrls, removeBannedUrls } from '@/api/mediaProxyCache'
import { Message } from 'element-ui'
import i18n from '@/lang'
const mediaProxyCache = {
state: {
bannedUrls: [],
bannedUrlsCount: 0,
currentPage: 1,
loading: false
},
mutations: {
SET_BANNED_URLS: (state, urls) => {
state.bannedUrls = urls.map(el => { return { url: el } })
},
SET_BANNED_URLS_COUNT: (state, count) => {
state.bannedUrlsCount = count
},
SET_LOADING: (state, status) => {
state.loading = status
},
SET_PAGE: (state, page) => {
state.currentPage = page
}
},
actions: {
async ListBannedUrls({ commit, getters }, page) {
commit('SET_LOADING', true)
const response = await listBannedUrls(page, getters.authHost, getters.token)
commit('SET_BANNED_URLS', response.data.urls)
// commit('SET_BANNED_URLS_COUNT', count)
commit('SET_PAGE', page)
commit('SET_LOADING', false)
},
async PurgeUrls({ dispatch, getters, state }, { urls, ban }) {
await purgeUrls(urls, ban, getters.authHost, getters.token)
Message({
message: i18n.t('mediaProxyCache.evictedMessage'),
type: 'success',
duration: 5 * 1000
})
if (ban) {
dispatch('ListBannedUrls', state.currentPage)
}
},
async RemoveBannedUrls({ dispatch, getters, state }, urls) {
await removeBannedUrls(urls, getters.authHost, getters.token)
dispatch('ListBannedUrls', state.currentPage)
}
}
}
export default mediaProxyCache

View file

@ -71,18 +71,16 @@ export const parseTuples = (tuples, key) => {
return [...acc, { [mascot.tuple[0]]: { ...mascot.tuple[1], id: `f${(~~(Math.random() * 1e8)).toString(16)}` }}]
}, [])
} else if (Array.isArray(item.tuple[1]) &&
(item.tuple[0] === ':groups' || item.tuple[0] === ':replace' || item.tuple[0] === ':retries')) {
accum[item.tuple[0]] = item.tuple[1].reduce((acc, group) => {
return [...acc, { [group.tuple[0]]: { value: group.tuple[1], id: `f${(~~(Math.random() * 1e8)).toString(16)}` }}]
}, [])
} else if (item.tuple[0] === ':crontab') {
accum[item.tuple[0]] = item.tuple[1].reduce((acc, group) => {
return { ...acc, [group.tuple[1]]: group.tuple[0] }
}, {})
} else if (item.tuple[0] === ':match_actor') {
accum[item.tuple[0]] = Object.keys(item.tuple[1]).reduce((acc, regex) => {
return [...acc, { [regex]: { value: item.tuple[1][regex], id: `f${(~~(Math.random() * 1e8)).toString(16)}` }}]
}, [])
(item.tuple[0] === ':groups' || item.tuple[0] === ':replace' || item.tuple[0] === ':retries' || item.tuple[0] === ':headers' || item.tuple[0] === ':crontab')) {
if (item.tuple[0] === ':crontab') {
accum[item.tuple[0]] = item.tuple[1].reduce((acc, group) => {
return [...acc, { [group.tuple[1]]: { value: group.tuple[0], id: `f${(~~(Math.random() * 1e8)).toString(16)}` }}]
}, [])
} else {
accum[item.tuple[0]] = item.tuple[1].reduce((acc, group) => {
return [...acc, { [group.tuple[0]]: { value: group.tuple[1], id: `f${(~~(Math.random() * 1e8)).toString(16)}` }}]
}, [])
}
} else if (item.tuple[0] === ':icons') {
accum[item.tuple[0]] = item.tuple[1].map(icon => {
return Object.keys(icon).map(name => {
@ -103,7 +101,13 @@ export const parseTuples = (tuples, key) => {
} else if (item.tuple[0] === ':ip') {
accum[item.tuple[0]] = item.tuple[1].tuple.join('.')
} else if (item.tuple[1] && typeof item.tuple[1] === 'object') {
accum[item.tuple[0]] = parseObject(item.tuple[1])
if (item.tuple[0] === ':params' || item.tuple[0] === ':match_actor') {
accum[item.tuple[0]] = Object.keys(item.tuple[1]).reduce((acc, key) => {
return [...acc, { [key]: { value: item.tuple[1][key], id: `f${(~~(Math.random() * 1e8)).toString(16)}` }}]
}, [])
} else {
accum[item.tuple[0]] = parseObject(item.tuple[1])
}
} else {
accum[item.tuple[0]] = item.tuple[1]
}
@ -214,11 +218,11 @@ export const wrapUpdatedSettings = (group, settings, currentState) => {
const wrapValues = (settings, currentState) => {
return Object.keys(settings).map(setting => {
const [type, value] = settings[setting]
if (
type === 'keyword' ||
type.includes('keyword') ||
type.includes('tuple') && type.includes('list') ||
setting === ':replace'
if (type === 'keyword' ||
(Array.isArray(type) && (
type.includes('keyword') ||
(type.includes('tuple') && type.includes('list'))
))
) {
return { 'tuple': [setting, wrapValues(value, currentState)] }
} else if (prependWithСolon(type, value)) {
@ -231,15 +235,16 @@ const wrapValues = (settings, currentState) => {
return { 'tuple': [value, setting] }
} else if (type === 'map') {
const mapValue = Object.keys(value).reduce((acc, key) => {
acc[key] = setting === ':match_actor' ? value[key] : value[key][1]
acc[key] = value[key][1]
return acc
}, {})
const mapCurrentState = setting === ':match_actor'
? currentState[setting].reduce((acc, element) => {
return { ...acc, ...{ [Object.keys(element)[0]]: Object.values(element)[0].value }}
}, {})
: currentState[setting]
return { 'tuple': [setting, { ...mapCurrentState, ...mapValue }] }
return { 'tuple': [setting, { ...currentState[setting], ...mapValue }] }
} else if (type.includes('map')) {
const mapValue = Object.keys(value).reduce((acc, key) => {
acc[key] = value[key][1]
return acc
}, {})
return { 'tuple': [setting, mapValue] }
} else if (setting === ':ip') {
const ip = value.split('.').map(s => parseInt(s, 10))
return { 'tuple': [setting, { 'tuple': ip }] }

View file

@ -0,0 +1,157 @@
<template>
<div class="media-proxy-cache-container">
<div class="media-proxy-cache-header-container">
<h1>{{ $t('mediaProxyCache.mediaProxyCache') }}</h1>
<reboot-button/>
</div>
<p class="media-proxy-cache-header">{{ $t('mediaProxyCache.evictObjectsHeader') }}</p>
<div class="url-input-container">
<el-input
:placeholder="$t('mediaProxyCache.url')"
v-model="urls"
type="textarea"
autosize
clearable
class="url-input"/>
<el-checkbox v-model="ban">{{ $t('mediaProxyCache.ban') }}</el-checkbox>
<el-button class="evict-button" @click="evictURL">{{ $t('mediaProxyCache.evict') }}</el-button>
</div>
<span class="expl url-input-expl">{{ $t('mediaProxyCache.multipleInput') }}</span>
<p class="media-proxy-cache-header">{{ $t('mediaProxyCache.listBannedUrlsHeader') }}</p>
<el-table
v-loading="loading"
:data="bannedUrls"
class="banned-urls-table"
@selection-change="handleSelectionChange">>
<el-table-column
type="selection"
align="center"
width="55"/>
<el-table-column
:min-width="isDesktop ? 320 : 120"
prop="url"/>
<el-table-column>
<template slot="header">
<el-button
:disabled="removeSelectedDisabled"
size="mini"
class="remove-url-button"
@click="removeSelected()">{{ $t('mediaProxyCache.removeSelected') }}</el-button>
</template>
<template slot-scope="scope">
<el-button
size="mini"
class="remove-url-button"
@click="removeUrl(scope.row.url)">{{ $t('mediaProxyCache.remove') }}</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script>
import RebootButton from '@/components/RebootButton'
export default {
name: 'MediaProxyCache',
components: { RebootButton },
data() {
return {
urls: '',
ban: false,
selectedUrls: []
}
},
computed: {
bannedUrls() {
return this.$store.state.mediaProxyCache.bannedUrls
},
isDesktop() {
return this.$store.state.app.device === 'desktop'
},
loading() {
return this.$store.state.mediaProxyCache.loading
},
removeSelectedDisabled() {
return this.selectedUrls.length === 0
}
},
mounted() {
this.$store.dispatch('GetNodeInfo')
this.$store.dispatch('NeedReboot')
this.$store.dispatch('ListBannedUrls', 1)
},
methods: {
evictURL() {
const urls = this.urls.split(',').map(url => url.trim()).filter(el => el.length > 0)
this.$store.dispatch('PurgeUrls', { urls, ban: this.ban })
this.urls = ''
},
handleSelectionChange(value) {
this.$data.selectedUrls = value
},
removeSelected() {
const urls = this.selectedUrls.map(el => el.url)
this.$store.dispatch('RemoveBannedUrls', urls)
this.selectedUrls = []
},
removeUrl(url) {
this.$store.dispatch('RemoveBannedUrls', [url])
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss' scoped>
h1 {
margin: 0;
}
.expl {
color: #666666;
font-size: 13px;
line-height: 22px;
margin: 5px 0 0 0;
overflow-wrap: break-word;
overflow: hidden;
text-overflow: ellipsis;
}
.banned-urls-table {
margin-top: 15px;
margin-bottom: 15px;
}
.evict-button {
margin-left: 15px;
}
.media-proxy-cache-header {
margin-left: 15px;
margin-top: 22px;
font-weight: 500;
}
.media-proxy-cache-header-container {
display: flex;
align-items: center;
justify-content: space-between;
margin: 10px 15px;
}
.remove-url-button {
width: 150px;
}
.url-input {
margin-right: 15px;
}
.url-input-container {
display: flex;
align-items: baseline;
margin: 15px 15px 5px 15px;
}
.url-input-expl {
margin-left: 15px;
}
@media only screen and (max-width:480px) {
.url-input {
width: 100%;
margin-bottom: 5px;
}
}
</style>

View file

@ -95,15 +95,14 @@
<el-input
v-if="setting.type === 'atom'"
:value="inputValue"
:placeholder="setting.suggestions[0] ? setting.suggestions[0].substr(1) : ''"
:placeholder="setting.suggestions && setting.suggestions[0] ? setting.suggestions[0].substr(1) : ''"
:data-search="setting.key || setting.group"
class="input"
@input="update($event, settingGroup.group, settingGroup.key, settingParent, setting.key, setting.type, nested)">
<template slot="prepend">:</template>
</el-input>
<!-- special inputs -->
<crontab-input v-if="setting.key === ':crontab'" :data="data[setting.key]" :setting-group="settingGroup" :setting="setting"/>
<editable-keyword-input v-if="editableKeyword(setting.key, setting.type)" :data="keywordData" :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"/>
@ -129,7 +128,6 @@
<script>
import i18n from '@/lang'
import {
CrontabInput,
EditableKeywordInput,
IconsInput,
ImageUploadInput,
@ -148,7 +146,6 @@ import marked from 'marked'
export default {
name: 'Inputs',
components: {
CrontabInput,
EditableKeywordInput,
IconsInput,
ImageUploadInput,
@ -225,7 +222,7 @@ export default {
this.$store.state.settings.db[group][key].includes(this.setting.key)
},
iconsData() {
return Array.isArray(this.data[':icons']) ? this.data[':icons'] : []
return Array.isArray(this.data) ? this.data : []
},
inputValue() {
if ([':esshd', ':cors_plug', ':quack', ':tesla', ':swoosh'].includes(this.settingGroup.group) &&
@ -267,6 +264,10 @@ export default {
}
},
keywordData() {
if (this.settingParent.length > 0 ||
(Array.isArray(this.setting.type) && this.setting.type.includes('tuple') && this.setting.type.includes('list'))) {
return Array.isArray(this.data[this.setting.key]) ? this.data[this.setting.key] : []
}
return Array.isArray(this.data) ? this.data : []
},
reducedSelects() {
@ -296,10 +297,14 @@ export default {
},
methods: {
editableKeyword(key, type) {
return key === ':replace' ||
type === 'map' ||
(Array.isArray(type) && type.includes('keyword') && type.includes('integer')) ||
(Array.isArray(type) && type.includes('keyword') && type.findIndex(el => el.includes('list') && el.includes('string')) !== -1)
return Array.isArray(type) && (
(type.includes('map') && type.includes('string')) ||
(type.includes('map') && type.findIndex(el => el.includes('list') && el.includes('string')) !== -1) ||
(type.includes('keyword') && type.includes('integer')) ||
(type.includes('keyword') && type.includes('string')) ||
(type.includes('tuple') && type.includes('list')) ||
(type.includes('keyword') && type.findIndex(el => el.includes('list') && el.includes('string')) !== -1)
)
},
getFormattedDescription(desc) {
return marked(desc)
@ -346,7 +351,7 @@ export default {
type.includes('module') ||
(type.includes('list') && type.includes('string')) ||
(type.includes('list') && type.includes('atom')) ||
(type.includes('regex') && type.includes('string'))
(!type.includes('keyword') && type.includes('regex') && type.includes('string'))
)
},
renderSingleSelect(type) {

View file

@ -3,6 +3,14 @@
<el-form v-if="!loading" :model="mediaProxyData" :label-position="labelPosition" :label-width="labelWidth">
<setting :setting-group="mediaProxy" :data="mediaProxyData"/>
</el-form>
<el-divider v-if="mediaProxy" class="divider thick-line"/>
<el-form v-if="!loading" :model="httpInvalidationData" :label-position="labelPosition" :label-width="labelWidth">
<setting :setting-group="httpInvalidation" :data="httpInvalidationData"/>
</el-form>
<el-divider v-if="httpInvalidation" class="divider thick-line"/>
<el-form v-if="!loading" :model="scriptInvalidationData" :label-position="labelPosition" :label-width="labelWidth">
<setting :setting-group="scriptInvalidation" :data="scriptInvalidationData"/>
</el-form>
<div class="submit-button-container">
<el-button class="submit-button" type="primary" @click="onSubmit">Submit</el-button>
</div>
@ -22,6 +30,12 @@ export default {
...mapGetters([
'settings'
]),
httpInvalidation() {
return this.settings.description.find(setting => setting.key === 'Pleroma.Web.MediaProxy.Invalidation.Http')
},
httpInvalidationData() {
return _.get(this.settings.settings, [':pleroma', 'Pleroma.Web.MediaProxy.Invalidation.Http']) || {}
},
isMobile() {
return this.$store.state.app.device === 'mobile'
},
@ -51,6 +65,12 @@ export default {
},
mediaProxyData() {
return _.get(this.settings.settings, [':pleroma', ':media_proxy']) || {}
},
scriptInvalidation() {
return this.settings.description.find(setting => setting.key === 'Pleroma.Web.MediaProxy.Invalidation.Script')
},
scriptInvalidationData() {
return _.get(this.settings.settings, [':pleroma', 'Pleroma.Web.MediaProxy.Invalidation.Script']) || {}
}
},
methods: {

View file

@ -122,7 +122,7 @@ export default {
return type === 'keyword' ||
type === 'map' ||
type.includes('keyword') ||
key === ':replace'
type.includes('map')
},
divideSetting(key) {
return [':sslopts', ':tlsopts', ':adapter', ':poll_limits', ':queues', ':styling', ':invalidation', ':multi_factor_authentication'].includes(key)

View file

@ -1,86 +0,0 @@
<template>
<el-form :label-width="labelWidth" :label-position="isMobile ? 'top' : 'right'" class="crontab">
<el-form-item v-for="worker in workers" :key="worker" :label="worker" :data-search="setting.key" class="crontab-container">
<el-input
:value="data[worker]"
:placeholder="getSuggestion(worker) || null"
class="input setting-input"
@input="update($event, worker)"/>
</el-form-item>
</el-form>
</template>
<script>
export default {
name: 'CrontabInput',
props: {
data: {
type: Object,
default: function() {
return {}
}
},
setting: {
type: Object,
default: function() {
return {}
}
},
settingGroup: {
type: Object,
default: function() {
return {}
}
}
},
computed: {
isDesktop() {
return this.$store.state.app.device === 'desktop'
},
isMobile() {
return this.$store.state.app.device === 'mobile'
},
isTablet() {
return this.$store.state.app.device === 'tablet'
},
labelWidth() {
if (this.isMobile) {
return '100%'
} else {
return '380px'
}
},
workers() {
return this.setting.suggestions.map(worker => worker[1])
}
},
methods: {
getSuggestion(worker) {
return this.setting.suggestions.find(suggestion => suggestion[1] === worker)[0]
},
update(value, worker) {
const currentValue = this.$store.state.settings.settings[this.settingGroup.group][this.settingGroup.key][this.setting.key]
const updatedValue = { ...currentValue, [worker]: value }
const updatedValueWithType = Object.keys(currentValue).reduce((acc, key) => {
if (key === worker) {
return { ...acc, [key]: ['reversed_tuple', value] }
} else {
return { ...acc, [key]: ['reversed_tuple', currentValue[key]] }
}
}, {})
this.$store.dispatch('UpdateSettings',
{ group: this.settingGroup.group, key: this.settingGroup.key, input: this.setting.key, value: updatedValueWithType, type: this.setting.type }
)
this.$store.dispatch('UpdateState',
{ group: this.settingGroup.group, key: this.settingGroup.key, input: this.setting.key, value: updatedValue }
)
}
}
}
</script>
<style rel='stylesheet/scss' lang='scss'>
@import '../../styles/main';
@include settings
</style>

View file

@ -1,12 +1,13 @@
<template>
<div class="editable-keyword-container">
<div v-if="setting.key === ':replace'" :data-search="setting.key || setting.group">
<div v-for="element in data" :key="getId(element)" class="setting-input">
<el-input :value="getKey(element)" placeholder="pattern" class="name-input" @input="parseEditableKeyword($event, 'key', element)"/> :
<el-input :value="getValue(element)" placeholder="replacement" class="value-input" @input="parseEditableKeyword($event, 'value', element)"/>
<el-button :size="isDesktop ? 'medium' : 'mini'" class="icon-minus-button" icon="el-icon-minus" circle @click="deleteEditableKeywordRow(element)"/>
</div>
<el-button :size="isDesktop ? 'medium' : 'mini'" icon="el-icon-plus" circle @click="addRowToEditableKeyword"/>
<div v-if="setting.key === ':crontab'" :data-search="setting.key" class="crontab">
<el-form-item v-for="worker in data" :key="getId(worker)" :label="getCrontabWorkerLabel(worker)" class="crontab-container">
<el-input
:value="getValue(worker)"
:placeholder="getSuggestion(worker) || null"
class="input setting-input"
@input="updateCrontab($event, 'value', worker)"/>
</el-form-item>
</div>
<div v-else-if="editableKeywordWithInteger" :data-search="setting.key || setting.group">
<div v-for="element in data" :key="getId(element)" class="setting-input">
@ -16,7 +17,15 @@
</div>
<el-button :size="isDesktop ? 'medium' : 'mini'" icon="el-icon-plus" circle @click="addRowToEditableKeyword"/>
</div>
<div v-else :data-search="setting.key || setting.group">
<div v-else-if="editableKeywordWithString" :data-search="setting.key || setting.group">
<div v-for="element in data" :key="getId(element)" class="setting-input">
<el-input :value="getKey(element)" :placeholder="keyPlaceholder" class="name-input" @input="parseEditableKeyword($event, 'key', element)"/> :
<el-input :value="getValue(element)" :placeholder="valuePlaceholder" class="value-input" @input="parseEditableKeyword($event, 'value', element)"/>
<el-button :size="isDesktop ? 'medium' : 'mini'" class="icon-minus-button" icon="el-icon-minus" circle @click="deleteEditableKeywordRow(element)"/>
</div>
<el-button :size="isDesktop ? 'medium' : 'mini'" icon="el-icon-plus" circle @click="addRowToEditableKeyword"/>
</div>
<div v-else-if="editableKeywordWithSelect" :data-search="setting.key || setting.group">
<div v-for="element in data" :key="getId(element)" class="setting-input">
<el-input :value="getKey(element)" placeholder="key" class="name-input" @input="parseEditableKeyword($event, 'key', element)"/> :
<el-select :value="getValue(element)" multiple filterable allow-create class="value-input" @change="parseEditableKeyword($event, 'value', element)"/>
@ -28,6 +37,8 @@
</template>
<script>
import { processNested } from '@/store/modules/normalizers'
export default {
name: 'EditableKeywordInput',
props: {
@ -37,6 +48,13 @@ export default {
return {}
}
},
parents: {
type: Array,
default: function() {
return []
},
required: false
},
setting: {
type: Object,
default: function() {
@ -52,10 +70,33 @@ export default {
},
computed: {
editableKeywordWithInteger() {
return Array.isArray(this.setting.type) && this.setting.type.includes('keyword') && this.setting.type.includes('integer')
return this.setting.type.includes('keyword') && this.setting.type.includes('integer')
},
editableKeywordWithSelect() {
return (this.setting.type.includes('map') && this.setting.type.findIndex(el => el.includes('list') && el.includes('string')) !== -1) ||
(this.setting.type.includes('keyword') && this.setting.type.findIndex(el => el.includes('list') && el.includes('string')) !== -1)
},
editableKeywordWithString() {
return this.setting.key !== ':crontab' && (
(this.setting.type.includes('keyword') && this.setting.type.includes('string')) ||
(this.setting.type.includes('tuple') && this.setting.type.includes('list')) ||
(this.setting.type.includes('map') && this.setting.type.includes('string'))
)
},
isDesktop() {
return this.$store.state.app.device === 'desktop'
},
keyPlaceholder() {
return this.setting.key === ':replace' ? 'pattern' : 'key'
},
settings() {
return this.$store.state.settings.settings
},
updatedSettings() {
return this.$store.state.settings.updatedSettings
},
valuePlaceholder() {
return this.setting.key === ':replace' ? 'replacement' : 'value'
}
},
methods: {
@ -71,6 +112,10 @@ export default {
generateID() {
return `f${(~~(Math.random() * 1e8)).toString(16)}`
},
getCrontabWorkerLabel(worker) {
const workerKey = this.getKey(worker)
return workerKey.includes('Pleroma.Workers.Cron.') ? workerKey.replace('Pleroma.Workers.Cron.', '') : workerKey
},
getKey(element) {
return Object.keys(element)[0]
},
@ -78,6 +123,9 @@ export default {
const { id } = Object.values(element)[0]
return id
},
getSuggestion(worker) {
return this.setting.suggestions.find(suggestion => suggestion[1] === this.getKey(worker))[0]
},
getValue(element) {
const { value } = Object.values(element)[0]
return value
@ -95,10 +143,40 @@ export default {
this.updateSetting(updatedValue, this.settingGroup.group, this.settingGroup.key, this.setting.key, this.setting.type)
},
updateCrontab(value, inputType, worker) {
const updatedId = this.getId(worker)
const updatedValue = this.data.map((worker, index) => {
if (Object.values(worker)[0].id === updatedId) {
return { [Object.keys(worker)[0]]: { ...Object.values(this.data[index])[0], value }}
}
return worker
})
const updatedValueWithType = updatedValue.reduce((acc, worker) => {
return { ...acc, [Object.keys(worker)[0]]: ['reversed_tuple', Object.values(worker)[0].value] }
}, {})
this.$store.dispatch('UpdateSettings',
{ group: this.settingGroup.group, key: this.settingGroup.key, input: this.setting.key, value: updatedValueWithType, type: this.setting.type }
)
this.$store.dispatch('UpdateState',
{ group: this.settingGroup.group, key: this.settingGroup.key, input: this.setting.key, value: updatedValue }
)
},
updateSetting(value, group, key, input, type) {
const updatedSettings = this.wrapUpdatedSettings(value, input, type)
this.$store.dispatch('UpdateSettings', { group, key, input, value: updatedSettings, type })
this.$store.dispatch('UpdateState', { group, key, input, value })
const wrappedSettings = this.wrapUpdatedSettings(value, input, type)
if (this.parents.length > 0) {
const { valueForState,
valueForUpdatedSettings,
setting } = processNested(value, wrappedSettings, group, key, this.parents.reverse(), this.settings, this.updatedSettings)
this.$store.dispatch('UpdateSettings',
{ group, key, input: setting.key, value: valueForUpdatedSettings, type: setting.type })
this.$store.dispatch('UpdateState',
{ group, key, input: setting.key, value: valueForState })
} else {
this.$store.dispatch('UpdateSettings', { group, key, input, value: wrappedSettings, type })
this.$store.dispatch('UpdateState', { group, key, input, value })
}
},
wrapUpdatedSettings(value, input, type) {
return type === 'map'

View file

@ -1,14 +1,16 @@
<template>
<div :data-search="setting.key || setting.group" class="rate-limit-container">
<div v-if="!rateLimitAuthUsers">
<el-input
<el-input-number
:value="rateLimitAllUsers[0]"
:controls="false"
placeholder="scale"
class="scale-input"
@input="parseRateLimiter($event, setting.key, 'scale', 'oneLimit', rateLimitAllUsers)"/>
<span>:</span>
<el-input
<el-input-number
:value="rateLimitAllUsers[1]"
:controls="false"
placeholder="limit"
class="limit-input"
@input="parseRateLimiter($event, setting.key, 'limit', 'oneLimit', rateLimitAllUsers)"/>
@ -25,16 +27,18 @@
</span>
</div>
<div class="rate-limit-content">
<el-input
<el-input-number
:value="rateLimitUnauthUsers[0]"
:controls="false"
placeholder="scale"
class="scale-input"
@input="parseRateLimiter(
$event, setting.key, 'scale', 'unauthUsersLimit', [rateLimitUnauthUsers, rateLimitAuthUsers]
)"/>
<span>:</span>
<el-input
<el-input-number
:value="rateLimitUnauthUsers[1]"
:controls="false"
placeholder="limit"
class="limit-input"
@input="parseRateLimiter(
@ -49,14 +53,16 @@
</span>
</div>
<div class="rate-limit-content">
<el-input
<el-input-number
:value="rateLimitAuthUsers[0]"
:controls="false"
placeholder="scale"
class="scale-input"
@input="parseRateLimiter($event, setting.key, 'scale', 'authUserslimit', [rateLimitUnauthUsers, rateLimitAuthUsers])"/>
<span>:</span>
<el-input
<el-input-number
:value="rateLimitAuthUsers[1]"
:controls="false"
placeholder="limit"
class="limit-input"
@input="parseRateLimiter($event, setting.key, 'limit', 'authUserslimit', [rateLimitUnauthUsers, rateLimitAuthUsers])"/>

View file

@ -1,5 +1,4 @@
export { default as EditableKeywordInput } from './EditableKeywordInput'
export { default as CrontabInput } from './CrontabInput'
export { default as IconsInput } from './IconsInput'
export { default as ImageUploadInput } from './ImageUploadInput'
export { default as LinkFormatterInput } from './LinkFormatterInput'

View file

@ -1,82 +1,84 @@
export const tabs = {
'activity-pub': {
label: 'settings.activityPub',
settings: [':activitypub', ':user']
},
'authentication': {
label: 'settings.auth',
settings: [':auth', ':ldap', ':oauth2', 'Pleroma.Web.Auth.Authenticator']
},
'esshd': {
label: 'settings.esshd',
settings: [':esshd']
},
'captcha': {
label: 'settings.captcha',
settings: ['Pleroma.Captcha', 'Pleroma.Captcha.Kocaptcha']
},
'frontend': {
label: 'settings.frontend',
settings: [':assets', ':chat', ':emoji', ':frontend_configurations', ':markup', ':static_fe']
},
'gopher': {
label: 'settings.gopher',
settings: [':gopher']
},
'http': {
label: 'settings.http',
settings: [':cors_plug', ':http', ':http_security', ':web_cache_ttl']
},
'instance': {
label: 'settings.instance',
settings: [':admin_token', ':instance', ':manifest', 'Pleroma.User', 'Pleroma.ScheduledActivity', ':uri_schemes', ':feed', ':streamer']
},
'job-queue': {
label: 'settings.jobQueue',
settings: ['Pleroma.ActivityExpiration', 'Oban', ':workers']
},
'link-formatter': {
label: 'settings.linkFormatter',
settings: ['Pleroma.Formatter']
},
'logger': {
label: 'settings.logger',
settings: [':console', ':ex_syslogger', ':quack', ':logger']
},
'mailer': {
label: 'settings.mailer',
settings: [':email_notifications', 'Pleroma.Emails.Mailer', 'Pleroma.Emails.UserEmail', ':swoosh', 'Pleroma.Emails.NewUsersDigestEmail']
},
'media-proxy': {
label: 'settings.mediaProxy',
settings: [':media_proxy']
},
'metadata': {
label: 'settings.metadata',
settings: ['Pleroma.Web.Metadata', ':rich_media']
},
'mrf': {
label: 'settings.mrf',
settings: [':mrf_simple', ':mrf_rejectnonpublic', ':mrf_hellthread', ':mrf_keyword', ':mrf_subchain', ':mrf_mention', ':mrf_normalize_markup', ':mrf_vocabulary', ':mrf_object_age', ':modules']
},
'rate-limiters': {
label: 'settings.rateLimiters',
settings: [':rate_limit']
},
'relays': {
label: 'settings.relays',
settings: ['relays']
},
'web-push': {
label: 'settings.webPush',
settings: [':vapid_details']
},
'upload': {
label: 'settings.upload',
settings: ['Pleroma.Upload.Filter.AnonymizeFilename', 'Pleroma.Upload.Filter.Mogrify', 'Pleroma.Uploaders.S3', 'Pleroma.Uploaders.Local', 'Pleroma.Upload']
},
'other': {
label: 'settings.other',
settings: [':mime', 'Pleroma.Plugs.RemoteIp']
export const tabs = description => {
return {
'activity-pub': {
label: 'settings.activityPub',
settings: [':activitypub', ':user']
},
'authentication': {
label: 'settings.auth',
settings: [':auth', ':ldap', ':oauth2', 'Pleroma.Web.Auth.Authenticator']
},
'auto-linker': {
label: 'settings.autoLinker',
settings: [':opts']
},
'esshd': {
label: 'settings.esshd',
settings: [':esshd']
},
'captcha': {
label: 'settings.captcha',
settings: ['Pleroma.Captcha', 'Pleroma.Captcha.Kocaptcha']
},
'frontend': {
label: 'settings.frontend',
settings: [':assets', ':chat', ':emoji', ':frontend_configurations', ':markup', ':static_fe']
},
'gopher': {
label: 'settings.gopher',
settings: [':gopher']
},
'http': {
label: 'settings.http',
settings: [':cors_plug', ':http', ':http_security', ':web_cache_ttl']
},
'instance': {
label: 'settings.instance',
settings: [':admin_token', ':instance', ':manifest', 'Pleroma.User', 'Pleroma.ScheduledActivity', ':uri_schemes', ':feed', ':streamer']
},
'job-queue': {
label: 'settings.jobQueue',
settings: ['Pleroma.ActivityExpiration', 'Oban', ':workers']
},
'logger': {
label: 'settings.logger',
settings: [':console', ':ex_syslogger', ':quack', ':logger']
},
'mailer': {
label: 'settings.mailer',
settings: [':email_notifications', 'Pleroma.Emails.Mailer', 'Pleroma.Emails.UserEmail', ':swoosh', 'Pleroma.Emails.NewUsersDigestEmail']
},
'media-proxy': {
label: 'settings.mediaProxy',
settings: [':media_proxy', 'Pleroma.Web.MediaProxy.Invalidation.Http', 'Pleroma.Web.MediaProxy.Invalidation.Script']
},
'metadata': {
label: 'settings.metadata',
settings: ['Pleroma.Web.Metadata', ':rich_media']
},
'mrf': {
label: 'settings.mrf',
settings: description.filter(el => el.tab === 'mrf').map(setting => setting.key)
},
'rate-limiters': {
label: 'settings.rateLimiters',
settings: [':rate_limit']
},
'relays': {
label: 'settings.relays',
settings: ['relays']
},
'web-push': {
label: 'settings.webPush',
settings: [':vapid_details']
},
'upload': {
label: 'settings.upload',
settings: ['Pleroma.Upload.Filter.AnonymizeFilename', 'Pleroma.Upload.Filter.Mogrify', 'Pleroma.Uploaders.S3', 'Pleroma.Uploaders.Local', 'Pleroma.Upload', ':s3']
},
'other': {
label: 'settings.other',
settings: [':mime', 'Pleroma.Plugs.RemoteIp']
}
}
}

View file

@ -200,7 +200,7 @@ export default {
return this.$store.state.settings.searchData
},
tabs() {
return tabs
return tabs(this.$store.state.settings.description)
}
},
mounted: function() {

View file

@ -198,6 +198,30 @@ describe('Parse tuples', () => {
expect(_.isEqual(expectedResult, result)).toBeTruthy()
})
it('parses crontab setting', () => {
const tuples = [{ tuple: [':crontab', [
{ tuple: ['0 0 * * *', 'Pleroma.Workers.Cron.ClearOauthTokenWorker'] },
{ tuple: ['0 * * * *', 'Pleroma.Workers.Cron.StatsWorker'] },
{ tuple: ['* * * * *', 'Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker']}
]]}]
const expectedResult = { ':crontab': [
{ 'Pleroma.Workers.Cron.ClearOauthTokenWorker': { value: '0 0 * * *'}},
{ 'Pleroma.Workers.Cron.StatsWorker': { value: '0 * * * *'}},
{ 'Pleroma.Workers.Cron.PurgeExpiredActivitiesWorker': { value: '* * * * *'}}
]}
const parsed = parseTuples(tuples, 'Oban')
expect(typeof parsed).toBe('object')
expect(':crontab' in parsed).toBeTruthy()
const result = { ...parsed, ':crontab': parsed[':crontab'].map(el => {
const key = Object.keys(el)[0]
const { id, ...rest } = el[key]
return { [key]: rest }
})}
expect(_.isEqual(expectedResult, result)).toBeTruthy()
})
it('parses match_actor setting in mrf_subchain group', () => {
const tuples = [{ tuple: [":match_actor",
{ '~r/https:\/\/example.com/s': ["Elixir.Pleroma.Web.ActivityPub.MRF.DropPolicy"]}]}]
@ -216,6 +240,26 @@ describe('Parse tuples', () => {
expect(_.isEqual(expectedResult, result)).toBeTruthy()
})
it('parses options setting in MediaProxy.Invalidation.Http group', () => {
const tuples = [{ tuple: [":options", [{ tuple: [":params", { xxx: "zzz", aaa: "bbb" }]}]]}]
const expectedResult = { ':options': { ':params':
[ { xxx: { value: 'zzz' }}, { aaa: { value: 'bbb' }}]
}}
const parsed = parseTuples(tuples, 'Pleroma.Web.MediaProxy.Invalidation.Http')
expect(typeof parsed).toBe('object')
expect(':options' in parsed).toBeTruthy()
const idRemoved = parsed[':options'][':params'].map(el => {
const key = Object.keys(el)[0]
const { id, ...rest } = el[key]
return { [key]: rest }
})
parsed[':options'][':params'] = idRemoved
expect(_.isEqual(expectedResult, parsed)).toBeTruthy()
})
it('parses proxy_url', () => {
const proxyUrlNull = [{ tuple: [":proxy_url", null] }]
const proxyUrlTuple = [{ tuple: [":proxy_url", { tuple: [":socks5", ":localhost", 3090] }]}]

View file

@ -130,7 +130,7 @@ describe('Wrap settings', () => {
}]
const settings2 = { ':emoji': { ':groups': [
['keyword', 'string', ['list', 'string']],
['keyword', ['list', 'string']],
{ ':custom': [['list'], ['/emoji/*.png', '/emoji/**/*.png']],
':another_group': ['list', ['/custom_emoji/*.png']]}
]}}
@ -151,7 +151,7 @@ describe('Wrap settings', () => {
it('wraps :replace setting', () => {
const settings = { ':mrf_keyword': { ':replace': [
[['tuple', 'string', 'string'], ['tuple', 'regex', 'string']],
['list', 'tuple'],
{ 'pattern': ['list', 'replacement'],
'/\w+/': ['list', 'test_replacement']}
]}}
@ -296,17 +296,23 @@ describe('Wrap settings', () => {
}]}]
}]
const settings3 = { ':mrf_subchain': { ':match_actor': ['map', {
'~r/https:\/\/example.com/s': ['Elixir.Pleroma.Web.ActivityPub.MRF.DropPolicy'],
'~r/https:\/\/test.com': ['Elixir.Pleroma.Web.ActivityPub.MRF.TestPolicy']
expect(_.isEqual(result1, expectedResult1)).toBeTruthy()
expect(_.isEqual(result2, expectedResult2)).toBeTruthy()
})
it('wraps settings with type that includes map', () => {
const settings1 = { ':mrf_subchain': { ':match_actor': [['map', ['list', 'string']], {
'~r/https:\/\/example.com/s': ['list', ['Elixir.Pleroma.Web.ActivityPub.MRF.DropPolicy']],
'~r/https:\/\/test.com': ['list', ['Elixir.Pleroma.Web.ActivityPub.MRF.TestPolicy']]
}]}}
const state3 = { ':pleroma': { ':mrf_subchain': { ':match_actor': [
{ '~r/https:\/\/example.com/s': ['Elixir.Pleroma.Web.ActivityPub.MRF.DropPolicy'] },
{ '~r/https:\/\/test.com': ['Elixir.Pleroma.Web.ActivityPub.MRF.TestPolicy'] }
const state1 = { ':pleroma': { ':mrf_subchain': { ':match_actor': [
{ '~r/https:\/\/example.com/s': { value: ['Elixir.Pleroma.Web.ActivityPub.MRF.DropPolicy'], id: '1234' }},
{ '~r/https:\/\/test.com': { value: ['Elixir.Pleroma.Web.ActivityPub.MRF.TestPolicy'], id: '5678' } }
]
}}}
const result3 = wrapUpdatedSettings(':pleroma', settings3, state3)
const expectedResult3 = [{
const result1 = wrapUpdatedSettings(':pleroma', settings1, state1)
const expectedResult1 = [{
group: ':pleroma',
key: ':mrf_subchain',
value: [{ tuple: [':match_actor', {
@ -315,9 +321,24 @@ describe('Wrap settings', () => {
}]}]
}]
const settings2 = { 'Pleroma.Web.MediaProxy.Invalidation.Http': {
':options': ['keyword', { ':params': [['map', 'string'], { aaa: ['list', 'bbb'], xxx: ['list', 'zzz'] }]}]
}}
const state2 = { ':pleroma': { 'Pleroma.Web.MediaProxy.Invalidation.Http': {
':options': { ':params': [{ aaa: { value: 'bbb', id: '1' }, xxx: { value: 'zzz', id: '2' }}] }
}}}
const result2 = wrapUpdatedSettings(':pleroma', settings2, state2)
const expectedResult2 = [{
group: ':pleroma',
key: 'Pleroma.Web.MediaProxy.Invalidation.Http',
value: [{ tuple: [':options', [
{ tuple: [':params', { aaa: 'bbb', xxx: 'zzz' }]}
]]}]
}]
expect(_.isEqual(result1, expectedResult1)).toBeTruthy()
expect(_.isEqual(result2, expectedResult2)).toBeTruthy()
expect(_.isEqual(result3, expectedResult3)).toBeTruthy()
})
it('wraps IP setting', () => {
@ -351,10 +372,10 @@ describe('Wrap settings', () => {
it('wraps regular settings', () => {
const settings = { ':http_security': {
':report_uri': ["string", "https://test.com"],
':ct_max_age': ["integer", 150000],
':sts': ["boolean", true],
':methods': [["list", "string"], ["POST", "PUT", "PATCH"]]
':report_uri': ['string', 'https://test.com'],
':ct_max_age': ['integer', 150000],
':sts': ['boolean', true],
':methods': [['list', 'string'], ['POST', 'PUT', 'PATCH']]
}}
const state = { ':pleroma': { ':http_security': {}}}
const result = wrapUpdatedSettings(':pleroma', settings, state)
@ -362,10 +383,10 @@ describe('Wrap settings', () => {
group: ':pleroma',
key: ':http_security',
value: [
{ tuple: [":report_uri", "https://test.com"] },
{ tuple: [":ct_max_age", 150000] },
{ tuple: [":sts", true] },
{ tuple: [":methods", ["POST", "PUT", "PATCH"]] }
{ tuple: [':report_uri', 'https://test.com'] },
{ tuple: [':ct_max_age', 150000] },
{ tuple: [':sts', true] },
{ tuple: [':methods', ['POST', 'PUT', 'PATCH']] }
]
}]