feat: new Report / Moderation feature, fix #221, fix #350, fix #220

This commit is contained in:
lesion 2024-03-18 12:25:36 +01:00
parent 0e3e045d3f
commit b40b4ba3b4
No known key found for this signature in database
GPG key ID: 352918250B012177
23 changed files with 385 additions and 98 deletions

View file

@ -27,14 +27,6 @@ span
v-list-item-content v-list-item-content
v-list-item-title(v-text="$t('common.clone')") v-list-item-title(v-text="$t('common.clone')")
//- Contact user
v-list-item(v-if='event.userId && event.userId !== $auth.user.id && settings.allow_message_users' @click='openContactUser = true')
v-list-item-icon
v-icon(v-text='mdiMessageTextOutline')
v-list-item-content
v-list-item-title(v-text="$t('common.contact_user')")
//- Remove //- Remove
v-list-item(v-if='!event.parentId' @click='remove(false)') v-list-item(v-if='!event.parentId' @click='remove(false)')
v-list-item-icon v-list-item-icon
@ -42,6 +34,12 @@ span
v-list-item-content v-list-item-content
v-list-item-title(v-text="$t('common.remove')") v-list-item-title(v-text="$t('common.remove')")
//- Moderation
v-list-item(v-if='settings.enable_moderation' @click='$emit("openModeration")')
v-list-item-icon
v-icon(v-text='mdiMessageTextOutline')
v-list-item-content
v-list-item-title(v-text="$t('common.moderation')")
template(v-if='event.parentId') template(v-if='event.parentId')
v-list-item.text-overline(v-html="$t('common.recurring_event_actions')") v-list-item.text-overline(v-html="$t('common.recurring_event_actions')")
@ -68,18 +66,6 @@ span
v-list-item-content v-list-item-content
v-list-item-title(v-text="$t('common.remove')") v-list-item-title(v-text="$t('common.remove')")
v-dialog(v-model='openContactUser' :fullscreen="$vuetify.breakpoint.xsOnly" width='1000px')
v-card
v-card-title {{$t('common.contact_user')}}
v-card-text
v-textarea(type='text'
@input='v => message=v'
:value='value.message'
label='Message')
br
v-card-actions.justify-space-between
v-btn(text @click='openContactUser=false' color='warning') Cancel
v-btn(text color='primary' @click='sendMessageToUser') Send
</template> </template>
<script> <script>
@ -90,8 +76,6 @@ export default {
data () { data () {
return { return {
mdiChevronUp, mdiRepeat, mdiDelete, mdiCalendarEdit, mdiEyeOff, mdiEye, mdiPause, mdiPlay, mdiDeleteForever, mdiScanner, mdiMessageTextOutline, mdiChevronUp, mdiRepeat, mdiDelete, mdiCalendarEdit, mdiEyeOff, mdiEye, mdiPause, mdiPlay, mdiDeleteForever, mdiScanner, mdiMessageTextOutline,
openContactUser: false,
message: this.value.message || ''
} }
}, },
props: { props: {
@ -99,11 +83,6 @@ export default {
type: Object, type: Object,
default: () => ({}) default: () => ({})
}, },
value: {
type: Object,
default: () => ({})
},
}, },
computed: { computed: {
...mapState(['settings']) ...mapState(['settings'])
@ -131,17 +110,6 @@ export default {
console.error(e) console.error(e)
} }
}, },
async sendMessageToUser () {
try {
await this.$axios.$post('/user/send_message', {
subject: this.event.title,
message: this.message,
userId: this.event.userId
})
} catch (e) {
console.error(e)
}
}
} }
} }
</script> </script>

View file

@ -0,0 +1,103 @@
<template>
<v-card class="eventModeration">
<v-card-title>{{$t('common.moderation')}} <v-spacer /><v-btn text label icon @click="$emit('close', false)" size='small'><v-icon v-text="mdiChevronRight"/></v-btn></v-card-title>
<v-card-text class="d-flex flex-column flex-grow-1 overflow-auto">
<v-textarea :label="$t('event.message')" :hint="$t('event.message_hint')" persistent-hint v-model='message' rows="2" class="mb-2"/>
<template v-if="$auth.user.is_admin || $auth.user.is_editor">
<v-btn class='mb-1' small outlined :disabled='!message || loading' :loading='loading' @click="sendMessage(false)" color="primary">{{$t('event.send_to_admins')}}</v-btn>
<v-btn class='mb-1' small outlined :disabled='!message || loading' :loading='loading' @click="sendMessage(true)" color="primary">{{$t('event.send_to_author_too')}}</v-btn>
</template>
<v-btn v-else small outlined :disabled='!message || loading' :loading='loading' @click="sendMessage(true)" color="primary">send</v-btn><br/>
<v-list dense class='messageList'>
<v-list-item v-for="(item, index) in messages" :key="index" class="px-2">
<v-list-item-content>
<span v-if="item?.message">{{ item.message }}</span>
<v-list-item-subtitle>{{ $time.format(item.createdAt, 'EEEE d MMMM HH:mm') }} / {{ item.author }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
<v-btn v-if='!event.isAnon' class='mb-1' small outlined :disabled='loading' :loading='loading' @click="disableAuthor" color="primary">{{$t('event.disable_author')}}</v-btn>
</v-card-text>
</v-card>
</template>
<script>
import { mdiMessageTextOutline, mdiSend, mdiChevronRight } from '@mdi/js'
import TBtn from '../components/TBtn.vue'
export default {
name: 'EventModeration',
components: { TBtn },
props: {
event: {
type: Object,
default: () => ({})
},
},
data () {
return {
mdiMessageTextOutline, mdiSend, mdiChevronRight,
message: '',
messages: [],
loading: false,
}
},
async mounted () {
this.messages = await this.$axios.$get(`/event/messages/${this.event.id}`)
},
methods: {
async disableAuthor () {
// ask confirmation only to disable
const ret = await this.$root.$confirm('admin.disable_user_confirm')
if (!ret) { return }
this.loading = true
try {
await this.$axios.$put(`/event/disable_author/${this.event.id}`)
this.$root.$message("Author disabled!", { color: 'success' })
} catch (e) {
this.$root.$message(e, { color: 'warning' })
}
this.loading = false
},
async sendMessage (is_author_visible) {
try {
this.loading = true
await this.$axios.$post(`/event/messages/${this.event.id}`, {
is_author_visible,
message: this.message,
})
this.message = ''
this.loading = false
this.messages = await this.$axios.$get(`/event/messages/${this.event.id}`)
} catch (e) {
this.$root.$message(e, { color: 'warning' })
this.loading = false
console.error(e)
}
}
}
}
</script>
<style>
.eventModeration {
display: flex;
flex-direction: column;
height: 100%;
}
.eventModeration .messageList {
overflow-y: auto;
flex-grow: 1;
padding: 0px;
margin: 8px 0;
}
.eventModeration .v-textarea {
flex-grow: 0;
}
.eventModeration .messageList .v-list-item {
border-top: 1px solid rgba(100,100,100,.3);
word-break: break-word;
}
</style>

View file

@ -60,9 +60,12 @@ v-container
inset inset
:label="$t('admin.allow_geolocation')") :label="$t('admin.allow_geolocation')")
v-switch.mt-1(v-model='allow_message_users' v-switch.mt-1(v-model='enable_moderation'
inset inset
:label="$t('admin.allow_message_users')") persistent-hint
:hint="$t('admin.enable_moderation_hint')"
:label="$t('admin.enable_moderation')")
v-dialog(v-model='showSMTP' destroy-on-close max-width='700px' :fullscreen='$vuetify.breakpoint.xsOnly') v-dialog(v-model='showSMTP' destroy-on-close max-width='700px' :fullscreen='$vuetify.breakpoint.xsOnly')
SMTP(@close='showSMTP = false') SMTP(@close='showSMTP = false')
@ -138,9 +141,9 @@ export default {
get () { return this.settings.allow_online_event }, get () { return this.settings.allow_online_event },
set (value) { this.setSetting({ key: 'allow_online_event', value }) } set (value) { this.setSetting({ key: 'allow_online_event', value }) }
}, },
allow_message_users: { enable_moderation: {
get () { return this.settings.allow_message_users }, get () { return this.settings.enable_moderation },
set (value) { this.setSetting({ key: 'allow_message_users', value }) } set (value) { this.setSetting({ key: 'enable_moderation', value }) }
}, },
filteredTimezones () { filteredTimezones () {
const current_timezone = DateTime.local().zoneName const current_timezone = DateTime.local().zoneName

View file

@ -25,5 +25,9 @@
"test": { "test": {
"subject": "Your SMTP configuration is working", "subject": "Your SMTP configuration is working",
"content": "This is a test email, if you are reading this your configuration is working." "content": "This is a test email, if you are reading this your configuration is working."
},
"report": {
"subject": "Report event [{{event.title}}] from {{author}}",
"content": "{{author}} reported the event {{event.title}} with the following message:<br/><pre>{{message}}</pre><br/><br/><a href='{{url}}'>Open moderation</a>"
} }
} }

View file

@ -112,7 +112,8 @@
"actors": "Node", "actors": "Node",
"collection_in_home": "Show a collection in home", "collection_in_home": "Show a collection in home",
"my_events": "My Events", "my_events": "My Events",
"contact_user": "Contact user" "contact_user": "Contact user",
"report": "Report event",
}, },
"login": { "login": {
"description": "By logging in you can publish new events.", "description": "By logging in you can publish new events.",
@ -199,23 +200,31 @@
"online_locations": "Online locations", "online_locations": "Online locations",
"online_locations_help": "For instance an url to a videconference room and a fallback url (max. 3)", "online_locations_help": "For instance an url to a videconference room and a fallback url (max. 3)",
"online_locations_fallback_urls": "Fallback links", "online_locations_fallback_urls": "Fallback links",
"address_geocoded_disclaimer": "If you cannot find the <strong>street address</strong> or the <strong>housenumber</strong> you are looking for in the geocoding results, you can manually insert them in the 'Address' field without loose the coordinates. Consider also that the <a target=\"_blank\" href=\"https://www.openstreetmap.org/\">OpenStreetMap</a> project is open to contributions. If you have Android, we recommend <a target=\"_blank\" href=\"https://f-droid.org/en/packages/de.westnordost.streetcomplete/\">StreetComplete</a> " "address_geocoded_disclaimer": "If you cannot find the <strong>street address</strong> or the <strong>housenumber</strong> you are looking for in the geocoding results, you can manually insert them in the 'Address' field without loose the coordinates. Consider also that the <a target=\"_blank\" href=\"https://www.openstreetmap.org/\">OpenStreetMap</a> project is open to contributions. If you have Android, we recommend <a target=\"_blank\" href=\"https://f-droid.org/en/packages/de.westnordost.streetcomplete/\">StreetComplete</a> ",
"message": "Message",
"message_hint": "You could send a message to other admins and optionally to the person who authored the event.",
"report_message_confirmation": "Do you think this event should not be here? Write to us why",
"send_to_admins": "Send to admins",
"send_to_author_too": "Send to admins and author",
"disable_author": "Disable the event author"
}, },
"admin": { "admin": {
"place_description": "If you have gotten the place or address wrong, you can change it.<br/>All current and past events associated with this place will change address.", "place_description": "If you have gotten the place or address wrong, you can change it.<br/>All current and past events associated with this place will change address.",
"event_confirm_description": "You can confirm events entered by anonymous users here", "event_confirm_description": "You can confirm events entered by anonymous users here",
"delete_user": "Remove", "delete_user": "Remove",
"remove_admin": "Remove admin", "remove_admin": "Remove admin",
"disable_user_confirm": "Are you sure you want to disable {user}?", "change_role_confirm": "Are you sure to change <strong>{user}</strong> role from <strong>{from_role}</strong> to <strong>{to_role}</strong>",
"delete_user_confirm": "Are you sure you want to remove {user}?", "disable_user_confirm": "Are you sure you want to disable <strong>{user}</strong>?",
"disable_admin_user_confirm": "Are you sure to remove admin permissions from {user}?", "delete_user_confirm": "Are you sure you want to remove <strong>{user}</strong>?",
"enable_admin_user_confirm": "Are you sure to add admin permissions to {user}?", "disable_admin_user_confirm": "Are you sure to remove admin permissions from <strong>{user}</strong>?",
"enable_admin_user_confirm": "Are you sure to add admin permissions to <strong>{user}</strong>?",
"user_remove_ok": "User removed", "user_remove_ok": "User removed",
"user_create_ok": "User created", "user_create_ok": "User created",
"event_remove_ok": "Event removed", "event_remove_ok": "Event removed",
"allow_registration_description": "Allow open registrations?", "allow_registration_description": "Allow open registrations?",
"allow_anon_event": "Allow anonymous events (has to be confirmed)?", "allow_anon_event": "Allow anonymous events (has to be confirmed)?",
"allow_message_users": "Allow admin to message users", "enable_moderation": "Enable moderation",
"enable_moderation_hint": "Anyone could report an event, admins are notified and could talk each other optionally including event's author",
"allow_multidate_event": "Allow multi-day events", "allow_multidate_event": "Allow multi-day events",
"allow_recurrent_event": "Allow recurring events", "allow_recurrent_event": "Allow recurring events",
"allow_online_event": "Allow online events", "allow_online_event": "Allow online events",

View file

@ -1,5 +1,5 @@
export default async function ({ redirect, $auth }) { export default async function ({ redirect, $auth }) {
if (!$auth.user.is_admin) { if (!$auth?.user?.is_admin) {
return redirect('/') return redirect('/')
} }
} }

View file

@ -1,5 +1,5 @@
export default async function ({ redirect, $auth }) { export default async function ({ redirect, $auth }) {
if (!$auth.user.is_editor && !$auth.user.is_admin) { if (!$auth?.user?.is_editor && !$auth?.user?.is_admin) {
return redirect('/') return redirect('/')
} }
} }

View file

@ -28,7 +28,7 @@
.font-weight-light.p-street-address(v-if='event?.place?.name !=="online"' itemprop='address') {{event?.place?.address}} .font-weight-light.p-street-address(v-if='event?.place?.name !=="online"' itemprop='address') {{event?.place?.address}}
//- a.d-block(v-if='event.ap_object?.url' :href="event.ap_object?.url") {{ event.ap_object?.url }} //- a.d-block(v-if='event.ap_object?.url' :href="event.ap_object?.url") {{ event.ap_object?.url }}
a(v-if='event?.ap_user' :href="event?.ap_user?.object?.url ?? event?.ap_user?.ap_id") @{{event.ap_user?.object?.preferredUsername}}@{{ event.ap_user?.instanceDomain }} a(v-if='event?.original_url' :href="event?.original_url") {{event.original_url}}
//- tags, hashtags //- tags, hashtags
v-container.pt-0(v-if='event?.tags?.length') v-container.pt-0(v-if='event?.tags?.length')
@ -61,13 +61,6 @@
v-list-item-content v-list-item-content
v-list-item-title(v-text="$t('common.show_map')") v-list-item-title(v-text="$t('common.show_map')")
//- embed
v-list-item(@click='showEmbed=true')
v-list-item-icon
v-icon(v-text='mdiCodeTags')
v-list-item-content
v-list-item-title(v-text="$t('common.embed')")
//- calendar //- calendar
v-list-item(:href='`/api/event/detail/${event.slug || event.id}.ics`') v-list-item(:href='`/api/event/detail/${event.slug || event.id}.ics`')
v-list-item-icon v-list-item-icon
@ -75,6 +68,13 @@
v-list-item-content v-list-item-content
v-list-item-title(v-text="$t('common.add_to_calendar')") v-list-item-title(v-text="$t('common.add_to_calendar')")
//- Report
v-list-item(v-if='settings.enable_moderation && !showModeration' @click='report')
v-list-item-icon
v-icon(v-text='mdiMessageTextOutline')
v-list-item-content
v-list-item-title(v-text="$t('common.report')")
//- download flyer //- download flyer
v-list-item(v-if='hasMedia' :href='$helper.mediaURL(event, "download")') v-list-item(v-if='hasMedia' :href='$helper.mediaURL(event, "download")')
v-list-item-icon v-list-item-icon
@ -82,11 +82,18 @@
v-list-item-content v-list-item-content
v-list-item-title(v-text="$t('event.download_flyer')") v-list-item-title(v-text="$t('event.download_flyer')")
//- embed
v-list-item(@click='showEmbed=true')
v-list-item-icon
v-icon(v-text='mdiCodeTags')
v-list-item-content
v-list-item-title(v-text="$t('common.embed')")
//- admin actions //- admin actions
template(v-if='can_edit') template(v-if='can_edit')
v-divider EventAdmin(:event='event' @openModeration='openModeration=true')
EventAdmin(:event='event')
//- resources from fediverse //- resources from fediverse
EventResource#resources.mt-3(:event='event' v-if='showResources') EventResource#resources.mt-3(:event='event' v-if='showResources')
@ -106,10 +113,12 @@
v-dialog(v-show='settings.allow_geolocation && event.place?.latitude && event.place?.longitude' v-model='mapModal' :fullscreen='$vuetify.breakpoint.xsOnly' destroy-on-close) v-dialog(v-show='settings.allow_geolocation && event.place?.latitude && event.place?.longitude' v-model='mapModal' :fullscreen='$vuetify.breakpoint.xsOnly' destroy-on-close)
EventMapDialog(:place='event.place' @close='mapModal=false') EventMapDialog(:place='event.place' @close='mapModal=false')
v-navigation-drawer(v-model='openModeration' :fullscreen='$vuetify.breakpoint.xsOnly' fixed top right width=400 temporary)
EventModeration(:event='event' v-if='openModeration' @close='openModeration=false')
</template> </template>
<script> <script>
import { mapState } from 'vuex' import { mapState } from 'vuex'
import get from 'lodash/get'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import clipboard from '../../assets/clipboard' import clipboard from '../../assets/clipboard'
import MyPicture from '~/components/MyPicture' import MyPicture from '~/components/MyPicture'
@ -117,8 +126,9 @@ import EventAdmin from '@/components/EventAdmin'
import EventResource from '@/components/EventResource' import EventResource from '@/components/EventResource'
import EmbedEvent from '@/components/embedEvent' import EmbedEvent from '@/components/embedEvent'
import EventMapDialog from '@/components/EventMapDialog' import EventMapDialog from '@/components/EventMapDialog'
import EventModeration from '@/components/EventModeration'
import { mdiArrowLeft, mdiArrowRight, mdiDotsVertical, mdiCodeTags, mdiClose, mdiMap, import { mdiArrowLeft, mdiArrowRight, mdiDotsVertical, mdiCodeTags, mdiClose, mdiMap, mdiMessageTextOutline,
mdiEye, mdiEyeOff, mdiDelete, mdiRepeat, mdiLock, mdiFileDownloadOutline, mdiShareAll, mdiEye, mdiEyeOff, mdiDelete, mdiRepeat, mdiLock, mdiFileDownloadOutline, mdiShareAll,
mdiCalendarExport, mdiCalendar, mdiContentCopy, mdiMapMarker, mdiChevronUp, mdiMonitorAccount, mdiBookmark, mdiStar } from '@mdi/js' mdiCalendarExport, mdiCalendar, mdiContentCopy, mdiMapMarker, mdiChevronUp, mdiMonitorAccount, mdiBookmark, mdiStar } from '@mdi/js'
@ -128,6 +138,7 @@ export default {
components: { components: {
EventAdmin, EventAdmin,
EventResource, EventResource,
EventModeration,
EmbedEvent, EmbedEvent,
MyPicture, MyPicture,
EventMapDialog EventMapDialog
@ -142,12 +153,13 @@ export default {
}, },
data ({$store}) { data ({$store}) {
return { return {
mdiArrowLeft, mdiArrowRight, mdiDotsVertical, mdiCodeTags, mdiCalendarExport, mdiCalendar, mdiFileDownloadOutline, mdiArrowLeft, mdiArrowRight, mdiDotsVertical, mdiCodeTags, mdiCalendarExport, mdiCalendar, mdiFileDownloadOutline, mdiMessageTextOutline,
mdiMapMarker, mdiContentCopy, mdiClose, mdiDelete, mdiEye, mdiEyeOff, mdiRepeat, mdiMap, mdiChevronUp, mdiMonitorAccount, mdiBookmark, mdiStar, mdiShareAll, mdiMapMarker, mdiContentCopy, mdiClose, mdiDelete, mdiEye, mdiEyeOff, mdiRepeat, mdiMap, mdiChevronUp, mdiMonitorAccount, mdiBookmark, mdiStar, mdiShareAll,
currentAttachment: 0, currentAttachment: 0,
event: {}, event: {},
showEmbed: false, showEmbed: false,
mapModal: false mapModal: false,
openModeration: false
} }
}, },
head () { head () {
@ -231,6 +243,9 @@ export default {
hasOnlineLocations () { hasOnlineLocations () {
return this.event.online_locations && this.event.online_locations.length return this.event.online_locations && this.event.online_locations.length
}, },
showModeration () {
return this.settings.enable_moderation && this.$auth?.user && (this.event.isMine || this.$auth?.user?.is_admin || this.$auth?.user?.is_editor)
},
showMap () { showMap () {
return this.settings.allow_geolocation && this.event.place?.latitude && this.event.place?.longitude return this.settings.allow_geolocation && this.event.place?.latitude && this.event.place?.longitude
}, },
@ -261,6 +276,19 @@ export default {
window.removeEventListener('keydown', this.keyDown) window.removeEventListener('keydown', this.keyDown)
}, },
methods: { methods: {
async report () {
const message = await this.$root.$prompt(this.$t('event.report_message_confirmation'), { title: this.$t('common.report')})
if (!message) {
return
}
try {
await this.$axios.$post(`/event/messages/${this.event.id}`, { message })
this.$root.$message('common.sent', { color: 'success' })
} catch (e) {
this.$root.$message(e, { color: 'warning' })
}
},
keyDown (ev) { keyDown (ev) {
if (ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey) { return } if (ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey) { return }
if (ev.key === 'ArrowRight' && this.event.next) { if (ev.key === 'ArrowRight' && this.event.next) {

View file

@ -93,12 +93,17 @@ export default {
Search Search
}, },
mixins: [clipboard], mixins: [clipboard],
async asyncData ({ $axios, params, store, $api, $time }) { async asyncData ({ $api, $time }) {
const events = await $api.getEvents({ try {
start: $time.currentTimestamp(), const events = await $api.getEvents({
show_recurrent: false start: $time.currentTimestamp(),
}) show_recurrent: false
return { events } })
return { events }
} catch (e) {
console.error(e)
return { events: [] }
}
}, },
data ({ $store }) { data ({ $store }) {
return { return {

View file

@ -11,7 +11,7 @@ const Col = helpers.col
const notifier = require('../../notifier') const notifier = require('../../notifier')
const { htmlToText } = require('html-to-text') const { htmlToText } = require('html-to-text')
const { Event, Resource, Tag, Place, Notification, APUser, Collection, EventNotification } = require('../models/models') const { Event, Resource, Tag, Place, Notification, APUser, EventNotification, Message, User } = require('../models/models')
const exportController = require('./export') const exportController = require('./export')
@ -121,7 +121,7 @@ const eventController = {
} }
}, },
attributes: { attributes: {
exclude: ['createdAt', 'updatedAt', 'placeId'] exclude: ['createdAt', 'updatedAt', 'placeId', 'ap_id', 'apUserApId']
}, },
include: [ include: [
{ model: Tag, required: false, attributes: ['tag'], through: { attributes: [] } }, { model: Tag, required: false, attributes: ['tag'], through: { attributes: [] } },
@ -134,7 +134,6 @@ const eventController = {
attributes: ['id', 'activitypub_id', 'data', 'hidden'] attributes: ['id', 'activitypub_id', 'data', 'hidden']
}, },
{ model: Event, required: false, as: 'parent', attributes: ['id', 'recurrent', 'is_visible', 'start_datetime'] }, { model: Event, required: false, as: 'parent', attributes: ['id', 'recurrent', 'is_visible', 'start_datetime'] },
{ model: APUser, required: false }
], ],
order: [[Resource, 'id', 'DESC']] order: [[Resource, 'id', 'DESC']]
}) })
@ -185,6 +184,9 @@ const eventController = {
if (event && (event.is_visible || is_admin)) { if (event && (event.is_visible || is_admin)) {
event = event.get() event = event.get()
event.isMine = event.userId === req.user?.id event.isMine = event.userId === req.user?.id
event.isAnon = event.userId === null
event.original_url = event?.ap_object?.url || event?.ap_object?.id
delete event.ap_object
delete event.userId delete event.userId
event.next = next && (next.slug || next.id) event.next = next && (next.slug || next.id)
event.prev = prev && (prev.slug || prev.id) event.prev = prev && (prev.slug || prev.id)
@ -206,6 +208,105 @@ const eventController = {
} }
}, },
async disableAuthor (req, res) {
const eventId = Number(req.params.event_id)
if (!res.locals.settings.enable_moderation) {
return res.sendStatus(403)
}
const event = await Event.findByPk(eventId, { include: [ User ]})
if (!event) {
return res.sendStatus(404)
}
if (event.user) {
await event.user.update({ is_active: false })
res.sendStatus(200)
} else {
res.sendStatus(404)
}
},
// get all event moderation messages if we are admin || editor
// get mine and to_author moderation messages if I'm the event author
async getMessages (req, res) {
if (!res.locals.settings.enable_moderation) {
return res.sendStatus(403)
}
const eventId = Number(req.params.event_id)
// in case we are admin or editor return all moderation messages related to this event
if (req.user.is_admin || req.user.is_editor) {
const messages = await Message.findAll({ where: { eventId }, order: [['createdAt', 'DESC']]})
return res.json(messages)
}
const event = await Event.findByPk(eventId)
if (!event) {
return res.sendStatus(404)
}
if (event.userId === req.user.id) {
const messages = await Message.findAll({ where: { eventId, is_author_visible: true }, order: [['createdAt', 'DESC']]})
return res.json(messages)
}
log.debug('userId: %s event ud %s', event, req.user.id)
return res.sendStatus(400)
},
async report (req, res) {
const mail = require('../mail')
if (!res.locals.settings.enable_moderation) {
return res.sendStatus(403)
}
const eventId = Number(req.params.event_id)
const event = await Event.findByPk(eventId)
if (!event) {
log.warn(`Trying to ... ${eventId}`)
return res.sendStatus(404)
}
const body = req.body
const isMine = req.user?.id === event.userId
const isAdminOrEditor = req.user?.is_editor || req.user?.is_admin
// if (!isAdminOrEditor && !isMine) {
// log.warn(`Someone not allowed is trying to report on an event -> "${event.title}" isMine: ${isMine} `)
// return res.sendStatus(403)
// }
// mail.send(user.email, 'message', { subject, message }, res.locals.settings.locale)
const author = isAdminOrEditor ? 'ADMIN' : isMine ? 'AUTHOR' : 'ANON'
try {
const message = await Message.create({
eventId,
message: body.message,
is_author_visible: body.is_author_visible || isMine,
author
})
const admins = await User.findAll({ where: { role: ['admin', 'editor'], is_active: true }, attributes: ['email'], raw: true })
console.error(admins)
let emails = [res.locals.settings.admin_email]
emails = emails.concat(admins.map(a => a.email))
log.info('[EVENT] Report event to %s', emails)
mail.send(emails, 'report', { event, message: body.message, author })
return res.json(message)
} catch (e) {
log.warn(`[EVENT] ${e}`)
return res.sendStatus(403)
}
},
/** confirm an anonymous event /** confirm an anonymous event
* and send related notifications * and send related notifications
*/ */

View file

@ -27,7 +27,7 @@ const defaultSettings = {
allow_multidate_event: true, allow_multidate_event: true,
allow_recurrent_event: false, allow_recurrent_event: false,
allow_online_event: true, allow_online_event: true,
allow_message_users: true, enable_moderation: true,
recurrent_event_visible: false, recurrent_event_visible: false,
allow_geolocation: false, allow_geolocation: false,
geocoding_provider_type: 'Nominatim', geocoding_provider_type: 'Nominatim',

View file

@ -9,20 +9,6 @@ const linkify = require('linkifyjs')
const userController = { const userController = {
async sendMessage (req, res) {
if (!settingsController.settings.allow_message_users) { return res.sendStatus(404) }
const subject = req.body.subject
const message = req.body.message
const user = await User.findByPk(req.body.userId)
if (!user) { return res.status(404).json({ success: false, message: 'User not found!' }) }
mail.send(user.email, 'message', { subject, message }, res.locals.settings.locale)
res.status(200).send()
},
async forgotPassword (req, res) { async forgotPassword (req, res) {
const email = req.body.email const email = req.body.email
const user = await User.findOne({ where: { email, is_active: true } }) const user = await User.findOne({ where: { email, is_active: true } })

View file

@ -81,7 +81,6 @@ module.exports = () => {
api.post('/user/recover', SPAMProtectionApiRateLimiter, userController.forgotPassword) api.post('/user/recover', SPAMProtectionApiRateLimiter, userController.forgotPassword)
api.post('/user/check_recover_code', userController.checkRecoverCode) api.post('/user/check_recover_code', userController.checkRecoverCode)
api.post('/user/recover_password', SPAMProtectionApiRateLimiter, userController.updatePasswordWithRecoverCode) api.post('/user/recover_password', SPAMProtectionApiRateLimiter, userController.updatePasswordWithRecoverCode)
api.post('/user/send_message', isAdmin, userController.sendMessage)
// register and add users // register and add users
api.post('/user/register', SPAMProtectionApiRateLimiter, userController.register) api.post('/user/register', SPAMProtectionApiRateLimiter, userController.register)
@ -169,12 +168,17 @@ module.exports = () => {
api.post('/settings/smtp', isAdmin, settingsController.testSMTP) api.post('/settings/smtp', isAdmin, settingsController.testSMTP)
api.get('/settings/smtp', isAdmin, settingsController.getSMTPSettings) api.get('/settings/smtp', isAdmin, settingsController.getSMTPSettings)
// moderation
api.post('/event/messages/:event_id', SPAMProtectionApiRateLimiter, eventController.report)
api.get('/event/messages/:event_id', isAuth, eventController.getMessages)
// get unconfirmed events // get unconfirmed events
api.get('/event/unconfirmed', isAdminOrEditor, eventController.getUnconfirmed) api.get('/event/unconfirmed', isAdminOrEditor, eventController.getUnconfirmed)
// [un]confirm event // [un]confirm event
api.put('/event/confirm/:event_id', isAuth, eventController.confirm) api.put('/event/confirm/:event_id', isAuth, eventController.confirm)
api.put('/event/unconfirm/:event_id', isAuth, eventController.unconfirm) api.put('/event/unconfirm/:event_id', isAuth, eventController.unconfirm)
api.put('/event/disable_author/:event_id', isAdminOrEditor, eventController.disableAuthor)
// get event // get event
api.get('/event/detail/:event_slug.:format?', cors, eventController.get) api.get('/event/detail/:event_slug.:format?', cors, eventController.get)

View file

@ -24,6 +24,7 @@ const models = {
Setting: require('./setting'), Setting: require('./setting'),
Tag: require('./tag'), Tag: require('./tag'),
User: require('./user'), User: require('./user'),
Message: require('./message')
} }
const db = { const db = {
@ -37,7 +38,7 @@ const db = {
}, },
associates () { associates () {
const { Filter, Collection, APUser, Instance, User, Event, EventNotification, Tag, const { Filter, Collection, APUser, Instance, User, Event, EventNotification, Tag,
OAuthCode, OAuthClient, OAuthToken, Resource, Place, Notification } = DB OAuthCode, OAuthClient, OAuthToken, Resource, Place, Notification, Message } = DB
Filter.belongsTo(Collection) Filter.belongsTo(Collection)
Collection.hasMany(Filter) Collection.hasMany(Filter)
@ -57,6 +58,9 @@ const db = {
Event.belongsTo(Place) Event.belongsTo(Place)
Place.hasMany(Event) Place.hasMany(Event)
Message.belongsTo(Event)
Event.hasMany(Message)
Event.belongsTo(User) Event.belongsTo(User)
User.hasMany(Event) User.hasMany(Event)

View file

@ -0,0 +1,27 @@
module.exports = (sequelize, DataTypes) =>
sequelize.define('message', {
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
message: {
type: DataTypes.TEXT,
allowNull: false
},
author: {
type: DataTypes.ENUM,
values: ['AUTHOR', 'ADMIN', 'ANON', 'REGISTERED']
},
is_author_visible: DataTypes.BOOLEAN, // is this message visible to the author?
})
/** Moderation
*
* - new global settings to enable/disable this feature (enabled by default)
* - every user could report an event
* - admins will receive an mail notification about the report
* - admin could reply to report (optional adding author as destination)
* - admin could always interact with event moderation (hide, confirm, remove)
* - admin could disable the author
*/

View file

@ -8,6 +8,7 @@
// Filter: require('./filter'), // Filter: require('./filter'),
// Instance: require('./instance'), // Instance: require('./instance'),
// Notification: require('./notification'), // Notification: require('./notification'),
// Message: require('./message'),
// OAuthClient: require('./oauth_client'), // OAuthClient: require('./oauth_client'),
// OAuthCode: require('./oauth_code'), // OAuthCode: require('./oauth_code'),
// OAuthToken: require('./oauth_token'), // OAuthToken: require('./oauth_token'),

View file

@ -1,3 +0,0 @@
extends ../layout.pug
block content
p #{message}

View file

@ -1 +0,0 @@
| [#{config.title}] #{subject}

View file

@ -0,0 +1,3 @@
extends ../layout.pug
block content
p !{t('report.content', {config, author, event, message, url: `${config.baseurl}/event/${event.slug || event.id}`})}

View file

@ -0,0 +1 @@
| [#{config.title}] #{t('report.subject', { config, event, author })}

View file

@ -77,7 +77,7 @@ module.exports = {
allow_registration: settings.allow_registration, allow_registration: settings.allow_registration,
allow_anon_event: settings.allow_anon_event, allow_anon_event: settings.allow_anon_event,
allow_recurrent_event: settings.allow_recurrent_event, allow_recurrent_event: settings.allow_recurrent_event,
allow_message_users: settings.allow_message_users, enable_moderation: settings.enable_moderation,
allow_multidate_event: settings.allow_multidate_event, allow_multidate_event: settings.allow_multidate_event,
allow_online_event: settings.allow_online_event, allow_online_event: settings.allow_online_event,
recurrent_event_visible: settings.recurrent_event_visible, recurrent_event_visible: settings.recurrent_event_visible,

View file

@ -0,0 +1,44 @@
'use strict';
module.exports = {
async up (queryInterface, Sequelize) {
return queryInterface.createTable('messages',
{
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true
},
message: {
type: Sequelize.TEXT,
allowNull: false
},
eventId: {
type: Sequelize.INTEGER,
references: {
model: 'events',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL'
},
author: {
type: Sequelize.ENUM,
values: ['AUTHOR', 'ADMIN', 'ANON', 'REGISTERED']
},
is_author_visible: Sequelize.BOOLEAN,
createdAt: {
allowNull: false,
type: Sequelize.DATE
},
updatedAt: {
allowNull: false,
type: Sequelize.DATE
}
})
},
async down (queryInterface, Sequelize) {
return queryInterface.dropTable('messages')
}
};

View file

@ -8,7 +8,7 @@ export const state = () => ({
instance_name: '', instance_name: '',
allow_registration: true, allow_registration: true,
allow_anon_event: true, allow_anon_event: true,
allow_message_users: false, enable_moderation: true,
allow_multidate_event: true, allow_multidate_event: true,
allow_recurrent_event: true, allow_recurrent_event: true,
allow_online_event: true, allow_online_event: true,