Merge branch 'analytics' into 'main'

Provide analytics on Front-end

Closes #690

See merge request framasoft/mobilizon!1200
This commit is contained in:
Thomas Citharel 2022-04-06 18:31:49 +00:00
commit ca6db74a73
43 changed files with 1017 additions and 390 deletions

View File

@ -110,7 +110,7 @@ deps:
exunit:
stage: test
services:
- name: postgis/postgis:13-3.1
- name: postgis/postgis:14-3.2
alias: postgres
variables:
MIX_ENV: test

View File

@ -345,6 +345,8 @@ config :mobilizon, :exports,
Mobilizon.Service.Export.Participants.CSV
]
config :mobilizon, :analytics, providers: []
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"

View File

@ -1,15 +1,11 @@
FROM elixir:latest
LABEL maintainer="Thomas Citharel <tcit@tcit.fr>"
ENV REFRESHED_AT=2021-12-15
ENV REFRESHED_AT=2022-04-06
RUN apt-get update -yq && apt-get install -yq build-essential inotify-tools postgresql-client git curl gnupg xvfb libgtk-3-dev libnotify-dev libgconf-2-4 libnss3 libxss1 libasound2 cmake exiftool python3-pip python3-setuptools
RUN curl -sL https://deb.nodesource.com/setup_16.x | bash && apt-get install nodejs -yq
RUN npm install -g yarn wait-on
RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN mix local.hex --force && mix local.rebar --force
# Weasyprint 53 requires pango >= 1.44.0, which is not available in Stretch.
# TODO: Remove the version requirement when elixir:latest is based on Bullseye
# https://github.com/erlang/docker-erlang-otp/issues/362
# https://github.com/Kozea/WeasyPrint/issues/1384
RUN pip3 install -Iv weasyprint==52 pyexcel_ods3
RUN pip3 install -Iv weasyprint pyexcel_ods3
RUN curl https://dbip.mirror.framasoft.org/files/dbip-city-lite-latest.mmdb --output GeoLite2-City.mmdb -s && mkdir -p /usr/share/GeoIP && mv GeoLite2-City.mmdb /usr/share/GeoIP/

View File

@ -8,7 +8,7 @@
"test:unit": "LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8 TZ=UTC vue-cli-service test:unit",
"test:e2e": "vue-cli-service test:e2e",
"lint": "vue-cli-service lint",
"build:assets": "vue-cli-service build",
"build:assets": "vue-cli-service build --report",
"build:pictures": "bash ./scripts/build/pictures.sh"
},
"dependencies": {
@ -16,6 +16,8 @@
"@absinthe/socket-apollo-link": "^0.2.1",
"@apollo/client": "^3.3.16",
"@mdi/font": "^6.1.95",
"@sentry/tracing": "^6.16.1",
"@sentry/vue": "^6.16.1",
"@tailwindcss/line-clamp": "^0.3.0",
"@tiptap/core": "^2.0.0-beta.41",
"@tiptap/extension-blockquote": "^2.0.0-beta.25",
@ -69,7 +71,9 @@
"vue": "^2.6.11",
"vue-class-component": "^7.2.3",
"vue-i18n": "^8.14.0",
"vue-matomo": "^4.1.0",
"vue-meta": "^2.3.1",
"vue-plausible": "^1.3.1",
"vue-property-decorator": "^9.0.0",
"vue-router": "^3.1.6",
"vue-scrollto": "^2.17.1",

View File

@ -203,6 +203,16 @@ export default class App extends Vue {
this.interval = undefined;
}
@Watch("config")
async initializeStatistics(config: IConfig) {
if (config) {
const { statistics } = (await import("./services/statistics")) as {
statistics: (config: IConfig, environment: Record<string, any>) => void;
};
statistics(config, { router: this.$router, version: config.version });
}
}
@Watch("$route", { immediate: true })
updateAnnouncement(route: Route): void {
const pageTitle = this.extractPageTitleFromRoute(route);

View File

@ -1,7 +1,7 @@
<template>
<div
class="w-80 bg-white rounded-lg shadow-md flex space-x-4 items-center"
:class="{ 'flex-col p-4 sm:p-8 pb-10': !inline }"
class="bg-white rounded-lg flex space-x-4 items-center"
:class="{ 'flex-col p-4 shadow-md sm:p-8 pb-10 w-80': !inline }"
>
<div>
<figure class="w-12 h-12" v-if="actor.avatar">
@ -20,8 +20,10 @@
class="ltr:-mr-0.5 rtl:-ml-0.5"
/>
</div>
<div :class="{ 'text-center': !inline }">
<h5 class="text-xl font-medium violet-title tracking-tight text-gray-900">
<div :class="{ 'text-center': !inline }" class="overflow-hidden w-full">
<h5
class="text-xl font-medium violet-title tracking-tight text-gray-900 whitespace-pre-line line-clamp-2"
>
{{ displayName(actor) }}
</h5>
<p class="text-gray-500 truncate" v-if="actor.name">

View File

@ -1,6 +1,6 @@
<template>
<div
class="ellipsis"
class="truncate"
:title="
isDescriptionDifferentFromLocality
? `${physicalAddress.description}, ${physicalAddress.locality}`
@ -8,8 +8,7 @@
"
>
<b-icon icon="map-marker" />
<span v-if="isDescriptionDifferentFromLocality">
{{ physicalAddress.description }},
<span v-if="physicalAddress.locality">
{{ physicalAddress.locality }}
</span>
<span v-else>
@ -35,11 +34,3 @@ export default class InlineAddress extends Vue {
}
}
</script>
<style lang="scss" scoped>
.ellipsis {
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@ -48,13 +48,75 @@
$t("Mobilizon")
}}</a>
</i18n>
{{
$t(
"We improve this software thanks to your feedback. To let us know about this issue, two possibilities (both unfortunately require user account creation):"
)
}}
<span v-if="sentryEnabled && sentryReady">
{{
$t(
"We collect your feedback and the error information in order to improve this service."
)
}}</span
>
<span v-else>
{{
$t(
"We improve this software thanks to your feedback. To let us know about this issue, two possibilities (both unfortunately require user account creation):"
)
}}
</span>
</p>
<div class="content">
<form
v-if="sentryEnabled && sentryReady && !submittedFeedback"
@submit.prevent="sendErrorToSentry"
>
<b-field :label="$t('What happened?')" label-for="what-happened">
<b-input
v-model="feedback"
type="textarea"
id="what-happened"
:placeholder="$t(`I've clicked on X, then on Y`)"
/>
</b-field>
<b-button icon-left="send" native-type="submit" type="is-primary">{{
$t("Send feedback")
}}</b-button>
<p class="content">
{{
$t(
"Please add as many details as possible to help identify the problem."
)
}}
</p>
</form>
<b-message type="is-danger" v-else-if="feedbackError">
<p>
{{
$t(
"Sorry, we wen't able to save your feedback. Don't worry, we'll try to fix this issue anyway."
)
}}
</p>
<i18n path="You may now close this page or {return_to_the_homepage}.">
<template #return_to_the_homepage>
<router-link :to="{ name: RouteName.HOME }">{{
$t("return to the homepage")
}}</router-link>
</template>
</i18n>
</b-message>
<b-message type="is-success" v-else-if="submittedFeedback">
<p>{{ $t("Thanks a lot, your feedback was submitted!") }}</p>
<i18n path="You may now close this page or {return_to_the_homepage}.">
<template #return_to_the_homepage>
<router-link :to="{ name: RouteName.HOME }">{{
$t("return to the homepage")
}}</router-link>
</template>
</i18n>
</b-message>
<div
class="content"
v-if="!(sentryEnabled && sentryReady) || submittedFeedback"
>
<p v-if="submittedFeedback">{{ $t("You may also:") }}</p>
<ul>
<li>
<a
@ -65,7 +127,7 @@
</li>
<li>
<a
href="https://framagit.org/framasoft/mobilizon/-/issues/new?issuable_template=Bug"
href="https://framagit.org/framasoft/mobilizon/-/issues/"
target="_blank"
>{{
$t("Open an issue on our bug tracker (advanced users)")
@ -74,7 +136,7 @@
</li>
</ul>
</div>
<p class="content">
<p class="content" v-if="!sentryEnabled">
{{
$t(
"Please add as many details as possible to help identify the problem."
@ -89,14 +151,14 @@
<p>{{ $t("Error stacktrace") }}</p>
<pre>{{ error.stack }}</pre>
</details>
<p>
<p v-if="!sentryEnabled">
{{
$t(
"The technical details of the error can help developers solve the problem more easily. Please add them to your feedback."
)
}}
</p>
<div class="buttons">
<div class="buttons" v-if="!sentryEnabled">
<b-tooltip
:label="tooltipConfig.label"
:type="tooltipConfig.type"
@ -115,14 +177,20 @@
</div>
</template>
<script lang="ts">
import { CONTACT } from "@/graphql/config";
import { CONFIG } from "@/graphql/config";
import { checkProviderConfig, convertConfig } from "@/services/statistics";
import { IAnalyticsConfig, IConfig } from "@/types/config.model";
import { Component, Prop, Vue } from "vue-property-decorator";
import { LOGGED_USER } from "@/graphql/user";
import { IUser } from "@/types/current-user.model";
import { ISentryConfiguration } from "@/types/analytics/sentry.model";
import { submitFeedback } from "@/services/statistics/sentry";
import RouteName from "@/router/name";
@Component({
apollo: {
config: {
query: CONTACT,
},
config: CONFIG,
loggedUser: LOGGED_USER,
},
metaInfo() {
return {
@ -138,7 +206,17 @@ export default class ErrorComponent extends Vue {
copied: "success" | "error" | false = false;
config!: { contact: string | null; name: string };
config!: IConfig;
feedback = "";
submittedFeedback = false;
feedbackError = false;
loggedUser!: IUser;
RouteName = RouteName;
async copyErrorToClipboard(): Promise<void> {
try {
@ -193,6 +271,56 @@ export default class ErrorComponent extends Vue {
document.body.removeChild(textArea);
}
get sentryEnabled(): boolean {
return this.sentryProvider?.enabled === true;
}
get sentryProvider(): IAnalyticsConfig | undefined {
return this.config && checkProviderConfig(this.config, "sentry");
}
get sentryConfig(): ISentryConfiguration | undefined {
if (this.sentryProvider?.configuration) {
return convertConfig(
this.sentryProvider?.configuration
) as ISentryConfiguration;
}
return undefined;
}
get sentryReady() {
const eventId = window.sessionStorage.getItem("lastEventId");
const dsn = this.sentryConfig?.dsn;
const organization = this.sentryConfig?.organization;
const project = this.sentryConfig?.project;
const host = this.sentryConfig?.host;
return eventId && dsn && organization && project && host;
}
async sendErrorToSentry() {
try {
const eventId = window.sessionStorage.getItem("lastEventId");
const dsn = this.sentryConfig?.dsn;
const organization = this.sentryConfig?.organization;
const project = this.sentryConfig?.project;
const host = this.sentryConfig?.host;
const endpoint = `https://${host}/api/0/projects/${organization}/${project}/user-feedback/`;
if (eventId && dsn && this.sentryReady) {
await submitFeedback(endpoint, dsn, {
event_id: eventId,
name:
this.loggedUser?.defaultActor?.preferredUsername || "Unknown user",
email: this.loggedUser?.email || "unknown@email.org",
comments: this.feedback,
});
this.submittedFeedback = true;
}
} catch (error) {
console.error(error);
this.feedbackError = true;
}
}
}
</script>
<style lang="scss" scoped>

View File

@ -67,7 +67,6 @@
<inline-address
dir="auto"
v-if="event.physicalAddress"
class="event-subtitle"
:physical-address="event.physicalAddress"
/>
<div

View File

@ -36,6 +36,7 @@
>
<router-link
v-if="event.attributedTo"
class="hover:underline"
:to="{
name: RouteName.GROUP,
params: {
@ -53,6 +54,7 @@
</router-link>
<actor-card v-else :actor="event.organizerActor" :inline="true" />
<actor-card
:inline="true"
:actor="contact"
v-for="contact in event.contacts"
:key="contact.id"
@ -65,6 +67,7 @@
>
<a
target="_blank"
class="hover:underline"
rel="noopener noreferrer ugc"
:href="event.onlineAddress"
:title="

View File

@ -1,6 +1,6 @@
<template>
<router-link
class="event-minimalist-card-wrapper"
class="event-minimalist-card-wrapper bg-white rounded-lg shadow-md"
dir="auto"
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
>

View File

@ -1,5 +1,9 @@
<template>
<div class="empty-content" :class="{ inline }" role="note">
<div
class="empty-content"
:class="{ inline, 'text-center': center }"
role="note"
>
<b-icon :icon="icon" size="is-large" />
<h2 class="empty-content__title">
<!-- @slot Mandatory title -->
@ -18,6 +22,7 @@ import { Component, Prop, Vue } from "vue-property-decorator";
export default class EmptyContent extends Vue {
@Prop({ type: String, required: true }) icon!: string;
@Prop({ type: Boolean, required: false, default: false }) inline!: boolean;
@Prop({ type: Boolean, required: false, default: false }) center!: boolean;
}
</script>

View File

@ -96,6 +96,15 @@ export const CONFIG = gql`
enabled
publicKey
}
analytics {
id
enabled
configuration {
key
value
type
}
}
}
}
`;

View File

@ -1309,5 +1309,17 @@
"Reset filters": "Reset filters",
"Category": "Category",
"Select a category": "Select a category",
"Any category": "Any category"
}
"Any category": "Any category",
"We collect your feedback and the error information in order to improve this service.": "We collect your feedback and the error information in order to improve this service.",
"What happened?": "What happened?",
"I've clicked on X, then on Y": "I've clicked on X, then on Y",
"Send feedback": "Send feedback",
"Sorry, we wen't able to save your feedback. Don't worry, we'll try to fix this issue anyway.": "Sorry, we wen't able to save your feedback. Don't worry, we'll try to fix this issue anyway.",
"return to the homepage": "return to the homepage",
"Thanks a lot, your feedback was submitted!": "Thanks a lot, your feedback was submitted!",
"You may also:": "You may also:",
"You may now close this page or {return_to_the_homepage}.": "You may now close this page or {return_to_the_homepage}.",
"This group is a remote group, it's possible the original instance has more informations.": "This group is a remote group, it's possible the original instance has more informations.",
"View the group profile on the original instance": "View the group profile on the original instance",
"View past events": "View past events"
}

View File

@ -1309,5 +1309,8 @@
"Category": "Catégorie",
"Select a category": "Choisissez une categorie",
"Any category": "N'importe quelle catégorie",
"No instance found.": "Aucune instance trouvée."
"No instance found.": "Aucune instance trouvée.",
"This group is a remote group, it's possible the original instance has more informations.": "Ce groupe est un groupe distant, il est possible que l'instance d'origine ait plus d'informations.",
"View the group profile on the original instance": "Afficher le profil du groupe sur l'instance d'origine",
"View past events": "Voir les événements passés"
}

View File

@ -0,0 +1,50 @@
import {
IAnalyticsConfig,
IConfig,
IKeyValueConfig,
} from "@/types/config.model";
export const statistics = async (config: IConfig, environement: any) => {
console.debug("Loading statistics", config.analytics);
const matomoConfig = checkProviderConfig(config, "matomo");
if (matomoConfig?.enabled === true) {
const { matomo } = (await import("./matomo")) as any;
matomo(environement, convertConfig(matomoConfig.configuration));
}
const sentryConfig = checkProviderConfig(config, "sentry");
if (sentryConfig?.enabled === true) {
const { sentry } = (await import("./sentry")) as any;
sentry(environement, convertConfig(sentryConfig.configuration));
}
};
export const checkProviderConfig = (
config: IConfig,
providerName: string
): IAnalyticsConfig | undefined => {
return config?.analytics?.find((provider) => provider.id === providerName);
};
export const convertConfig = (
configs: IKeyValueConfig[]
): Record<string, any> => {
return configs.reduce((acc, config) => {
acc[config.key] = toType(config.value, config.type);
return acc;
}, {} as Record<string, any>);
};
const toType = (value: string, type: string): string | number | boolean => {
switch (type) {
case "boolean":
return value === "true";
case "integer":
return parseInt(value, 10);
case "float":
return parseFloat(value);
case "string":
default:
return value;
}
};

View File

@ -0,0 +1,14 @@
import Vue from "vue";
import VueMatomo from "vue-matomo";
export const matomo = (environment: any, matomoConfiguration: any) => {
console.debug("Loading Matomo statistics");
console.debug(
"Calling VueMatomo with the following configuration",
matomoConfiguration
);
Vue.use(VueMatomo, {
...matomoConfiguration,
router: environment.router,
});
};

View File

@ -0,0 +1,11 @@
import VueRouter from "vue-router";
import Vue from "vue";
import { VuePlausible } from "vue-plausible";
export default (router: VueRouter, plausibleConfiguration: any) => {
console.debug("Loading Plausible statistics");
Vue.use(VuePlausible, {
// see configuration section
...plausibleConfiguration,
});
};

View File

@ -0,0 +1,54 @@
import Vue from "vue";
import * as Sentry from "@sentry/vue";
import { Integrations } from "@sentry/tracing";
export const sentry = (environment: any, sentryConfiguration: any) => {
console.debug("Loading Sentry statistics");
console.debug(
"Calling Sentry with the following configuration",
sentryConfiguration
);
// Don't attach errors to previous events
window.sessionStorage.removeItem("lastEventId");
Sentry.init({
Vue,
dsn: sentryConfiguration.dsn,
integrations: [
new Integrations.BrowserTracing({
routingInstrumentation: Sentry.vueRouterInstrumentation(
environment.router
),
tracingOrigins: ["localhost", "mobilizon1.com", /^\//],
}),
],
beforeSend(event) {
// Check if it is an exception, and if so, save it in session storage
// so that it can be retreived from the error component
if (event.exception && event.event_id) {
window.sessionStorage.setItem("lastEventId", event.event_id);
}
return event;
},
// Set tracesSampleRate to 1.0 to capture 100%
// of transactions for performance monitoring.
// We recommend adjusting this value in production
tracesSampleRate: sentryConfiguration.tracesSampleRate,
release: environment.version,
});
};
export const submitFeedback = async (
endpoint: string,
dsn: string,
params: Record<string, string>
): Promise<void> => {
await fetch(endpoint, {
method: "POST",
headers: {
"Content-type": "application/json",
Authorization: `DSN ${dsn}`,
},
body: JSON.stringify(params),
});
};

View File

@ -0,0 +1,7 @@
export interface ISentryConfiguration {
dsn: string;
organization?: string;
project?: string;
host?: string;
tracesSampleRate: number;
}

View File

@ -6,6 +6,18 @@ export interface IOAuthProvider {
label: string;
}
export interface IKeyValueConfig {
key: string;
value: string;
type: "boolean" | "integer" | "string";
}
export interface IAnalyticsConfig {
id: string;
enabled: boolean;
configuration: IKeyValueConfig[];
}
export interface IConfig {
name: string;
description: string;
@ -110,4 +122,5 @@ export interface IConfig {
exportFormats: {
eventParticipants: string[];
};
analytics: IAnalyticsConfig[];
}

1
js/src/typings/matomo.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module "vue-matomo";

View File

@ -43,21 +43,40 @@
<subtitle>
{{ showPassedEvents ? $t("Past events") : $t("Upcoming events") }}
</subtitle>
<b-switch v-model="showPassedEvents">{{ $t("Past events") }}</b-switch>
<b-switch class="mb-4" v-model="showPassedEvents">{{
$t("Past events")
}}</b-switch>
<grouped-multi-event-minimalist-card
:events="group.organizedEvents.elements"
:isCurrentActorMember="isCurrentActorMember"
/>
<b-message
<empty-content
v-if="
group.organizedEvents.elements.length === 0 &&
$apollo.loading === false
"
type="is-danger"
icon="calendar"
:inline="true"
:center="true"
>
{{ $t("No events found") }}
</b-message>
<template v-if="group.domain !== null">
<div class="mt-4">
<p>
{{
$t(
"This group is a remote group, it's possible the original instance has more informations."
)
}}
</p>
<b-button type="is-text" tag="a" :href="group.url">
{{ $t("View the group profile on the original instance") }}
</b-button>
</div>
</template>
</empty-content>
<b-pagination
class="mt-4"
:total="group.organizedEvents.total"
v-model="eventsPage"
:per-page="EVENTS_PAGE_LIMIT"
@ -81,6 +100,7 @@ import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
import GroupMixin from "@/mixins/group";
import { IMember } from "@/types/actor/member.model";
import { FETCH_GROUP_EVENTS } from "@/graphql/event";
import EmptyContent from "../../components/Utils/EmptyContent.vue";
import { displayName, usernameWithDomain } from "../../types/actor";
const EVENTS_PAGE_LIMIT = 10;
@ -114,6 +134,7 @@ const EVENTS_PAGE_LIMIT = 10;
},
},
components: {
EmptyContent,
Subtitle,
GroupedMultiEventMinimalistCard,
},

View File

@ -579,29 +579,47 @@
</div>
<empty-content v-else-if="group" icon="calendar" :inline="true">
{{ $t("No public upcoming events") }}
<template #desc v-if="isCurrentActorFollowing">
<i18n
class="has-text-grey-dark"
path="You will receive notifications about this group's public activity depending on %{notification_settings}."
>
<router-link
:to="{ name: RouteName.NOTIFICATIONS }"
slot="notification_settings"
>{{ $t("your notification settings") }}</router-link
<template #desc>
<template v-if="isCurrentActorFollowing">
<i18n
class="has-text-grey-dark"
path="You will receive notifications about this group's public activity depending on %{notification_settings}."
>
</i18n>
<router-link
:to="{ name: RouteName.NOTIFICATIONS }"
slot="notification_settings"
>{{ $t("your notification settings") }}</router-link
>
</i18n>
</template>
<b-button
tag="router-link"
class="my-2"
type="is-text"
:to="{
name: RouteName.GROUP_EVENTS,
params: { preferredUsername: usernameWithDomain(group) },
query: { future: false },
}"
>{{ $t("View past events") }}</b-button
>
</template>
</empty-content>
<b-skeleton animated v-else-if="$apollo.loading"></b-skeleton>
<router-link
v-if="organizedEvents.total > 0"
:to="{
name: RouteName.GROUP_EVENTS,
params: { preferredUsername: usernameWithDomain(group) },
query: { future: organizedEvents.elements.length > 0 },
}"
>{{ $t("View all events") }}</router-link
>
<div class="flex justify-center">
<b-button
tag="router-link"
class="my-4"
type="is-text"
v-if="organizedEvents.total > 0"
:to="{
name: RouteName.GROUP_EVENTS,
params: { preferredUsername: usernameWithDomain(group) },
query: { future: organizedEvents.elements.length > 0 },
}"
>{{ $t("View all events") }}</b-button
>
</div>
</section>
<section>
<subtitle>{{ $t("Latest posts") }}</subtitle>

View File

@ -123,7 +123,7 @@
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { Component, Prop, Vue } from "vue-property-decorator";
import { Route } from "vue-router";
import { ICurrentUser } from "@/types/current-user.model";
import { LoginError, LoginErrorCode } from "@/types/enums";
@ -207,6 +207,11 @@ export default class Login extends Vue {
const { query } = this.$route;
this.errorCode = query.code as LoginErrorCode;
this.redirect = query.redirect as string | undefined;
// Already-logged-in and accessing /login
if (this.currentUser.isLoggedIn) {
this.$router.push("/");
}
}
async loginAction(e: Event): Promise<Route | void> {
@ -240,7 +245,7 @@ export default class Login extends Vue {
if (window.localStorage) {
window.localStorage.setItem("welcome-back", "yes");
}
this.$router.push({ name: RouteName.HOME });
this.$router.replace({ name: RouteName.HOME });
return;
} catch (err: any) {
this.submitted = false;
@ -279,13 +284,6 @@ export default class Login extends Vue {
}
}
@Watch("currentUser")
redirectToHomepageIfAlreadyLoggedIn(): Promise<Route> | void {
if (this.currentUser.isLoggedIn) {
return this.$router.push("/");
}
}
get hasCaseWarning(): boolean {
return this.credentials.email !== this.credentials.email.toLowerCase();
}

View File

@ -13,6 +13,7 @@ export const defaultResolvers = {
id: "67",
preferredUsername: "someone",
name: "Personne",
avatar: null,
__typename: "CurrentActor",
}),
},

View File

@ -22,7 +22,7 @@ import { InMemoryCache } from "@apollo/client/cache";
const localVue = createLocalVue();
localVue.use(Buefy);
config.mocks.$t = (key: string): string => key;
const $router = { push: jest.fn() };
const $router = { push: jest.fn(), replace: jest.fn() };
describe("Render login form", () => {
let wrapper: Wrapper<Vue>;
@ -125,9 +125,9 @@ describe("Render login form", () => {
await flushPromises();
expect(currentUser?.email).toBe("some@email.tld");
expect(currentUser?.id).toBe("1");
expect(jest.isMockFunction(wrapper.vm.$router.push)).toBe(true);
expect(jest.isMockFunction(wrapper.vm.$router.replace)).toBe(true);
await flushPromises();
expect($router.push).toHaveBeenCalledWith({ name: RouteName.HOME });
expect($router.replace).toHaveBeenCalledWith({ name: RouteName.HOME });
});
it("handles a login error", async () => {

View File

@ -123,6 +123,8 @@ export const configMock = {
enabled: true,
publicKey: "",
},
eventCategories: [],
analytics: [],
},
},
};

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
alias Mobilizon.Actors.Actor
alias Mobilizon.Addresses.Address
alias Mobilizon.Events.Categories
alias Mobilizon.Events.Event, as: EventModel
alias Mobilizon.Medias.Media
@ -73,7 +74,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
medias: medias,
begins_on: object["startTime"],
ends_on: object["endTime"],
category: object["category"],
category: get_category(object["category"]),
visibility: visibility,
join_options: Map.get(object, "joinMode", "free"),
local: is_local?(object["id"]),
@ -330,4 +331,15 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
_participant_count
),
do: nil
@spec get_category(String.t() | nil) :: String.t()
defp get_category(nil), do: "MEETING"
defp get_category(category) when is_binary(category) do
if category in Enum.map(Categories.list(), &String.upcase(to_string(&1.id))) do
category
else
get_category(nil)
end
end
end

View File

@ -5,6 +5,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
alias Mobilizon.Config
alias Mobilizon.Events.Categories
alias Mobilizon.Service.FrontEndAnalytics
@doc """
Gets config.
@ -170,7 +171,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
public_key:
get_in(Application.get_env(:web_push_encryption, :vapid_details), [:public_key])
},
export_formats: Config.instance_export_formats()
export_formats: Config.instance_export_formats(),
analytics: FrontEndAnalytics.config()
}
end
end

View File

@ -75,6 +75,10 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
field(:web_push, :web_push, description: "Web Push settings for the instance")
field(:export_formats, :export_formats, description: "The instance list of export formats")
field(:analytics, list_of(:analytics),
description: "Configuration for diverse analytics services"
)
end
@desc """
@ -330,6 +334,28 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
field(:public_key, :string, description: "The server's public WebPush VAPID key")
end
object :analytics do
field(:id, :string, description: "ID of the analytics service")
field(:enabled, :boolean, description: "Whether the service is activated or not")
field(:configuration, list_of(:analytics_configuration),
description: "A list of key-values configuration"
)
end
enum :analytics_configuration_type do
value(:string, description: "A string")
value(:integer, description: "An integer")
value(:boolean, description: "A boolean")
value(:float, description: "A float")
end
object :analytics_configuration do
field(:key, :string, description: "The key for the analytics configuration element")
field(:value, :string, description: "The value for the analytics configuration element")
field(:type, :analytics_configuration_type, description: "The analytics configuration type")
end
@desc """
Export formats configuration
"""

View File

@ -0,0 +1,71 @@
defmodule Mobilizon.Service.FrontEndAnalytics do
@moduledoc """
Behaviour for any analytics service
"""
@callback id() :: String.t()
@doc """
Whether the service is enabled
"""
@callback enabled?() :: boolean()
@doc """
The configuration for the service
"""
@callback configuration() :: keyword()
@doc """
The CSP configuration to add for the service to work
"""
@callback csp() :: keyword()
@spec providers :: list(module())
def providers do
:mobilizon
|> Application.get_env(:analytics, [])
|> Keyword.get(:providers, [])
end
@spec config :: map()
def config do
Enum.reduce(providers(), [], &load_config/2)
end
@spec csp :: keyword()
def csp do
providers()
|> Enum.map(& &1.csp())
|> Enum.reduce([], &merge_csp_config/2)
end
@spec load_config(module(), list(map())) :: list(map())
defp load_config(provider, acc) do
acc ++
[
%{
id: provider.id(),
enabled: provider.enabled?(),
configuration: convert_config(provider.configuration())
}
]
end
@spec convert_config(Keyword.t()) :: list(map())
defp convert_config(config) do
Enum.reduce(config, [], fn {key, val}, acc ->
acc ++ [%{key: key, value: val, type: type(val)}]
end)
end
defp type(val) when is_integer(val), do: :integer
defp type(val) when is_float(val), do: :float
defp type(val) when is_boolean(val), do: :boolean
defp type(val) when is_binary(val), do: :string
defp merge_csp_config(config, global_config) do
Keyword.merge(global_config, config, fn _key, global, config ->
"#{global} #{config}"
end)
end
end

View File

@ -0,0 +1,40 @@
defmodule Mobilizon.Service.FrontEndAnalytics.Matomo do
@moduledoc """
Matomo analytics provider
"""
alias Mobilizon.Service.FrontEndAnalytics
@behaviour FrontEndAnalytics
@impl FrontEndAnalytics
def id, do: "matomo"
@doc """
Whether the service is enabled
"""
@impl FrontEndAnalytics
def enabled? do
:mobilizon
|> Application.get_env(__MODULE__, [])
|> Keyword.get(:enabled, false)
end
@doc """
The configuration for the service
"""
@impl FrontEndAnalytics
def configuration do
:mobilizon
|> Application.get_env(__MODULE__, [])
|> Keyword.drop([:enabled, :csp])
end
@doc """
The CSP configuration to add for the service to work
"""
@impl FrontEndAnalytics
def csp do
:mobilizon
|> Application.get_env(__MODULE__, [])
|> Keyword.get(:csp, [])
end
end

View File

@ -0,0 +1,41 @@
defmodule Mobilizon.Service.FrontEndAnalytics.Plausible do
@moduledoc """
Plausible analytics provider
"""
alias Mobilizon.Service.FrontEndAnalytics
@behaviour FrontEndAnalytics
@impl FrontEndAnalytics
def id, do: "plausible"
@doc """
Whether the service is enabled
"""
@impl FrontEndAnalytics
def enabled? do
:mobilizon
|> Application.get_env(__MODULE__, [])
|> Keyword.get(:enabled, false)
end
@doc """
The configuration for the service
"""
@impl FrontEndAnalytics
def configuration do
:mobilizon
|> Application.get_env(__MODULE__, [])
|> Keyword.drop([:enabled, :csp])
end
@doc """
The CSP configuration to add for the service to work
"""
@impl FrontEndAnalytics
def csp do
:mobilizon
|> Application.get_env(__MODULE__, [])
|> Keyword.get(:csp, [])
end
end

View File

@ -0,0 +1,41 @@
defmodule Mobilizon.Service.FrontEndAnalytics.Sentry do
@moduledoc """
Sentry analytics provider
"""
alias Mobilizon.Service.FrontEndAnalytics
@behaviour FrontEndAnalytics
@impl FrontEndAnalytics
def id, do: "sentry"
@doc """
Whether the service is enabled
"""
@impl FrontEndAnalytics
def enabled? do
:mobilizon
|> Application.get_env(__MODULE__, [])
|> Keyword.get(:enabled, false)
end
@doc """
The configuration for the service
"""
@impl FrontEndAnalytics
def configuration do
:mobilizon
|> Application.get_env(__MODULE__, [])
|> Keyword.drop([:enabled, :csp])
end
@doc """
The CSP configuration to add for the service to work
"""
@impl FrontEndAnalytics
def csp do
:mobilizon
|> Application.get_env(__MODULE__, [])
|> Keyword.get(:csp, [])
end
end

View File

@ -29,9 +29,12 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
@spec do_get_actor(String.t()) :: {:commit, Actor.t()} | {:ignore, nil}
defp do_get_actor("actor_" <> name) do
case Actor.find_or_make_actor_from_nickname(name) do
{:ok, %ActorModel{} = actor} ->
{:ok, %ActorModel{suspended: false} = actor} ->
{:commit, actor}
{:ok, %ActorModel{}} ->
{:ignore, nil}
{:error, _err} ->
{:ignore, nil}
end
@ -45,9 +48,12 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
def get_local_actor_by_name(name) do
Cachex.fetch(@cache, "local_actor_" <> name, fn "local_actor_" <> name ->
case Actors.get_local_actor_by_name(name) do
%ActorModel{} = actor ->
%ActorModel{suspended: false} = actor ->
{:commit, actor}
{:ok, %ActorModel{}} ->
{:ignore, nil}
nil ->
{:ignore, nil}
end

View File

@ -9,6 +9,7 @@ defmodule Mobilizon.Web.Plugs.HTTPSecurityPlug do
"""
alias Mobilizon.Config
alias Mobilizon.Service.FrontEndAnalytics
import Plug.Conn
require Logger
@ -136,8 +137,9 @@ defmodule Mobilizon.Web.Plugs.HTTPSecurityPlug do
@spec get_csp_config(atom(), Keyword.t()) :: iodata()
defp get_csp_config(type, options) do
options
|> Keyword.get(type, Config.get([:http_security, :csp_policy, type]))
|> Enum.join(" ")
config_policy = Keyword.get(options, type, Config.get([:http_security, :csp_policy, type]))
front_end_analytics_policy = [Keyword.get(FrontEndAnalytics.csp(), type, [])]
Enum.join(config_policy ++ front_end_analytics_policy, " ")
end
end

View File

@ -168,7 +168,7 @@ defmodule Mobilizon.Mixfile do
{:mogrify, "~> 0.9"},
{:linkify, "~> 0.3"},
{:http_signatures, "~> 0.1.0"},
{:ex_cldr, "~> 2.0"},
{:ex_cldr, "2.27.1"},
{:ex_cldr_dates_times, "~> 2.2"},
{:ex_optimizer, "~> 0.1"},
{:progress_bar, "~> 2.0"},

View File

@ -103,7 +103,7 @@