From e717312de71c32216a4fdf95ddcd07e0d84d0fac Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Tue, 28 Dec 2021 11:42:08 +0100 Subject: [PATCH] Introduce instances admin page Signed-off-by: Thomas Citharel --- config/config.exs | 1 + js/src/apollo/utils.ts | 3 + js/src/assets/logo.svg | 9 + js/src/components/Admin/Followers.vue | 262 --------------- js/src/components/Admin/Followings.vue | 311 ------------------ js/src/components/Settings/SettingsMenu.vue | 2 +- js/src/graphql/admin.ts | 61 ++++ js/src/i18n/en_US.json | 23 +- js/src/i18n/fr_FR.json | 23 +- js/src/mixins/relay.ts | 38 --- js/src/router/settings.ts | 64 ++-- js/src/types/enums.ts | 12 + js/src/types/instance.model.ts | 14 + js/src/views/Admin/AdminProfile.vue | 6 + js/src/views/Admin/Follows.vue | 116 ------- js/src/views/Admin/Instance.vue | 268 +++++++++++++++ js/src/views/Admin/Instances.vue | 305 +++++++++++++++++ lib/graphql/resolvers/admin.ex | 86 ++++- lib/graphql/schema/admin.ex | 124 +++++++ lib/mobilizon/actors/actors.ex | 10 + lib/mobilizon/instances/instance.ex | 19 ++ lib/mobilizon/instances/instances.ex | 115 +++++++ lib/service/workers/refresh_instances.ex | 31 ++ ...3141104_add_instance_materialized_view.exs | 64 ++++ schema.graphql | 226 ++++++++++++- 25 files changed, 1415 insertions(+), 778 deletions(-) create mode 100644 js/src/assets/logo.svg delete mode 100644 js/src/components/Admin/Followers.vue delete mode 100644 js/src/components/Admin/Followings.vue delete mode 100644 js/src/mixins/relay.ts create mode 100644 js/src/types/instance.model.ts delete mode 100644 js/src/views/Admin/Follows.vue create mode 100644 js/src/views/Admin/Instance.vue create mode 100644 js/src/views/Admin/Instances.vue create mode 100644 lib/mobilizon/instances/instance.ex create mode 100644 lib/mobilizon/instances/instances.ex create mode 100644 lib/service/workers/refresh_instances.ex create mode 100644 priv/repo/migrations/20211223141104_add_instance_materialized_view.exs diff --git a/config/config.exs b/config/config.exs index 73c3ae5d6..10a20f43f 100644 --- a/config/config.exs +++ b/config/config.exs @@ -290,6 +290,7 @@ config :mobilizon, Oban, crontab: [ {"@hourly", Mobilizon.Service.Workers.BuildSiteMap, queue: :background}, {"17 4 * * *", Mobilizon.Service.Workers.RefreshGroups, queue: :background}, + {"36 * * * *", Mobilizon.Service.Workers.RefreshInstances, queue: :background}, {"@hourly", Mobilizon.Service.Workers.CleanOrphanMediaWorker, queue: :background}, {"@hourly", Mobilizon.Service.Workers.CleanUnconfirmedUsersWorker, queue: :background}, {"@hourly", Mobilizon.Service.Workers.ExportCleanerWorker, queue: :background}, diff --git a/js/src/apollo/utils.ts b/js/src/apollo/utils.ts index 84ba969e9..07caeb405 100644 --- a/js/src/apollo/utils.ts +++ b/js/src/apollo/utils.ts @@ -70,6 +70,9 @@ export const typePolicies: TypePolicies = { participantStats: { merge: replaceMergePolicy }, }, }, + Instance: { + keyFields: ["domain"], + }, RootQueryType: { fields: { relayFollowers: paginatedLimitPagination(), diff --git a/js/src/assets/logo.svg b/js/src/assets/logo.svg new file mode 100644 index 000000000..32bac7d36 --- /dev/null +++ b/js/src/assets/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/js/src/components/Admin/Followers.vue b/js/src/components/Admin/Followers.vue deleted file mode 100644 index d05b6d606..000000000 --- a/js/src/components/Admin/Followers.vue +++ /dev/null @@ -1,262 +0,0 @@ - - diff --git a/js/src/components/Admin/Followings.vue b/js/src/components/Admin/Followings.vue deleted file mode 100644 index 6739aba01..000000000 --- a/js/src/components/Admin/Followings.vue +++ /dev/null @@ -1,311 +0,0 @@ - - diff --git a/js/src/components/Settings/SettingsMenu.vue b/js/src/components/Settings/SettingsMenu.vue index bbf96cdcb..3be7a96ad 100644 --- a/js/src/components/Settings/SettingsMenu.vue +++ b/js/src/components/Settings/SettingsMenu.vue @@ -78,7 +78,7 @@ /> diff --git a/js/src/graphql/admin.ts b/js/src/graphql/admin.ts index acb3f73b1..733e71fe4 100644 --- a/js/src/graphql/admin.ts +++ b/js/src/graphql/admin.ts @@ -70,6 +70,67 @@ export const RELAY_FOLLOWINGS = gql` ${RELAY_FRAGMENT} `; +export const INSTANCE_FRAGMENT = gql` + fragment InstanceFragment on Instance { + domain + hasRelay + followerStatus + followedStatus + eventCount + personCount + groupCount + followersCount + followingsCount + reportsCount + mediaSize + } +`; + +export const INSTANCE = gql` + query instance($domain: ID!) { + instance(domain: $domain) { + ...InstanceFragment + } + } + ${INSTANCE_FRAGMENT} +`; + +export const INSTANCES = gql` + query Instances( + $page: Int + $limit: Int + $orderBy: InstancesSortFields + $direction: String + $filterDomain: String + $filterFollowStatus: InstanceFilterFollowStatus + $filterSuspendStatus: InstanceFilterSuspendStatus + ) { + instances( + page: $page + limit: $limit + orderBy: $orderBy + direction: $direction + filterDomain: $filterDomain + filterFollowStatus: $filterFollowStatus + filterSuspendStatus: $filterSuspendStatus + ) { + total + elements { + ...InstanceFragment + } + } + } + ${INSTANCE_FRAGMENT} +`; +export const ADD_INSTANCE = gql` + mutation addInstance($domain: String!) { + addInstance(domain: $domain) { + ...InstanceFragment + } + } + ${INSTANCE_FRAGMENT} +`; + export const ADD_RELAY = gql` mutation addRelay($address: String!) { addRelay(address: $address) { diff --git a/js/src/i18n/en_US.json b/js/src/i18n/en_US.json index d6ead134b..bf831b871 100644 --- a/js/src/i18n/en_US.json +++ b/js/src/i18n/en_US.json @@ -1260,5 +1260,24 @@ "This profile was not found": "This profile was not found", "Back to profile list": "Back to profile list", "This user was not found": "This user was not found", - "Back to user list": "Back to user list" -} + "Back to user list": "Back to user list", + "Stop following instance": "Stop following instance", + "Follow instance": "Follow instance", + "Accept follow": "Accept follow", + "Reject follow": "Reject follow", + "This instance doesn't follow yours.": "This instance doesn't follow yours.", + "Only Mobilizon instances can be followed": "", + "Follow a new instance": "Follow a new instance", + "Follow status": "Follow status", + "All": "All", + "Following": "Following", + "Followed": "Followed", + "Followed, pending response": "Followed, pending response", + "Follows us": "Follows us", + "Follows us, pending approval": "Follows us, pending approval", + "No instance found.": "No instance found.", + "No instances match this filter. Try resetting filter fields?": "No instances match this filter. Try resetting filter fields?", + "You haven't interacted with other instances yet.": "You haven't interacted with other instances yet.", + "mobilizon-instance.tld": "mobilizon-instance.tld", + "Report status": "Report status" +} \ No newline at end of file diff --git a/js/src/i18n/fr_FR.json b/js/src/i18n/fr_FR.json index ff8bb1128..77333a1a1 100644 --- a/js/src/i18n/fr_FR.json +++ b/js/src/i18n/fr_FR.json @@ -1260,5 +1260,24 @@ "{timezoneLongName} ({timezoneShortName})": "{timezoneLongName} ({timezoneShortName})", "{title} ({count} todos)": "{title} ({count} todos)", "{username} was invited to {group}": "{username} a été invité à {group}", - "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap" -} + "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap", + "Stop following instance": "Arrêter de suivre l'instance", + "Follow instance": "Suivre l'instance", + "Accept follow": "Accepter le suivi", + "Reject follow": "Rejetter le suivi", + "This instance doesn't follow yours.": "Cette instance ne suit pas la vôtre.", + "Only Mobilizon instances can be followed": "Seules les instances Mobilizon peuvent être suivies", + "Follow a new instance": "Suivre une nouvelle instance", + "Follow status": "Statut du suivi", + "All": "Toutes", + "Following": "Suivantes", + "Followed": "Suivies", + "Followed, pending response": "Suivie, en attente de la réponse", + "Follows us": "Nous suit", + "Follows us, pending approval": "Nous suit, en attente de validation", + "No instance found": "Aucune instance trouvée", + "No instances match this filter. Try resetting filter fields?": "Aucune instance ne correspond à ce filtre. Essayer de remettre à zéro les champs des filtres ?", + "You haven't interacted with other instances yet.": "Vous n'avez interagi avec encore aucune autre instance.", + "mobilizon-instance.tld": "instance-mobilizon.tld", + "Report status": "Statut du signalement" +} \ No newline at end of file diff --git a/js/src/mixins/relay.ts b/js/src/mixins/relay.ts deleted file mode 100644 index 0289c1392..000000000 --- a/js/src/mixins/relay.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { IActor } from "@/types/actor"; -import { ActorType } from "@/types/enums"; -import { Component, Vue, Ref } from "vue-property-decorator"; -import VueRouter from "vue-router"; -const { isNavigationFailure, NavigationFailureType } = VueRouter; - -@Component -export default class RelayMixin extends Vue { - @Ref("table") readonly table!: any; - - toggle(row: Record): void { - this.table.toggleDetails(row); - } - - protected async pushRouter( - routeName: string, - args: Record - ): Promise { - try { - await this.$router.push({ - name: routeName, - query: { ...this.$route.query, ...args }, - }); - } catch (e) { - if (isNavigationFailure(e, NavigationFailureType.redirected)) { - throw Error(e.toString()); - } - } - } - - static isInstance(actor: IActor): boolean { - return ( - actor.type === ActorType.APPLICATION && - (actor.preferredUsername === "relay" || - actor.preferredUsername === actor.domain) - ); - } -} diff --git a/js/src/router/settings.ts b/js/src/router/settings.ts index 29b3de00c..fcd8eeca2 100644 --- a/js/src/router/settings.ts +++ b/js/src/router/settings.ts @@ -11,9 +11,8 @@ export enum SettingsRouteName { ADMIN = "ADMIN", ADMIN_DASHBOARD = "ADMIN_DASHBOARD", ADMIN_SETTINGS = "ADMIN_SETTINGS", - RELAYS = "Relays", - RELAY_FOLLOWINGS = "Followings", - RELAY_FOLLOWERS = "Followers", + INSTANCES = "INSTANCES", + INSTANCE = "INSTANCE", USERS = "USERS", PROFILES = "PROFILES", ADMIN_PROFILE = "ADMIN_PROFILE", @@ -199,44 +198,35 @@ export const settingsRoutes: RouteConfig[] = [ meta: { requiredAuth: true, announcer: { skip: true } }, }, { - path: "admin/relays", - name: SettingsRouteName.RELAYS, - redirect: { name: SettingsRouteName.RELAY_FOLLOWINGS }, + path: "admin/instances", + name: SettingsRouteName.INSTANCES, component: (): Promise => - import(/* webpackChunkName: "Follows" */ "@/views/Admin/Follows.vue"), - meta: { requiredAuth: true, announcer: { skip: true } }, - children: [ - { - path: "followings", - name: SettingsRouteName.RELAY_FOLLOWINGS, - component: (): Promise => - import( - /* webpackChunkName: "Followings" */ "@/components/Admin/Followings.vue" - ), - meta: { - requiredAuth: true, - announcer: { - message: (): string => i18n.t("Followings") as string, - }, - }, + import( + /* webpackChunkName: "Instances" */ "@/views/Admin/Instances.vue" + ), + meta: { + requiredAuth: true, + announcer: { + message: (): string => i18n.t("Instances") as string, }, - { - path: "followers", - name: SettingsRouteName.RELAY_FOLLOWERS, - component: (): Promise => - import( - /* webpackChunkName: "Followers" */ "@/components/Admin/Followers.vue" - ), - meta: { - requiredAuth: true, - announcer: { - message: (): string => i18n.t("Followers") as string, - }, - }, - }, - ], + }, props: true, }, + { + path: "admin/instances/:domain", + name: SettingsRouteName.INSTANCE, + component: (): Promise => + import( + /* webpackChunkName: "Instance" */ "@/views/Admin/Instance.vue" + ), + props: true, + meta: { + requiredAuth: true, + announcer: { + message: (): string => i18n.t("Instance") as string, + }, + }, + }, { path: "/moderation", name: SettingsRouteName.MODERATION, diff --git a/js/src/types/enums.ts b/js/src/types/enums.ts index 34049388b..2f05bd0d0 100644 --- a/js/src/types/enums.ts +++ b/js/src/types/enums.ts @@ -276,3 +276,15 @@ export enum EventMetadataCategories { BOOKING = "BOOKING", VIDEO_CONFERENCE = "VIDEO_CONFERENCE", } + +export enum InstanceFilterFollowStatus { + ALL = "ALL", + FOLLOWING = "FOLLOWING", + FOLLOWED = "FOLLOWED", +} + +export enum InstanceFollowStatus { + APPROVED = "APPROVED", + PENDING = "PENDING", + NONE = "NONE", +} diff --git a/js/src/types/instance.model.ts b/js/src/types/instance.model.ts new file mode 100644 index 000000000..5936f6ba6 --- /dev/null +++ b/js/src/types/instance.model.ts @@ -0,0 +1,14 @@ +import { InstanceFollowStatus } from "./enums"; + +export interface IInstance { + domain: string; + hasRelay: boolean; + followerStatus: InstanceFollowStatus; + followedStatus: InstanceFollowStatus; + personCount: number; + groupCount: number; + followersCount: number; + followingsCount: number; + reportsCount: number; + mediaSize: number; +} diff --git a/js/src/views/Admin/AdminProfile.vue b/js/src/views/Admin/AdminProfile.vue index 77040c163..32bb836e6 100644 --- a/js/src/views/Admin/AdminProfile.vue +++ b/js/src/views/Admin/AdminProfile.vue @@ -384,6 +384,12 @@ export default class AdminProfile extends Vue { { key: this.$t("Domain") as string, value: this.person.domain ? this.person.domain : this.$t("Local"), + link: this.person.domain + ? { + name: RouteName.INSTANCE, + params: { domain: this.person.domain }, + } + : undefined, }, { key: this.$i18n.t("Uploaded media size"), diff --git a/js/src/views/Admin/Follows.vue b/js/src/views/Admin/Follows.vue deleted file mode 100644 index aae1acecd..000000000 --- a/js/src/views/Admin/Follows.vue +++ /dev/null @@ -1,116 +0,0 @@ - - - - diff --git a/js/src/views/Admin/Instance.vue b/js/src/views/Admin/Instance.vue new file mode 100644 index 000000000..85233234f --- /dev/null +++ b/js/src/views/Admin/Instance.vue @@ -0,0 +1,268 @@ + + diff --git a/js/src/views/Admin/Instances.vue b/js/src/views/Admin/Instances.vue new file mode 100644 index 000000000..e2c64940f --- /dev/null +++ b/js/src/views/Admin/Instances.vue @@ -0,0 +1,305 @@ + + + + diff --git a/lib/graphql/resolvers/admin.ex b/lib/graphql/resolvers/admin.ex index 36857e7dd..54c407ec4 100644 --- a/lib/graphql/resolvers/admin.ex +++ b/lib/graphql/resolvers/admin.ex @@ -5,7 +5,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do import Mobilizon.Users.Guards - alias Mobilizon.{Actors, Admin, Config, Events} + alias Mobilizon.{Actors, Admin, Config, Events, Instances} alias Mobilizon.Actors.{Actor, Follower} alias Mobilizon.Admin.{ActionLog, Setting} alias Mobilizon.Cldr.Language @@ -329,6 +329,79 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do {:error, :unauthenticated} end + def get_instances( + _parent, + args, + %{ + context: %{current_user: %User{role: role}} + } + ) + when is_admin(role) do + {:ok, + Instances.instances( + args + |> Keyword.new() + |> Keyword.take([ + :page, + :limit, + :order_by, + :direction, + :filter_domain, + :filter_follow_status, + :filter_suspend_status + ]) + )} + end + + def get_instances(_parent, _args, %{context: %{current_user: %User{}}}) do + {:error, :unauthorized} + end + + def get_instances(_parent, _args, _resolution) do + {:error, :unauthenticated} + end + + def get_instance(_parent, %{domain: domain}, %{ + context: %{current_user: %User{role: role}} + }) + when is_admin(role) do + has_relay = Actors.has_relay?(domain) + remote_relay = Actors.get_actor_by_name("relay@#{domain}") + local_relay = Relay.get_actor() + + result = %{ + has_relay: has_relay, + follower_status: follow_status(remote_relay, local_relay), + followed_status: follow_status(local_relay, remote_relay) + } + + {:ok, Map.merge(Instances.instance(domain), result)} + end + + def get_instance(_parent, _args, %{context: %{current_user: %User{}}}) do + {:error, :unauthorized} + end + + def get_instance(_parent, _args, _resolution) do + {:error, :unauthenticated} + end + + def create_instance( + parent, + %{domain: domain} = args, + %{context: %{current_user: %User{role: role}}} = resolution + ) + when is_admin(role) do + case Relay.follow(domain) do + {:ok, _activity, _follow} -> + Instances.refresh() + get_instance(parent, args, resolution) + + {:error, err} -> + {:error, err} + end + end + @spec create_relay(any(), map(), Absinthe.Resolution.t()) :: {:ok, Follower.t()} | {:error, any()} def create_relay(_parent, %{address: address}, %{context: %{current_user: %User{role: role}}}) @@ -425,4 +498,15 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do :ok end end + + @spec follow_status(Actor.t() | nil, Actor.t() | nil) :: :approved | :pending | :none + defp follow_status(follower, followed) when follower != nil and followed != nil do + case Actors.check_follow(follower, followed) do + %Follower{approved: true} -> :approved + %Follower{approved: false} -> :pending + _ -> :none + end + end + + defp follow_status(_, _), do: :none end diff --git a/lib/graphql/schema/admin.ex b/lib/graphql/schema/admin.ex index dfe02c093..44b70274d 100644 --- a/lib/graphql/schema/admin.ex +++ b/lib/graphql/schema/admin.ex @@ -153,6 +153,80 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do value(:custom, as: "CUSTOM", description: "Custom privacy policy text") end + enum :instance_follow_status do + value(:approved, description: "The instance follow was approved") + value(:pending, description: "The instance follow is still pending") + value(:none, description: "There's no instance follow etablished") + end + + enum :instances_sort_fields do + value(:event_count) + value(:person_count) + value(:group_count) + value(:followers_count) + value(:followings_count) + value(:reports_count) + value(:media_size) + end + + enum :instance_filter_follow_status do + value(:all) + value(:following) + value(:followed) + end + + enum :instance_filter_suspend_status do + value(:all) + value(:suspended) + end + + @desc """ + An instance representation + """ + object :instance do + field(:domain, :id, description: "The domain name of the instance") + field(:has_relay, :boolean, description: "Whether this instance has a Mobilizon relay actor") + field(:follower_status, :instance_follow_status, description: "Do we follow this instance") + field(:followed_status, :instance_follow_status, description: "Does this instance follow us?") + + field(:event_count, :integer, description: "The number of events on this instance we know of") + + field(:person_count, :integer, + description: "The number of profiles on this instance we know of" + ) + + field(:group_count, :integer, description: "The number of grouo on this instance we know of") + + field(:followers_count, :integer, + description: "The number of their profiles who follow our groups" + ) + + field(:followings_count, :integer, + description: "The number of our profiles who follow their groups" + ) + + field(:reports_count, :integer, + description: "The number of reports made against profiles from this instance" + ) + + field(:media_size, :integer, + description: "The size of all the media files sent by actors from this instance" + ) + + field(:has_relay, :boolean, + description: + "Whether this instance has a relay, meaning that it's a Mobilizon instance that we can follow" + ) + end + + @desc """ + A paginated list of instances + """ + object :paginated_instance_list do + field(:elements, list_of(:instance), description: "A list of instances") + field(:total, :integer, description: "The total number of instances in the list") + end + object :admin_queries do @desc "Get the list of action logs" field :action_logs, type: :paginated_action_log_list do @@ -226,9 +300,59 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do arg(:direction, :string, default_value: :desc, description: "The sorting direction") resolve(&Admin.list_relay_followings/3) end + + @desc """ + List instances + """ + field :instances, :paginated_instance_list do + arg(:page, :integer, + default_value: 1, + description: "The page in the paginated relay followings list" + ) + + arg(:limit, :integer, + default_value: 10, + description: "The limit of relay followings per page" + ) + + arg(:order_by, :instances_sort_fields, + default_value: :event_count, + description: "The field to order by the list" + ) + + arg(:filter_domain, :string, default_value: nil, description: "Filter by domain") + + arg(:filter_follow_status, :instance_filter_follow_status, + default_value: :all, + description: "Whether or not to filter instances by the follow status" + ) + + arg(:filter_suspend_status, :instance_filter_suspend_status, + default_value: :all, + description: "Whether or not to filter instances by the suspended status" + ) + + arg(:direction, :string, default_value: :desc, description: "The sorting direction") + resolve(&Admin.get_instances/3) + end + + @desc """ + Get an instance's details + """ + field :instance, :instance do + arg(:domain, non_null(:id), description: "The instance domain") + resolve(&Admin.get_instance/3) + end end object :admin_mutations do + @desc "Add an instance subscription" + field :add_instance, type: :instance do + arg(:domain, non_null(:string), description: "The instance domain to add") + + resolve(&Admin.create_instance/3) + end + @desc "Add a relay subscription" field :add_relay, type: :follower do arg(:address, non_null(:string), description: "The relay hostname to add") diff --git a/lib/mobilizon/actors/actors.ex b/lib/mobilizon/actors/actors.ex index 527aa5eef..b67b85e11 100644 --- a/lib/mobilizon/actors/actors.ex +++ b/lib/mobilizon/actors/actors.ex @@ -1256,6 +1256,16 @@ defmodule Mobilizon.Actors do :ok end + @spec has_relay?(String.t()) :: boolean() + def has_relay?(domain) do + Actor + |> where( + [a], + a.preferred_username == "relay" and a.domain == ^domain and a.type == :Application + ) + |> Repo.exists?() + end + @spec delete_files_if_media_changed(Ecto.Changeset.t()) :: Ecto.Changeset.t() defp delete_files_if_media_changed(%Ecto.Changeset{changes: changes, data: data} = changeset) do Enum.each([:avatar, :banner], fn key -> diff --git a/lib/mobilizon/instances/instance.ex b/lib/mobilizon/instances/instance.ex new file mode 100644 index 000000000..a3aceddef --- /dev/null +++ b/lib/mobilizon/instances/instance.ex @@ -0,0 +1,19 @@ +defmodule Mobilizon.Instances.Instance do + @moduledoc """ + An instance representation + + Using a MATERIALIZED VIEW underneath + """ + use Ecto.Schema + + @primary_key {:domain, :string, []} + schema "instances" do + field(:event_count, :integer) + field(:person_count, :integer) + field(:group_count, :integer) + field(:followers_count, :integer) + field(:followings_count, :integer) + field(:reports_count, :integer) + field(:media_size, :integer) + end +end diff --git a/lib/mobilizon/instances/instances.ex b/lib/mobilizon/instances/instances.ex new file mode 100644 index 000000000..470e06c1b --- /dev/null +++ b/lib/mobilizon/instances/instances.ex @@ -0,0 +1,115 @@ +defmodule Mobilizon.Instances do + @moduledoc """ + The instances context + """ + alias Ecto.Adapters.SQL + alias Mobilizon.Actors.{Actor, Follower} + alias Mobilizon.Instances.Instance + alias Mobilizon.Storage.{Page, Repo} + import Ecto.Query + + @is_null_fragment "CASE WHEN ? IS NULL THEN FALSE ELSE TRUE END" + + @spec instances(Keyword.t()) :: Page.t(Instance.t()) + def instances(options) do + page = Keyword.get(options, :page) + limit = Keyword.get(options, :limit) + order_by = Keyword.get(options, :order_by) + direction = Keyword.get(options, :direction) + filter_domain = Keyword.get(options, :filter_domain) + # suspend_status = Keyword.get(options, :filter_suspend_status) + follow_status = Keyword.get(options, :filter_follow_status) + + order_by_options = Keyword.new([{direction, order_by}]) + + subquery = + Actor + |> where( + [a], + a.preferred_username == "relay" and a.type == :Application and not is_nil(a.domain) + ) + |> join(:left, [a], f1 in Follower, on: f1.target_actor_id == a.id) + |> join(:left, [a], f2 in Follower, on: f2.actor_id == a.id) + |> select([a, f1, f2], %{ + domain: a.domain, + has_relay: fragment(@is_null_fragment, a.id), + following: fragment(@is_null_fragment, f2.id), + following_approved: f2.approved, + follower: fragment(@is_null_fragment, f1.id), + follower_approved: f1.approved + }) + + query = + Instance + |> join(:left, [i], s in subquery(subquery), on: i.domain == s.domain) + |> select([i, s], {i, s}) + |> order_by(^order_by_options) + + query = + if is_nil(filter_domain) or filter_domain == "" do + query + else + where(query, [i], like(i.domain, ^"%#{filter_domain}%")) + end + + query = + case follow_status do + :following -> where(query, [i, s], s.following == true) + :followed -> where(query, [i, s], s.follower == true) + :all -> query + end + + %Page{elements: elements} = paged_instances = Page.build_page(query, page, limit, :domain) + + %Page{ + paged_instances + | elements: Enum.map(elements, &convert_instance_meta/1) + } + end + + @spec instance(String.t()) :: Instance.t() + def instance(domain) do + Instance + |> where(domain: ^domain) + |> Repo.one() + end + + @spec all_domains :: list(Instance.t()) + def all_domains do + Instance + |> distinct(true) + |> select([:domain]) + |> Repo.all() + end + + @spec refresh :: %{ + :rows => nil | [[term()] | binary()], + :num_rows => non_neg_integer(), + optional(atom()) => any() + } + def refresh do + SQL.query!(Repo, "REFRESH MATERIALIZED VIEW instances") + end + + defp convert_instance_meta( + {instance, + %{ + domain: _domain, + follower: follower, + follower_approved: follower_approved, + following: following, + following_approved: following_approved, + has_relay: has_relay + }} + ) do + instance + |> Map.put(:follower_status, follow_status(following, following_approved)) + |> Map.put(:followed_status, follow_status(follower, follower_approved)) + |> Map.put(:has_relay, has_relay) + end + + defp follow_status(true, true), do: :approved + defp follow_status(true, false), do: :pending + defp follow_status(false, _), do: :none + defp follow_status(nil, _), do: :none +end diff --git a/lib/service/workers/refresh_instances.ex b/lib/service/workers/refresh_instances.ex new file mode 100644 index 000000000..23515b3fa --- /dev/null +++ b/lib/service/workers/refresh_instances.ex @@ -0,0 +1,31 @@ +defmodule Mobilizon.Service.Workers.RefreshInstances do + @moduledoc """ + Worker to refresh the instances materialized view and the relay actors + """ + + use Oban.Worker, unique: [period: :infinity, keys: [:event_uuid, :action]] + + alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor + alias Mobilizon.Instances + alias Mobilizon.Instances.Instance + alias Oban.Job + + @impl Oban.Worker + @spec perform(Oban.Job.t()) :: :ok + def perform(%Job{}) do + Instances.refresh() + + Instances.all_domains() + |> Enum.each(&refresh_instance_actor/1) + end + + @spec refresh_instance_actor(Instance.t()) :: + {:ok, Mobilizon.Actors.Actor.t()} + | {:error, + Mobilizon.Federation.ActivityPub.Actor.make_actor_errors() + | Mobilizon.Federation.WebFinger.finger_errors()} + + defp refresh_instance_actor(%Instance{domain: domain}) do + ActivityPubActor.find_or_make_actor_from_nickname("relay@#{domain}") + end +end diff --git a/priv/repo/migrations/20211223141104_add_instance_materialized_view.exs b/priv/repo/migrations/20211223141104_add_instance_materialized_view.exs new file mode 100644 index 000000000..a997321c9 --- /dev/null +++ b/priv/repo/migrations/20211223141104_add_instance_materialized_view.exs @@ -0,0 +1,64 @@ +defmodule Mobilizon.Storage.Repo.Migrations.AddInstanceMaterializedView do + use Ecto.Migration + + def up do + execute(""" + CREATE MATERIALIZED VIEW instances AS + SELECT + a.domain, + COUNT(DISTINCT(p.id)) AS person_count, + COUNT(DISTINCT(g.id)) AS group_count, + COUNT(DISTINCT(e.id)) AS event_count, + COUNT(f1.id) AS followers_count, + COUNT(f2.id) AS followings_count, + COUNT(r.id) AS reports_count, + SUM(COALESCE((m.file->>'size')::int, 0)) AS media_size + FROM actors a + LEFT JOIN actors p ON a.id = p.id AND p.type = 'Person' + LEFT JOIN actors g ON a.id = g.id AND g.type = 'Group' + LEFT JOIN events e ON a.id = e.organizer_actor_id + LEFT JOIN followers f1 ON a.id = f1.actor_id + LEFT JOIN followers f2 ON a.id = f2.target_actor_id + LEFT JOIN reports r ON r.reported_id = a.id + LEFT JOIN medias m ON m.actor_id = a.id + WHERE a.domain IS NOT NULL + GROUP BY a.domain; + """) + + execute(""" + CREATE OR REPLACE FUNCTION refresh_instances() + RETURNS trigger AS $$ + BEGIN + REFRESH MATERIALIZED VIEW instances; + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + """) + + execute(""" + DROP TRIGGER IF EXISTS refresh_instances_trigger ON actors; + """) + + execute(""" + CREATE TRIGGER refresh_instances_trigger + AFTER INSERT OR UPDATE OR DELETE + ON actors + FOR EACH STATEMENT + EXECUTE PROCEDURE refresh_instances(); + """) + + create_if_not_exists(unique_index("instances", [:domain])) + end + + def down do + drop_if_exists(unique_index("instances", [:domain])) + + execute(""" + DROP FUNCTION IF EXISTS refresh_instances() CASCADE; + """) + + execute(""" + DROP MATERIALIZED VIEW IF EXISTS instances; + """) + end +end diff --git a/schema.graphql b/schema.graphql index fdb2a86aa..70c117702 100644 --- a/schema.graphql +++ b/schema.graphql @@ -211,6 +211,9 @@ type Config { "The instance's features" features: Features + "The instance's restrictions" + restrictions: Restrictions + "The instance's version" version: String @@ -240,6 +243,9 @@ type Config { "Web Push settings for the instance" webPush: WebPush + + "The instance list of export formats" + exportFormats: ExportFormats } "A tag" @@ -306,7 +312,13 @@ type TodoList { actor: Actor "The todo-list's todos" - todos: PaginatedTodoList + todos( + "The page in the paginated todos list" + page: Int + + "The limit of todos per page" + limit: Int + ): PaginatedTodoList } "Represents a participant to an event" @@ -455,7 +467,7 @@ type Comment implements ActivityObject & ActionLogObject { isAnnouncement: Boolean! "The comment language" - language: String! + language: String } "An attached media or a link to a media" @@ -690,6 +702,9 @@ enum ExportFormatEnum { "PDF format" PDF + + "ODS format" + ODS } "The list of visibility options for a comment" @@ -770,6 +785,14 @@ interface Interactable { url: String } +enum EventType { + "The event will happen in person. It can also be livestreamed, but has a physical address" + IN_PERSON + + "The event will only happen online. It has no physical address" + ONLINE +} + enum EventMetadataType { "A string" STRING @@ -794,6 +817,14 @@ type DeletedObject { id: ID } +"A follow group event" +type FollowedGroupEvent { + user: User + profile: Person + group: Group + event: Event +} + "A paginated list of comments" type PaginatedCommentList { "A list of comments" @@ -851,7 +882,7 @@ type Post implements ActivityObject { updatedAt: DateTime "The post language" - language: String! + language: String "The post's tags" tags: [Tag] @@ -941,6 +972,12 @@ type Statistics { numberOfInstanceFollowings: Int } +"Export formats configuration" +type ExportFormats { + "The list of formats the event participants can be exported to" + eventParticipants: [String] +} + "Search persons result" type Persons { "Total elements" @@ -1128,6 +1165,18 @@ type Person implements ActionLogObject & Actor { "The limit of memberships per page" limit: Int ): PaginatedMemberList + + "The list of groups this person follows" + follows( + "Filter by group federated username" + group: String + + "The page in the follows list" + page: Int + + "The limit of follows per page" + limit: Int + ): PaginatedFollowerList } "Root Mutation" @@ -1396,6 +1445,30 @@ type RootMutationType { groupId: ID! ): DeletedObject + "Follow a group" + followGroup( + "The group ID" + groupId: ID! + + "Whether to notify profile from group activity" + notify: Boolean + ): Follower + + "Update a group follow" + updateGroupFollow( + "The follow ID" + followId: ID! + + "Whether to notify profile from group activity" + notify: Boolean + ): Follower + + "Unfollow a group" + unfollowGroup( + "The group ID" + groupId: ID! + ): Follower + "Create an event" createEvent( "The event's title" @@ -1589,6 +1662,9 @@ type RootMutationType { "The anonymous participant's locale" locale: String + + "The anonymous participant's timezone" + timezone: String ): Participant "Leave an event" @@ -1663,6 +1739,18 @@ type RootMutationType { id: ID! ): Member + "Approve a membership request" + approveMember( + "The member ID" + memberId: ID! + ): Member + + "Reject a membership request" + rejectMember( + "The member ID" + memberId: ID! + ): Member + "Update a member's role" updateMember( "The member ID" @@ -1674,11 +1762,11 @@ type RootMutationType { "Remove a member from a group" removeMember( - "The group ID" - groupId: ID! - "The member ID" memberId: ID! + + "Whether the member should be excluded from the group" + exclude: Boolean ): Member "Create a Feed Token" @@ -2108,6 +2196,12 @@ type RootQueryType { "A geohash for coordinates" location: String + "Whether to include the groups the current actor is member or follower" + excludeMyGroups: Boolean + + "The minimum visibility the group must have" + minimumVisibility: GroupVisibility + "Radius around the location to search in" radius: Float @@ -2128,6 +2222,9 @@ type RootQueryType { "A geohash for coordinates" location: String + "Whether the event is online or in person" + type: EventType + "Radius around the location to search in" radius: Float @@ -2389,6 +2486,12 @@ type RootQueryType { direction: String ): PaginatedFollowerList + "Get an instance's details" + instance( + "The instance domain" + domain: ID! + ): Instance + "Get a todo list" todoList( "The todo-list ID" @@ -2461,6 +2564,39 @@ string. """ scalar NaiveDateTime +"An instance representation" +type Instance { + "The domain name of the instance" + domain: ID + + "Whether this instance has a Mobilizon relay actor" + hasRelay: Boolean + + "Do we follow this instance" + followerStatus: InstanceFollowStatus + + "Does this instance follow us?" + followedStatus: InstanceFollowStatus + + "The number of profiles on this instance we know of" + personCount: Int + + "The number of grouo on this instance we know of" + groupCount: Int + + "The number of their profiles who follow our groups" + followersCount: Int + + "The number of our profiles who follow their groups" + followingsCount: Int + + "The number of reports made against profiles from this instance" + reportsCount: Int + + "The size of all the media files sent by actors from this instance" + mediaSize: Int +} + """ The `DateTime` scalar type represents a date and time in the UTC timezone. The DateTime appears in a JSON response as an ISO8601 formatted @@ -2573,8 +2709,14 @@ input EventOptionsInput { "Show event end time" showEndTime: Boolean + "The event's timezone" + timezone: String + "Whether to show or hide the person organizer when event is organized by a group" hideOrganizerWhenGroupEvent: Boolean + + "Whether the event is fully online" + isOnline: Boolean } "A report object" @@ -2765,7 +2907,7 @@ type Event implements ActivityObject & Interactable & ActionLogObject { metadata: [EventMetadata] "The event language" - language: String! + language: String } "An event offer" @@ -2835,6 +2977,18 @@ input AddressInput { "The address's original ID from the provider" originId: String + + "The (estimated) timezone of the location" + timezone: String +} + +"The instance's restrictions" +type Restrictions { + "Whether groups creation is allowed only for admin, not for all users" + onlyAdminCanCreateGroups: Boolean + + "Whether events creation is allowed only for groups, not for persons" + onlyGroupsCanCreateEvents: Boolean } "Instance anonymous configuration" @@ -2916,8 +3070,14 @@ type EventOptions { "Show event end time" showEndTime: Boolean + "The event's timezone" + timezone: String + "Whether to show or hide the person organizer when event is organized by a group" hideOrganizerWhenGroupEvent: Boolean + + "Whether the event is fully online" + isOnline: Boolean } "A resource provider details" @@ -2958,6 +3118,9 @@ type Follower { "Whether the follow has been approved by the target actor" approved: Boolean + "Whether the follower will be notified by the target actor's activity or not (applicable for profile\/group follows)" + notify: Boolean + "When the follow was created" insertedAt: DateTime @@ -3322,6 +3485,15 @@ type PaginatedGroupList { total: Int } +"A paginated list of follow group events" +type PaginatedFollowedGroupEvents { + "A list of follow group events" + elements: [FollowedGroupEvent] + + "The total number of follow group events in the list" + total: Int +} + "Instance map tiles configuration" type Tiles { "The instance's tiles endpoint" @@ -3377,6 +3549,20 @@ type Address { "The address's original ID from the provider" originId: String + + "The (estimated) timezone of the location" + timezone: String +} + +enum InstanceFollowStatus { + "The instance follow was approved" + APPROVED + + "The instance follow is still pending" + PENDING + + "There's no instance follow etablished" + NONE } "The instance's terms configuration" @@ -3599,6 +3785,9 @@ type User implements ActionLogObject { "The list of memberships for this user" memberships( + "A name to filter members by" + name: String + "The page in the paginated memberships list" page: Int @@ -3615,6 +3804,18 @@ type User implements ActionLogObject { limit: Int ): [Event] + "The suggested events from the groups this user follows" + followedGroupEvents( + "The page in the follow group events list" + page: Int + + "The limit of follow group events per page" + limit: Int + + "Filter follow group events by event start datetime" + afterDatetime: DateTime + ): PaginatedFollowedGroupEvents + "The list of settings for this user" settings: UserSettings @@ -3731,6 +3932,9 @@ type Group implements ActionLogObject & ActivityObject & Interactable & Actor { "A paginated list of group members" members( + "A name to filter members by" + name: String + "The page in the paginated member list" page: Int @@ -3760,7 +3964,13 @@ type Group implements ActionLogObject & ActivityObject & Interactable & Actor { ): PaginatedPostList "A paginated list of the todo lists this group has" - todoLists: PaginatedTodoListList + todoLists( + "The page in the paginated todo-lists list" + page: Int + + "The limit of todo-lists per page" + limit: Int + ): PaginatedTodoListList "A paginated list of the followers this group has" followers(