Add admin interface to manage instances subscriptions

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2019-12-03 11:29:51 +01:00
parent 7777f4e147
commit ce6f78b054
141 changed files with 4198 additions and 1923 deletions

1
.gitignore vendored
View File

@ -19,6 +19,7 @@ erl_crash.dump
.env.test
/.env
.env.2
.env.1
/setup_db.psql

View File

@ -22,7 +22,7 @@ config :mobilizon, :instance,
repository: Mix.Project.config()[:source_url],
allow_relay: true,
# Federation is to be activated with Mobilizon 1.0.0-beta.2
federating: false,
federating: true,
remote_limit: 100_000,
upload_limit: 10_000_000,
avatar_upload_limit: 2_000_000,
@ -63,7 +63,7 @@ config :mobilizon, MobilizonWeb.Upload,
config :mobilizon, MobilizonWeb.Uploaders.Local, uploads: "uploads"
config :mobilizon, :media_proxy,
enabled: false,
enabled: true,
proxy_opts: [
redirect_on_failure: false,
max_body_length: 25 * 1_048_576,
@ -107,7 +107,9 @@ config :auto_linker,
# TODO: Set to :no_scheme when it works properly
validate_tld: true,
class: false,
strip_prefix: false
strip_prefix: false,
new_window: true,
rel: "noopener noreferrer ugc"
]
config :phoenix, :format_encoders, json: Jason, "activity-json": Jason
@ -120,6 +122,8 @@ config :ex_cldr,
config :http_signatures,
adapter: Mobilizon.Service.HTTPSignatures.Signature
config :mobilizon, :activitypub, sign_object_fetches: true
config :mobilizon, Mobilizon.Service.Geospatial.Nominatim,
endpoint:
System.get_env("GEOSPATIAL_NOMINATIM_ENDPOINT") || "https://nominatim.openstreetmap.org",
@ -155,7 +159,7 @@ config :mobilizon, :maps,
config :mobilizon, Oban,
repo: Mobilizon.Storage.Repo,
prune: {:maxlen, 10_000},
queues: [default: 10, search: 20]
queues: [default: 10, search: 20, background: 5]
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.

View File

@ -0,0 +1,104 @@
# Federation
## ActivityPub
Mobilizon uses [ActivityPub](http://activitypub.rocks/) to federate content between instances. It only supports the server-to-server part of [the ActivityPub spec](https://www.w3.org/TR/activitypub/).
It implements the [HTTP signatures spec](https://tools.ietf.org/html/draft-cavage-http-signatures-12) for authentication of inbox deliveries, but doesn't implement Linked Data Signatures for forwarded payloads, and instead fetches content when needed.
To match usernames to actors, Mobilizon uses [WebFinger](https://tools.ietf.org/html/rfc7033).
## Instance subscriptions
Instances subscribe to each other through an internal actor named `relay@instance.tld` that publishes (through `Announce`) every created content to it's followers. Each content creation share is saved so that updates and deletes are correctly sent to every
## Activities
Supported Activity | Supported Object
------------ | -------------
`Accept` | `Follow`, `Join`
`Announce` | `Object`
`Create` | `Note`, `Event`
`Delete` | `Object`
`Flag` | `Object`
`Follow` | `Object`
`Reject` | `Follow`, `Join`
`Remove` | `Note`, `Event`
`Undo` | `Announce`, `Follow`
`Update` | `Object`
## Extensions
### Event
The vocabulary for Event is based on [the Event object in ActivityStreams](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-event), extended with :
* the [Event Schema](https://schema.org/Event) from Schema.org
* some properties from [iCalendar](https://tools.ietf.org/html/rfc5545), such as `ical:status` (see [this issue](https://framagit.org/framasoft/mobilizon/issues/320))
The following properties are added.
#### repliesModeration
Disabling replies is [an ongoing issue with ActivityPub](https://github.com/w3c/activitypub/issues/319) so we use a temporary property.
See [the corresponding issue](https://framagit.org/framasoft/mobilizon/issues/321).
Accepted values: `allow_all`, `closed`, `moderated` (not used at the moment)
Example:
```json
{
"@context": [
"...",
{
"mz": "https://joinmobilizon.org/ns#",
"repliesModerationOption": {
"@id": "mz:repliesModerationOption",
"@type": "mz:repliesModerationOptionType"
},
"repliesModerationOptionType": {
"@id": "mz:repliesModerationOptionType",
"@type": "rdfs:Class"
}
}
],
"...": "...",
"repliesModerationOption": "allow_all",
"type": "Event",
"url": "http://mobilizon1.com/events/8cf76e9f-c426-4912-9cd6-c7030b969611"
}
```
#### joinMode
Indicator of how new members may be able to join.
See [the corresponding issue](https://framagit.org/framasoft/mobilizon/issues/321).
Accepted values: `free`, `restricted`, `invite` (not used at the moment)
Example:
```json
{
"@context": [
"...",
{
"mz": "https://joinmobilizon.org/ns#",
"joinMode": {
"@id": "mz:joinMode",
"@type": "mz:joinModeType"
},
"joinModeType": {
"@id": "mz:joinModeType",
"@type": "rdfs:Class"
}
}
],
"...": "...",
"joinMode": "restricted",
"type": "Event",
"url": "http://mobilizon1.com/events/8cf76e9f-c426-4912-9cd6-c7030b969611"
}
```

View File

@ -12,15 +12,21 @@
"dev": "vue-cli-service build --watch",
"styleguide": "vue-cli-service styleguidist",
"styleguide:build": "vue-cli-service styleguidist:build",
"vue-i18n-extract": "vue-i18n-extract"
"vue-i18n-extract": "vue-i18n-extract",
"graphql:get-schema": "graphql get-schema",
"i18n-extract": "vue-i18n-extract report -v './src/**/*.?(ts|vue)' -l './src/i18n/en_US.json' -o output.json"
},
"dependencies": {
"@absinthe/socket": "^0.2.1",
"@absinthe/socket-apollo-link": "^0.2.1",
"@mdi/font": "^4.5.95",
"apollo-absinthe-upload-link": "^1.5.0",
"apollo-cache-inmemory": "^1.5.1",
"apollo-client": "^2.5.1",
"apollo-link": "^1.2.11",
"apollo-link-http": "^1.5.16",
"apollo-link-ws": "^1.0.19",
"apollo-utilities": "^1.3.2",
"buefy": "^0.8.2",
"graphql": "^14.5.8",
"graphql-tag": "^2.10.1",
@ -30,6 +36,7 @@
"leaflet.locatecontrol": "^0.68.0",
"lodash": "^4.17.11",
"ngeohash": "^0.6.3",
"phoenix": "^1.4.11",
"register-service-worker": "^1.6.2",
"tippy.js": "4.3.5",
"tiptap": "^1.26.0",

View File

@ -26,7 +26,8 @@
</div>
<div class="media-content">
<span class="title" ref="title">{{ actorDisplayName }}</span><br>
<small class="has-text-grey">@{{ participant.actor.preferredUsername }}</small>
<small class="has-text-grey" v-if="participant.actor.domain">@{{ participant.actor.preferredUsername }}@{{ participant.actor.domain }}</small>
<small class="has-text-grey" v-else>@{{ participant.actor.preferredUsername }}</small>
</div>
</div>
</div>
@ -41,7 +42,7 @@
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IActor, IPerson, Person } from '@/types/actor';
import { Person } from '@/types/actor';
import { IParticipant, ParticipantRole } from '@/types/event.model';
@Component

View File

@ -0,0 +1,141 @@
<template>
<div>
<b-table
v-show="relayFollowers.elements.length > 0"
:data="relayFollowers.elements"
:loading="$apollo.queries.relayFollowers.loading"
ref="table"
:checked-rows.sync="checkedRows"
:is-row-checkable="(row) => row.id !== 3"
detailed
:show-detail-icon="false"
paginated
backend-pagination
:total="relayFollowers.total"
:per-page="perPage"
@page-change="onPageChange"
checkable
checkbox-position="left">
<template slot-scope="props">
<b-table-column field="actor.id" label="ID" width="40" numeric>
{{ props.row.actor.id }}
</b-table-column>
<b-table-column field="actor.type" :label="$t('Type')" width="80">
<b-icon icon="lan" v-if="isInstance(props.row.actor)" />
<b-icon icon="account-circle" v-else />
</b-table-column>
<b-table-column field="approved" :label="$t('Status')" width="100" sortable centered>
<span :class="`tag ${props.row.approved ? 'is-success' : 'is-danger' }`">
{{ props.row.approved ? $t('Accepted') : $t('Pending') }}
</span>
</b-table-column>
<b-table-column field="actor.domain" :label="$t('Domain')" sortable>
<template>
<a @click="toggle(props.row)" v-if="isInstance(props.row.actor)">
{{ props.row.actor.domain }}
</a>
<a @click="toggle(props.row)" v-else>
{{ `${props.row.actor.preferredUsername}@${props.row.actor.domain}` }}
</a>
</template>
</b-table-column>
<b-table-column field="actor.updatedAt" :label="$t('Date')" sortable>
{{ props.row.updatedAt | formatDateTimeString }}
</b-table-column>
</template>
<template slot="detail" slot-scope="props">
<article>
<div class="content">
<strong>{{ props.row.actor.domain }}</strong>
<small>@{{ props.row.actor.preferredUsername }}</small>
<small>31m</small>
<br>
<p v-html="props.row.actor.summary" />
</div>
</article>
</template>
<template slot="bottom-left" v-if="checkedRows.length > 0">
<div class="buttons">
<b-button @click="acceptRelays" type="is-success" v-if="checkedRowsHaveAtLeastOneToApprove">
{{ $tc('No instance to approve|Approve instance|Approve {number} instances', checkedRows.length, { number: checkedRows.length }) }}
</b-button>
<b-button @click="rejectRelays" type="is-danger">
{{ $tc('No instance to reject|Reject instance|Reject {number} instances', checkedRows.length, { number: checkedRows.length }) }}
</b-button>
</div>
</template>
</b-table>
<b-message type="is-danger" v-if="relayFollowers.elements.length === 0">
{{ $t("No instance follows your instance yet.") }}
</b-message>
</div>
</template>
<script lang="ts">
import { Component, Mixins } from 'vue-property-decorator';
import { ACCEPT_RELAY, REJECT_RELAY, RELAY_FOLLOWERS } from '@/graphql/admin';
import { Paginate } from '@/types/paginate';
import { IFollower } from '@/types/actor/follower.model';
import RelayMixin from '@/mixins/relay';
@Component({
apollo: {
relayFollowers: {
query: RELAY_FOLLOWERS,
fetchPolicy: 'cache-and-network',
},
},
metaInfo() {
return {
title: this.$t('Followers') as string,
titleTemplate: '%s | Mobilizon',
};
},
})
export default class Followers extends Mixins(RelayMixin) {
relayFollowers: Paginate<IFollower> = { elements: [], total: 0 };
async acceptRelays() {
await this.checkedRows.forEach((row: IFollower) => {
this.acceptRelay(`${row.actor.preferredUsername}@${row.actor.domain}`);
});
}
async rejectRelays() {
await this.checkedRows.forEach((row: IFollower) => {
this.rejectRelay(`${row.actor.preferredUsername}@${row.actor.domain}`);
});
}
async acceptRelay(address: String) {
await this.$apollo.mutate({
mutation: ACCEPT_RELAY,
variables: {
address,
},
});
await this.$apollo.queries.relayFollowers.refetch();
this.checkedRows = [];
}
async rejectRelay(address: String) {
await this.$apollo.mutate({
mutation: REJECT_RELAY,
variables: {
address,
},
});
await this.$apollo.queries.relayFollowers.refetch();
this.checkedRows = [];
}
get checkedRowsHaveAtLeastOneToApprove(): boolean {
return this.checkedRows.some(checkedRow => !checkedRow.approved);
}
}
</script>

View File

@ -0,0 +1,142 @@
<template>
<div>
<form @submit="followRelay">
<b-field :label="$t('Add an instance')" custom-class="add-relay" horizontal>
<b-field grouped expanded size="is-large">
<p class="control">
<b-input v-model="newRelayAddress" :placeholder="$t('Ex: test.mobilizon.org')" />
</p>
<p class="control">
<b-button type="is-primary" native-type="submit">{{ $t('Add an instance') }}</b-button>
</p>
</b-field>
</b-field>
</form>
<b-table
v-show="relayFollowings.elements.length > 0"
:data="relayFollowings.elements"
:loading="$apollo.queries.relayFollowings.loading"
ref="table"
:checked-rows.sync="checkedRows"
:is-row-checkable="(row) => row.id !== 3"
detailed
:show-detail-icon="false"
paginated
backend-pagination
:total="relayFollowings.total"
:per-page="perPage"
@page-change="onPageChange"
checkable
checkbox-position="left">
<template slot-scope="props">
<b-table-column field="targetActor.id" label="ID" width="40" numeric>
{{ props.row.targetActor.id }}
</b-table-column>
<b-table-column field="targetActor.type" :label="$t('Type')" width="80">
<b-icon icon="lan" v-if="isInstance(props.row.targetActor)" />
<b-icon icon="account-circle" v-else />
</b-table-column>
<b-table-column field="approved" :label="$t('Status')" width="100" sortable centered>
<span :class="`tag ${props.row.approved ? 'is-success' : 'is-danger' }`">
{{ props.row.approved ? $t('Accepted') : $t('Pending') }}
</span>
</b-table-column>
<b-table-column field="targetActor.domain" :label="$t('Domain')" sortable>
<template>
<a @click="toggle(props.row)" v-if="isInstance(props.row.targetActor)">
{{ props.row.targetActor.domain }}
</a>
<a @click="toggle(props.row)" v-else>
{{ `${props.row.targetActor.preferredUsername}@${props.row.targetActor.domain}` }}
</a>
</template>
</b-table-column>
<b-table-column field="targetActor.updatedAt" :label="$t('Date')" sortable>
{{ props.row.updatedAt | formatDateTimeString }}
</b-table-column>
</template>
<template slot="detail" slot-scope="props">
<article>
<div class="content">
<strong>{{ props.row.targetActor.domain }}</strong>
<small>@{{ props.row.targetActor.preferredUsername }}</small>
<small>31m</small>
<br>
<p v-html="props.row.targetActor.summary" />
</div>
</article>
</template>
<template slot="bottom-left" v-if="checkedRows.length > 0">
<b-button @click="removeRelays" type="is-danger">
{{ $tc('No instance to remove|Remove instance|Remove {number} instances', checkedRows.length, { number: checkedRows.length }) }}
</b-button>
</template>
</b-table>
<b-message type="is-danger" v-if="relayFollowings.elements.length === 0">
{{ $t("You don't follow any instances yet.") }}
</b-message>
</div>
</template>
<script lang="ts">
import { Component, Mixins } from 'vue-property-decorator';
import { ADD_RELAY, RELAY_FOLLOWINGS, REMOVE_RELAY } from '@/graphql/admin';
import { IFollower } from '@/types/actor/follower.model';
import { Paginate } from '@/types/paginate';
import RelayMixin from '@/mixins/relay';
@Component({
apollo: {
relayFollowings: {
query: RELAY_FOLLOWINGS,
fetchPolicy: 'cache-and-network',
},
},
metaInfo() {
return {
title: this.$t('Followings') as string,
titleTemplate: '%s | Mobilizon',
};
},
})
export default class Followings extends Mixins(RelayMixin) {
relayFollowings: Paginate<IFollower> = { elements: [], total: 0 };
newRelayAddress: String = '';
async followRelay(e) {
e.preventDefault();
await this.$apollo.mutate({
mutation: ADD_RELAY,
variables: {
address: this.newRelayAddress,
},
// TODO: Handle cache update properly without refreshing
});
await this.$apollo.queries.relayFollowings.refetch();
this.newRelayAddress = '';
}
async removeRelays() {
await this.checkedRows.forEach((row: IFollower) => {
this.removeRelay(`${row.targetActor.preferredUsername}@${row.targetActor.domain}`);
});
}
async removeRelay(address: String) {
await this.$apollo.mutate({
mutation: REMOVE_RELAY,
variables: {
address,
},
});
await this.$apollo.queries.relayFollowings.refetch();
this.checkedRows = [];
}
}
</script>

View File

@ -11,7 +11,8 @@
<div class="content">
<span class="first-line" v-if="!comment.deletedAt">
<strong>{{ comment.actor.name }}</strong>
<small>@{{ comment.actor.preferredUsername }}</small>
<small v-if="comment.actor.domain">@{{ comment.actor.preferredUsername }}@{{ comment.actor.domain }}</small>
<small v-else>@{{ comment.actor.preferredUsername }}</small>
<a class="comment-link has-text-grey" :href="commentId">
<small>{{ timeago(new Date(comment.updatedAt)) }}</small>
</a>
@ -202,7 +203,7 @@ export default class Comment extends Vue {
timeago(dateTime): String {
if (this.timeAgoInstance != null) {
// @ts-ignore
// @ts-ignore
return this.timeAgoInstance.format(dateTime);
}
return '';
@ -213,7 +214,7 @@ export default class Comment extends Vue {
}
get commentFromOrganizer(): boolean {
return this.event.organizerActor !== undefined && this.comment.actor.id === this.event.organizerActor.id;
return this.event.organizerActor !== undefined && this.comment.actor && this.comment.actor.id === this.event.organizerActor.id;
}
get commentId(): String {
@ -230,6 +231,7 @@ export default class Comment extends Vue {
title: this.$t('Report this comment'),
comment: this.comment,
onConfirm: this.reportComment,
outsideDomain: this.comment.actor.domain,
},
});
}
@ -244,6 +246,7 @@ export default class Comment extends Vue {
reportedId: this.comment.actor.id,
commentsIds: [this.comment.id],
content,
forward,
},
});
this.$buefy.notification.open({

View File

@ -221,7 +221,7 @@ export default class CommentTree extends Vue {
data: { thread: replies },
});
// @ts-ignore
// @ts-ignore
const parentCommentIndex = oldComments.findIndex(oldComment => oldComment.id === comment.originComment.id);
const parentComment = oldComments[parentCommentIndex];
parentComment.replies = replies;

View File

@ -409,9 +409,9 @@ export default class EditorComponent extends Vue {
}
replyToComment(comment: IComment) {
console.log('called replyToComment', comment);
const actorModel = new Actor(comment.actor);
if (!this.editor) return;
console.log(this.editor.commands);
this.editor.commands.mention({ id: actorModel.id, label: actorModel.usernameWithDomain().substring(1) });
this.editor.focus();
}

View File

@ -112,7 +112,7 @@ export default class AddressAutoComplete extends Vue {
addressData: IAddress[] = [];
selected: IAddress = new Address();
isFetching: boolean = false;
queryText: string = this.value && (new Address(this.value)).fullName || '';
queryText: string = (this.value && (new Address(this.value)).fullName) || '';
addressModalActive: boolean = false;
private gettingLocation: boolean = false;
private location!: Position;
@ -164,6 +164,7 @@ export default class AddressAutoComplete extends Vue {
@Watch('value')
updateEditing() {
if (!(this.value && this.value.id)) return;
this.selected = this.value;
const address = new Address(this.selected);
this.queryText = `${address.poiInfos.name} ${address.poiInfos.alternativeName}`;

View File

@ -26,11 +26,11 @@ A button to set your participation
<div class="participation-button">
<b-dropdown aria-role="list" position="is-bottom-left" v-if="participation && participation.role === ParticipantRole.PARTICIPANT">
<button class="button is-success" type="button" slot="trigger">
<b-icon icon="check"></b-icon>
<b-icon icon="check" />
<template>
<span>{{ $t('I participate') }}</span>
</template>
<b-icon icon="menu-down"></b-icon>
<b-icon icon="menu-down" />
</button>
<!-- <b-dropdown-item :value="false" aria-role="listitem">-->
@ -45,11 +45,11 @@ A button to set your participation
<div v-else-if="participation && participation.role === ParticipantRole.NOT_APPROVED">
<b-dropdown aria-role="list" position="is-bottom-left" class="dropdown-disabled">
<button class="button is-success" type="button" slot="trigger">
<b-icon icon="timer-sand-empty"></b-icon>
<b-icon icon="timer-sand-empty" />
<template>
<span>{{ $t('I participate') }}</span>
</template>
<b-icon icon="menu-down"></b-icon>
<b-icon icon="menu-down" />
</button>
<!-- <b-dropdown-item :value="false" aria-role="listitem">-->
@ -73,7 +73,7 @@ A button to set your participation
<template>
<span>{{ $t('Participate') }}</span>
</template>
<b-icon icon="menu-down"></b-icon>
<b-icon icon="menu-down" />
</button>
<b-dropdown-item :value="true" aria-role="listitem" @click="joinEvent(currentActor)">
@ -84,12 +84,12 @@ A button to set your participation
</figure>
</div>
<div class="media-content">
<span>{{ $t('with {identity}', {identity: currentActor.preferredUsername }) }}</span>
<span>{{ $t('as {identity}', {identity: currentActor.preferredUsername }) }}</span>
</div>
</div>
</b-dropdown-item>
<b-dropdown-item :value="false" aria-role="listitem" @click="joinModal">
<b-dropdown-item :value="false" aria-role="listitem" @click="joinModal" v-if="identities.length > 1">
{{ $t('with another identity…')}}
</b-dropdown-item>
</b-dropdown>
@ -99,14 +99,32 @@ A button to set your participation
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IParticipant, ParticipantRole } from '@/types/event.model';
import { IPerson } from '@/types/actor';
import { IPerson, Person } from '@/types/actor';
import { IDENTITIES } from '@/graphql/actor';
import { CURRENT_USER_CLIENT } from '@/graphql/user';
import { ICurrentUser } from '@/types/current-user.model';
@Component
@Component({
apollo: {
currentUser: {
query: CURRENT_USER_CLIENT,
},
identities: {
query: IDENTITIES,
update: ({ identities }) => identities ? identities.map(identity => new Person(identity)) : [],
skip() {
return this.currentUser.isLoggedIn === false;
},
},
},
})
export default class ParticipationButton extends Vue {
@Prop({ required: true }) participation!: IParticipant;
@Prop({ required: true }) currentActor!: IPerson;
ParticipantRole = ParticipantRole;
currentUser!: ICurrentUser;
identities: IPerson[] = [];
joinEvent(actor: IPerson) {
this.$emit('joinEvent', actor);

View File

@ -16,7 +16,7 @@ import { Component, Prop, Vue } from 'vue-property-decorator';
@Component({
beforeDestroy() {
// @ts-ignore
// @ts-ignore
this.parentContainer.removeLayer(this);
},
})

View File

@ -20,7 +20,14 @@
</div>
<div class="content columns">
<div class="column is-one-quarter-desktop">Reported by <img v-if="report.reporter.avatar" class="image" :src="report.reporter.avatar.url" /> @{{ report.reporter.preferredUsername }}</div>
<div class="column is-one-quarter-desktop">
<span v-if="report.reporter.type === ActorType.APPLICATION">
{{ $t('Reported by someone on {domain}', { domain: report.reporter.domain}) }}
</span>
<span v-else>
{{ $t('Reported by {reporter}', { reporter: report.reporter.preferredUsername}) }}
</span>
</div>
<div class="column" v-if="report.content">{{ report.content }}</div>
</div>
</div>
@ -29,10 +36,13 @@
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IReport } from '@/types/report.model';
import { ActorType } from '@/types/actor';
@Component
export default class ReportCard extends Vue {
@Prop({ required: true }) report!: IReport;
ActorType = ActorType;
}
</script>
<style lang="scss">

View File

@ -44,11 +44,8 @@
/>
</div>
<p v-if="outsideDomain">
{{ $t('The content came from another server. Transfer an anonymous copy of the report?') }}
</p>
<div class="control" v-if="outsideDomain">
<p>{{ $t('The content came from another server. Transfer an anonymous copy of the report?') }}</p>
<b-switch v-model="forward">{{ $t('Transfer to {outsideDomain}', { outsideDomain }) }}</b-switch>
</div>
</div>

View File

@ -19,3 +19,87 @@ export const DASHBOARD = gql`
}
}
`;
export const RELAY_FRAGMENT = gql`
fragment relayFragment on Follower {
actor {
id,
preferredUsername,
name,
domain,
type,
summary
},
targetActor {
id,
preferredUsername,
name,
domain,
type,
summary
},
approved,
insertedAt,
updatedAt
}
`;
export const RELAY_FOLLOWERS = gql`
query relayFollowers($page: Int, $limit: Int) {
relayFollowers(page: $page, limit: $limit) {
elements {
...relayFragment
},
total
}
}
${RELAY_FRAGMENT}
`;
export const RELAY_FOLLOWINGS = gql`
query relayFollowings($page: Int, $limit: Int) {
relayFollowings(page: $page, limit: $limit) {
elements {
...relayFragment
},
total
}
}
${RELAY_FRAGMENT}
`;
export const ADD_RELAY = gql`
mutation addRelay($address: String!) {
addRelay(address: $address) {
...relayFragment
}
}
${RELAY_FRAGMENT}
`;
export const REMOVE_RELAY = gql`
mutation removeRelay($address: String!) {
removeRelay(address: $address) {
...relayFragment
}
}
${RELAY_FRAGMENT}
`;
export const ACCEPT_RELAY = gql`
mutation acceptRelay($address: String!) {
acceptRelay(address: $address) {
...relayFragment
}
}
${RELAY_FRAGMENT}
`;
export const REJECT_RELAY = gql`
mutation rejectRelay($address: String!) {
rejectRelay(address: $address) {
...relayFragment
}
}
${RELAY_FRAGMENT}
`;

View File

@ -13,6 +13,7 @@ export const COMMENT_FIELDS_FRAGMENT = gql`
url
},
id,
domain,
preferredUsername,
name
},

View File

@ -10,7 +10,8 @@ const participantQuery = `
url
},
name,
id
id,
domain
},
event {
id
@ -441,3 +442,21 @@ export const EVENT_PERSON_PARTICIPATION = gql`
}
}
`;
export const EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED = gql`
subscription ($actorId: ID!, $eventId: ID!) {
eventPersonParticipationChanged(personId: $actorId) {
id,
participations(eventId: $eventId) {
id,
role,
actor {
id
},
event {
id
}
}
}
}
`;

View File

@ -18,7 +18,9 @@ export const REPORTS = gql`
name,
avatar {
url
}
},
domain,
type
},
event {
id,
@ -52,7 +54,9 @@ const REPORT_FRAGMENT = gql`
name,
avatar {
url
}
},
domain,
type
},
event {
id,
@ -111,9 +115,10 @@ export const CREATE_REPORT = gql`
$reporterId: ID!,
$reportedId: ID!,
$content: String,
$commentsIds: [ID]
$commentsIds: [ID],
$forward: Boolean
) {
createReport(eventId: $eventId, reporterId: $reporterId, reportedId: $reportedId, content: $content, commentsIds: $commentsIds) {
createReport(eventId: $eventId, reporterId: $reporterId, reportedId: $reportedId, content: $content, commentsIds: $commentsIds, forward: $forward) {
id
}
}

View File

@ -322,7 +322,6 @@
"resend confirmation email": "Bestätigungsmail erneut senden",
"respect of the fundamental freedoms": "Respekt für die fundamentalen Freiheiten",
"with another identity…": "mit einer anderen Identität.…",
"with {identity}": "mit {identity}",
"{approved} / {total} seats": "{approved} / {total} Plätze",
"{count} participants": "Noch keine Teilnehmer | Ein Teilnehmer | {count} Teilnehmer",
"{count} requests waiting": "{count} Anfragen ausstehend",

View File

@ -333,11 +333,59 @@
"resend confirmation email": "resend confirmation email",
"respect of the fundamental freedoms": "respect of the fundamental freedoms",
"with another identity…": "with another identity…",
"with {identity}": "with {identity}",
"as {identity}": "as {identity}",
"{approved} / {total} seats": "{approved} / {total} seats",
"{count} participants": "No participants yet | One participant | {count} participants",
"{count} requests waiting": "{count} requests waiting",
"{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.",
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks",
"© The OpenStreetMap Contributors": "© The OpenStreetMap Contributors"
"© The OpenStreetMap Contributors": "© The OpenStreetMap Contributors",
"Reply": "Reply",
"Accepted": "Accepted",
"Pending": "Pending",
"No instance to remove|Remove instance|Remove {number} instances": "No instances to remove|Remove instance|Remove {number} instances",
"Dashboard": "Dashboard",
"Reports": "Reports",
"Mark as resolved": "Mark as resolved",
"Reopen": "Reopen",
"Close": "Close",
"Reported identity": "Reported identity",
"Reported by": "Reported by",
"Reported": "Reported",
"Updated": "Updated",
"Open": "Open",
"Closed": "Closed",
"Resolved": "Resolved",
"Unknown": "Unknown",
"No comment": "No comment",
"Notes": "Notes",
"New note": "New note",
"Add a note": "Add a note",
"Deleting event": "Deleting event",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.": "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.",
"Delete Event": "Delete Event",
"Type": "Type",
"Domain": "Domain",
"Date": "Date",
"No instance to approve|Approve instance|Approve {number} instances": "No instance to approve|Approve instance|Approve {number} instances",
"No instance to reject|Reject instance|Reject {number} instances": "No instance to reject|Reject instance|Reject {number} instances",
"No instance follows your instance yet.": "No instance follows your instance yet.",
"Followers": "Followers",
"Add an instance": "Add an instance",
"Ex: test.mobilizon.org": "Ex: test.mobilizon.org",
"You don't follow any instances yet.": "You don't follow any instances yet.",
"Followings": "Followings",
"Instances": "Instances",
"Reported by {reporter}": "Reported by {reporter}",
"No open reports yet": "No open reports yet",
"No resolved reports yet": "No resolved reports yet",
"No closed reports yet": "No closed reports yet",
"Reported by someone on {domain}": "Reported by someone on {domain}",
"Your participation has been rejected": "Your participation has been rejected",
"Your participation status has been changed": "Your participation status has been changed",
"Unknown actor": "Unknown actor",
"Deleting comment": "Deleting comment",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Are you sure you want to <b>delete</b> this comment? This action cannot be undone.",
"Delete Comment": "Delete Comment",
"Comment deleted": "Comment deleted"
}

View File

@ -286,7 +286,7 @@
"Update my event": "Éditer mon événement",
"User accounts and every other data is currently deleted every 48 hours, so you may want to register again.": "Les comptes utilisateurs et toutes les autres données sont actuellement supprimées toutes les 48 heures, donc vous voulez peut-être vous inscrire à nouveau.",
"Username": "Pseudo",
"Users": "Utilisateurs",
"Users": "Utilisateur⋅ice⋅s",
"View a reply": "Aucune réponse | Voir une réponse | Voir {totalReplies} réponses",
"View event page": "Voir la page de l'événement",
"View everything": "Voir tout",
@ -337,11 +337,57 @@
"resend confirmation email": "réenvoyer l'email de confirmation",
"respect of the fundamental freedoms": "le respect des libertés fondamentales",
"with another identity…": "avec une autre identité…",
"with {identity}": "avec {identity}",
"as {identity}": "en tant que {identity}",
"{approved} / {total} seats": "{approved} / {total} places",
"{count} participants": "Aucun⋅e participant⋅e | Un⋅e participant⋅e | {count} participant⋅e⋅s",
"{count} requests waiting": "Une demande en attente|{count} demandes en attente",
"{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} garantit {respect} des personnes qui l'utiliseront. Puisque {source}, il est publiquement auditable, ce qui garantit sa transparence.",
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© Les contributeurs de Mobilizon {date} - Fait avec Elixir, Phoenix, VueJS & et de l'amour et des semaines",
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap"
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
"Reply": "Répondre",
"Accepted": "Accepté",
"Pending": "En attente",
"No instance to remove|Remove instance|Remove {number} instances": "Pas d'instances à supprimer|Supprimer une instance|Supprimer {number} instances",
"Mark as resolved": "Marquer comme résolu",
"Reopen": "Réouvrir",
"Close": "Fermé",
"Reported identity": "Identité signalée",
"Reported by": "Signalée par",
"Reported": "Signalée",
"Updated": "Mis à jour",
"Open": "Ouvert",
"Closed": "Fermé",
"Resolved": "Résolu",
"Unknown": "Inconnu",
"No comment": "Pas de commentaire",
"Notes": "Notes",
"New note": "Nouvelle note",
"Add a note": "Ajouter une note",
"Deleting event": "Suppression de l'événement",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> cet événement ? Cette action n'est pas réversible. Vous voulez peut-être engager la conversation avec le créateur de l'événement ou bien éditer son événement à la place.",
"Delete Event": "Supprimer l'événement",
"Type": "Type",
"Domain": "Domaine",
"Date": "Date",
"No instance to approve|Approve instance|Approve {number} instances": "Aucune instance à approuver|Approuver une instance|Approuver {number} instances",
"No instance to reject|Reject instance|Reject {number} instances": "Aucune instance à rejetter|Rejetter une instance|Rejetter {number} instances",
"No instance follows your instance yet.": "Aucune instance ne suit votre instance pour le moment.",
"Followers": "Abonnés",
"Add an instance": "Ajouter une instance",
"Ex: test.mobilizon.org": "Ex: test.mobilizon.org",
"You don't follow any instances yet.": "Vous ne suivez aucune instance pour le moment.",
"Followings": "Abonnements",
"Instances": "Instances",
"Reported by {reporter}": "Signalé par {reporter}",
"No open reports yet": "Aucun signalement ouvert pour le moment",
"No resolved reports yet": "Aucun signalement résolu pour le moment",
"No closed reports yet": "Aucun signalement fermé pour le moment",
"Reported by someone on {domain}": "Signalé par quelqu'un depuis {domain}",
"Your participation has been rejected": "Votre participation a été rejettée",
"Your participation status has been changed": "Le statut de votre participation a été mis à jour",
"Unknown actor": "Acteur inconnu",
"Deleting comment": "Suppression du commentaire en cours",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> ce commentaire? Cette action ne peut pas être annulée.",
"Delete Comment": "Supprimer le commentaire",
"Comment deleted": "Commentaire supprimé"
}

View File

@ -321,7 +321,6 @@
"resend confirmation email": "bevestigingsemail opnieuw versturen",
"respect of the fundamental freedoms": "respect voor de fundamentele vrijheden",
"with another identity…": "met een andere identiteit…",
"with {identity}": "met {identity}",
"{approved} / {total} seats": "{approved} / {total} plaatsen",
"{count} participants": "Nog geen deelnemers | Eén deelnemer | {count} deelnemers",
"{count} requests waiting": "{count} aanvragen in afwachting",

View File

@ -368,7 +368,6 @@
"resend confirmation email": "tornar enviar lo messatge de confirmacion",
"respect of the fundamental freedoms": "lo respet de las libertats fondamentalas",
"with another identity…": "amb una autra identitat…",
"with {identity}": "amb {identity}",
"{actor}'s avatar": "Avatar de {actor}",
"{approved} / {total} seats": "{approved} / {total} plaças",
"{count} participants": "Cap de participacion pel moment|Un participant|{count} participants",

View File

@ -324,7 +324,6 @@
"resend confirmation email": "skicka bekräftelsemail igen",
"respect of the fundamental freedoms": "respektera våra grundläggande friheter",
"with another identity…": "med en annan identitet…",
"with {identity}": "med {identity}",
"{approved} / {total} seats": "{approved} / {total} platser",
"{count} participants": "Inga deltagande ännu|En deltagande|{count} deltagande",
"{count} requests waiting": "{count} förfrågningar väntar",

44
js/src/mixins/relay.ts Normal file
View File

@ -0,0 +1,44 @@
import { Component, Vue } from 'vue-property-decorator';
import { Refs } from '@/shims-vue';
import { ActorType, IActor } from '@/types/actor';
import { IFollower } from '@/types/actor/follower.model';
@Component
export default class RelayMixin extends Vue {
$refs!: Refs<{
table: any,
}>;
checkedRows: IFollower[] = [];
page: number = 1;
perPage: number = 2;
toggle(row) {
this.$refs.table.toggleDetails(row);
}
async onPageChange(page: number) {
this.page = page;
await this.$apollo.queries.relayFollowings.fetchMore({
variables: {
page: this.page,
limit: this.perPage,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newFollowings = fetchMoreResult.relayFollowings.elements;
return {
relayFollowings: {
__typename: previousResult.relayFollowings.__typename,
total: previousResult.relayFollowings.total,
elements: [...previousResult.relayFollowings.elements, ...newFollowings],
},
};
},
});
}
isInstance(actor: IActor): boolean {
return actor.type === ActorType.APPLICATION && actor.preferredUsername === 'relay';
}
}

View File

@ -1,10 +1,12 @@
import Vue from 'vue';
import { ColorModifiers } from 'buefy/types/helpers';
declare module 'vue/types/vue' {
interface Vue {
$notifier: {
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
};
}
}
@ -17,21 +19,23 @@ export class Notifier {
}
success(message: string) {
this.vue.prototype.$buefy.notification.open({
message,
duration: 5000,
position: 'is-bottom-right',
type: 'is-success',
hasIcon: true,
});
this.notification(message, 'is-success');
}
error(message: string) {
this.notification(message, 'is-danger');
}
info(message: string) {
this.notification(message, 'is-info');
}
private notification(message: string, type: ColorModifiers) {
this.vue.prototype.$buefy.notification.open({
message,
duration: 5000,
position: 'is-bottom-right',
type: 'is-danger',
type,
hasIcon: true,
});
}

View File

@ -1,8 +1,14 @@
import { RouteConfig } from 'vue-router';
import Dashboard from '@/views/Admin/Dashboard.vue';
import Follows from '@/views/Admin/Follows.vue';
import Followings from '@/components/Admin/Followings.vue';
import Followers from '@/components/Admin/Followers.vue';
export enum AdminRouteName {
DASHBOARD = 'Dashboard',
RELAYS = 'Relays',
RELAY_FOLLOWINGS = 'Followings',
RELAY_FOLLOWERS = 'Followers',
}
export const adminRoutes: RouteConfig[] = [
@ -13,4 +19,24 @@ export const adminRoutes: RouteConfig[] = [
props: true,
meta: { requiredAuth: true },
},
{
path: '/admin/relays',
name: AdminRouteName.RELAYS,
redirect: { name: AdminRouteName.RELAY_FOLLOWINGS },
component: Follows,
children: [
{
path: 'followings',
name: AdminRouteName.RELAY_FOLLOWINGS,
component: Followings,
},
{
path: 'followers',
name: AdminRouteName.RELAY_FOLLOWERS,
component: Followers,
},
],
props: true,
meta: { requiredAuth: true },
},
];

View File

@ -1,5 +1,13 @@
import { IPicture } from '@/types/picture.model';
export enum ActorType {
PERSON = 'PERSON',
APPLICATION = 'APPLICATION',
GROUP = 'GROUP',
ORGANISATION = 'ORGANISATION',
SERVICE = 'SERVICE',
}
export interface IActor {
id?: number;
url: string;
@ -10,6 +18,7 @@ export interface IActor {
suspended: boolean;
avatar: IPicture | null;
banner: IPicture | null;
type: ActorType;
}
export class Actor implements IActor {
@ -22,6 +31,7 @@ export class Actor implements IActor {
summary: string = '';
suspended: boolean = false;
url: string = '';
type: ActorType = ActorType.PERSON;
constructor (hash: IActor | {} = {}) {
Object.assign(this, hash);

View File

@ -0,0 +1,8 @@
import { IActor } from '@/types/actor/actor.model';
export interface IFollower {
id?: string;
actor: IActor;
targetActor: IActor;
approved: boolean;
}

View File

@ -242,7 +242,7 @@ export class EventModel implements IEvent {
this.onlineAddress = hash.onlineAddress;
this.phoneAddress = hash.phoneAddress;
this.physicalAddress = new Address(hash.physicalAddress);
this.physicalAddress = hash.physicalAddress ? new Address(hash.physicalAddress) : undefined;
this.participantStats = hash.participantStats;
this.tags = hash.tags;

4
js/src/types/paginate.ts Normal file
View File

@ -0,0 +1,4 @@
export interface Paginate<T> {
elements: T[];
total: number;
}

View File

@ -7,7 +7,7 @@
<div class="list is-hoverable">
<a class="list-item" v-for="identity in identities" :class="{ 'is-active': identity.id === currentIdentity.id }" @click="changeCurrentIdentity(identity)">
<div class="media">
<img class="media-left image" v-if="identity.avatar" :src="identity.avatar.url" alt="" />
<img class="media-left image is-48x48" v-if="identity.avatar" :src="identity.avatar.url" alt="" />
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
<div class="media-content">
<h3>@{{ identity.preferredUsername }}</h3>
@ -17,7 +17,7 @@
</a>
</div>
</section>
<slot name="footer"></slot>
<slot name="footer" />
</div>
</template>
<script lang="ts">

View File

@ -1,7 +1,9 @@
<template>
<div class="identity-picker">
<span v-if="inline" class="inline">
<img class="image" v-if="currentIdentity.avatar" :src="currentIdentity.avatar.url" :alt="currentIdentity.avatar.alt"/> {{ currentIdentity.name || `@${currentIdentity.preferredUsername}` }}
<img class="image" v-if="currentIdentity.avatar" :src="currentIdentity.avatar.url" :alt="currentIdentity.avatar.alt"/>
<b-icon v-else size="is-small" icon="account-circle" />
{{ currentIdentity.name || `@${currentIdentity.preferredUsername}` }}
<b-button type="is-text" @click="isComponentModalActive = true">
{{ $t('Change') }}
</b-button>

View File

@ -38,6 +38,13 @@
</article>
</router-link>
</div>
<div class="tile is-parent">
<router-link :to="{ name: RouteName.RELAYS }">
<article class="tile is-child box">
<p class="subtitle">{{ $t('Instances') }}</p>
</article>
</router-link>
</div>
</div>
<div class="tile is-parent">
<article class="tile is-child box">
@ -67,6 +74,12 @@ import { RouteName } from '@/router';
query: DASHBOARD,
},
},
metaInfo() {
return {
title: this.$t('Administration') as string,
titleTemplate: '%s | Mobilizon',
};
},
})
export default class Dashboard extends Vue {
dashboard!: IDashboard;

View File

@ -0,0 +1,57 @@
<template>
<div class="container">
<h1 class="title">{{ $t('Instances') }}</h1>
<div class="tabs is-boxed">
<ul>
<router-link tag="li" active-class="is-active" :to="{name: RouteName.RELAY_FOLLOWINGS}" exact>
<a>
<b-icon icon="inbox-arrow-down"></b-icon>
<span>{{ $t('Followings') }} <b-tag rounded> {{ relayFollowings.total }} </b-tag> </span>
</a>
</router-link>
<router-link tag="li" active-class="is-active" :to="{name: RouteName.RELAY_FOLLOWERS}" exact>
<a>
<b-icon icon="inbox-arrow-up"></b-icon>
<span>{{ $t('Followers') }} <b-tag rounded> {{ relayFollowers.total }} </b-tag> </span>
</a>
</router-link>
</ul>
</div>
<router-view></router-view>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { RouteName } from '@/router';
import { RELAY_FOLLOWERS, RELAY_FOLLOWINGS } from '@/graphql/admin';
import { Paginate } from '@/types/paginate';
import { IFollower } from '@/types/actor/follower.model';
@Component({
apollo: {
relayFollowings: {
query: RELAY_FOLLOWINGS,
fetchPolicy: 'cache-and-network',
},
relayFollowers: {
query: RELAY_FOLLOWERS,
fetchPolicy: 'cache-and-network',
},
},
})
export default class Follows extends Vue {
RouteName = RouteName;
activeTab: number = 0;
relayFollowings: Paginate<IFollower> = { elements: [], total: 0 };
relayFollowers: Paginate<IFollower> = { elements: [], total: 0 };
}
</script>
<style lang="scss">
.tab-item {
form {
margin-bottom: 1.5rem;
}
}
</style>

View File

@ -29,7 +29,7 @@
<address-auto-complete v-model="event.physicalAddress" />
<b-field :label="$t('Organizer')">
<identity-picker-wrapper v-model="event.organizerActor"></identity-picker-wrapper>
<identity-picker-wrapper v-model="event.organizerActor" />
</b-field>
<div class="field">
@ -92,7 +92,7 @@
<div class="box" v-if="limitedPlaces">
<b-field :label="$t('Number of places')">
<b-numberinput controls-position="compact" min="0" v-model="event.options.maximumAttendeeCapacity"></b-numberinput>
<b-numberinput controls-position="compact" min="0" v-model="event.options.maximumAttendeeCapacity" />
</b-field>
<!--
<b-field>
@ -145,21 +145,21 @@
name="status"
type="is-warning"
:native-value="EventStatus.TENTATIVE">
<b-icon icon="calendar-question"></b-icon>
<b-icon icon="calendar-question" />
{{ $t('Tentative: Will be confirmed later') }}
</b-radio-button>
<b-radio-button v-model="event.status"
name="status"
type="is-success"
:native-value="EventStatus.CONFIRMED">
<b-icon icon="calendar-check"></b-icon>
<b-icon icon="calendar-check" />
{{ $t('Confirmed: Will happen') }}
</b-radio-button>
<b-radio-button v-model="event.status"
name="status"
type="is-danger"
:native-value="EventStatus.CANCELLED">
<b-icon icon="calendar-remove"></b-icon>
<b-icon icon="calendar-remove" />
{{ $t("Cancelled: Won't happen") }}
</b-radio-button>
</b-field>
@ -191,7 +191,7 @@
</div>
</form>
</b-modal>
<span ref="bottomObserver"></span>
<span ref="bottomObserver" />
<nav role="navigation" aria-label="main navigation" class="navbar" :class="{'is-fixed-bottom': showFixedNavbar }">
<div class="container">
<div class="navbar-menu">
@ -395,6 +395,11 @@ export default class EditEvent extends Vue {
}
}
@Watch('currentActor')
setCurrentActor() {
this.event.organizerActor = this.currentActor;
}
private validateForm() {
const form = this.$refs.form as HTMLFormElement;
if (form.checkValidity()) {

View File

@ -1,6 +1,8 @@
import {ParticipantRole} from "@/types/event.model";
import {ParticipantRole} from "@/types/event.model";
<template>
<div class="container">
<b-loading :active.sync="$apollo.loading"></b-loading>
<b-loading :active.sync="$apollo.loading" />
<transition appear name="fade" mode="out-in">
<div>
<div class="header-picture" v-if="event.picture" :style="`background-image: url('${event.picture.url}')`" />
@ -9,7 +11,7 @@
<div class="title-and-participate-button">
<div class="title-wrapper">
<div class="date-component">
<date-calendar-icon :date="event.beginsOn"></date-calendar-icon>
<date-calendar-icon :date="event.beginsOn" />
</div>
<div class="title-and-informations">
<h1 class="title">{{ event.title }}</h1>
@ -49,7 +51,7 @@
<template>
<span>{{ $t('Event already passed')}}</span>
</template>
<b-icon icon="menu-down"></b-icon>
<b-icon icon="menu-down" />
</button>
</div>
</div>
@ -65,6 +67,9 @@
<b-tag type="is-info" v-if="event.visibility === EventVisibility.PUBLIC">{{ $t('Public event') }}</b-tag>
<b-tag type="is-info" v-if="event.visibility === EventVisibility.UNLISTED">{{ $t('Private event') }}</b-tag>
</span>
<span v-if="!event.local">
<b-tag type="is-primary">{{ event.organizerActor.domain }}</b-tag>
</span>
<router-link
v-if="event.tags && event.tags.length > 0"
v-for="tag in event.tags"
@ -136,7 +141,7 @@
</b-modal>
</div>
<span class="online-address" v-if="event.onlineAddress && urlToHostname(event.onlineAddress)">
<b-icon icon="link"></b-icon>
<b-icon icon="link" />
<a
target="_blank"
rel="noopener noreferrer"
@ -250,8 +255,14 @@
</template>
<script lang="ts">
import { EVENT_PERSON_PARTICIPATION, FETCH_EVENT, JOIN_EVENT, LEAVE_EVENT } from '@/graphql/event';
import { Component, Prop } from 'vue-property-decorator';
import {
EVENT_PERSON_PARTICIPATION,
EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
FETCH_EVENT,
JOIN_EVENT,
LEAVE_EVENT,
} from '@/graphql/event';
import { Component, Prop, Watch } from 'vue-property-decorator';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { EventModel, EventStatus, EventVisibility, IEvent, IParticipant, ParticipantRole } from '@/types/event.model';
import { IPerson, Person } from '@/types/actor';
@ -311,6 +322,15 @@ import 'intersection-observer';
actorId: this.currentActor.id,
};
},
subscribeToMore: {
document: EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
variables() {
return {
eventId: this.event.id,
actorId: this.currentActor.id,
};
},
},
update: (data) => {
if (data && data.person) return data.person.participations;
return [];
@ -341,6 +361,7 @@ export default class Event extends EventMixin {
currentActor!: IPerson;
identity: IPerson = new Person();
participations: IParticipant[] = [];
oldParticipationRole!: String;
showMap: boolean = false;
isReportModalActive: boolean = false;
isJoinModalActive: boolean = false;
@ -432,14 +453,10 @@ export default class Event extends EventMixin {
reporterId: this.currentActor.id,
reportedId: this.event.organizerActor.id,
content,
forward,
},
});
this.$buefy.notification.open({
message: this.$t('Event {eventTitle} reported', { eventTitle }) as string,
type: 'is-success',
position: 'is-bottom-right',
duration: 5000,
});
this.$notifier.success(this.$t('Event {eventTitle} reported', { eventTitle }) as string);
} catch (error) {
console.error(error);
}
@ -493,12 +510,11 @@ export default class Event extends EventMixin {
},
});
if (data) {
this.$buefy.notification.open({
message: (data.joinEvent.role === ParticipantRole.NOT_APPROVED ? this.$t('Your participation has been requested') : this.$t('Your participation has been confirmed')) as string,
type: 'is-success',
position: 'is-bottom-right',
duration: 5000,
});
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
this.participationRequestedMessage();
} else {
this.participationConfirmedMessage();
}
}
} catch (error) {
console.error(error);
@ -563,18 +579,55 @@ export default class Event extends EventMixin {
},
});
if (data) {
this.$buefy.notification.open({
message: this.$t('You have cancelled your participation') as string,
type: 'is-success',
position: 'is-bottom-right',
duration: 5000,
});
this.participationCancelledMessage();
}
} catch (error) {
console.error(error);
}
}
@Watch('participations')
watchParticipations() {
if (this.participations.length > 0) {
if (this.oldParticipationRole
&& this.participations[0].role !== ParticipantRole.NOT_APPROVED
&& this.oldParticipationRole !== this.participations[0].role) {
switch (this.participations[0].role) {
case ParticipantRole.PARTICIPANT:
this.participationConfirmedMessage();
break;
case ParticipantRole.REJECTED:
this.participationRejectedMessage();
break;
default:
this.participationChangedMessage();
break;
}
}
this.oldParticipationRole = this.participations[0].role;
}
}
private participationConfirmedMessage() {
this.$notifier.success(this.$t('Your participation has been confirmed') as string);
}
private participationRequestedMessage() {
this.$notifier.success(this.$t('Your participation has been requested') as string);
}
private participationRejectedMessage() {
this.$notifier.error(this.$t('Your participation has been rejected') as string);
}
private participationChangedMessage() {
this.$notifier.info(this.$t('Your participation status has been changed') as string);
}
private participationCancelledMessage() {
this.$notifier.success(this.$t('You have cancelled your participation') as string);
}
async downloadIcsEvent() {
const data = await (await fetch(`${GRAPHQL_API_ENDPOINT}/events/${this.uuid}/export/ics`)).text();
const blob = new Blob([data], { type: 'text/calendar' });

View File

@ -64,7 +64,7 @@ export default class ReportList extends Vue {
RouteName = RouteName;
}
</script>
<style lang="scss">
<style lang="scss" scoped>
.container li {
margin: 10px auto;
}

View File

@ -27,7 +27,10 @@
</tr>
<tr>
<td>{{ $t('Reported by') }}</td>
<td>
<td v-if="report.reporter.type === ActorType.APPLICATION">
{{ report.reporter.domain }}
</td>
<td v-else>
<router-link :to="{ name: RouteName.PROFILE, params: { name: report.reporter.preferredUsername } }">
<img v-if="report.reporter.avatar" class="image" :src="report.reporter.avatar.url" /> @{{ report.reporter.preferredUsername }}
</router-link>
@ -55,15 +58,15 @@
<td>
<router-link :to="{ name: RouteName.EVENT, params: { uuid: report.event.uuid }}">{{ report.event.title }}</router-link>
<span class="is-pulled-right">
<b-button
tag="router-link"
type="is-primary"
:to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"
icon-left="pencil"
size="is-small">{{ $t('Edit') }}</b-button>
<!-- <b-button-->
<!-- tag="router-link"-->
<!-- type="is-primary"-->
<!-- :to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"-->
<!-- icon-left="pencil"-->
<!-- size="is-small">{{ $t('Edit') }}</b-button>-->
<b-button
type="is-danger"
@click="confirmDelete()"
@click="confirmEventDelete()"
icon-left="delete"
size="is-small">{{ $t('Delete') }}</b-button>
</span>
@ -74,24 +77,24 @@
</div>
<div class="box report-content">
<p v-if="report.content" v-html="nl2br(report.content)"></p>
<p v-if="report.content" v-html="nl2br(report.content)" />
<p v-else>{{ $t('No comment') }}</p>
</div>
<div class="box" v-if="report.event && report.comments.length === 0">
<router-link :to="{ name: RouteName.EVENT, params: { uuid: report.event.uuid }}">
<h3 class="title">{{ report.event.title }}</h3>
<p v-html="report.event.description"></p>
<p v-html="report.event.description" />
</router-link>
<b-button
tag="router-link"
type="is-primary"
:to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"
icon-left="pencil"
size="is-small">{{ $t('Edit') }}</b-button>
<!-- <b-button-->
<!-- tag="router-link"-->
<!-- type="is-primary"-->
<!-- :to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"-->
<!-- icon-left="pencil"-->
<!-- size="is-small">{{ $t('Edit') }}</b-button>-->
<b-button
type="is-danger"
@click="confirmDelete()"
@click="confirmEventDelete()"
icon-left="delete"
size="is-small">{{ $t('Delete') }}</b-button>
</div>
@ -101,17 +104,25 @@
<div class="box" v-if="comment">
<article class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="comment.actor.avatar">
<figure class="image is-48x48" v-if="comment.actor && comment.actor.avatar">
<img :src="comment.actor.avatar.url" alt="Image">
</figure>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
</div>
<div class="media-content">
<div class="content">
<strong>{{ comment.actor.name }}</strong> <small>@{{ comment.actor.preferredUsername }}</small>
<span v-if="comment.actor">
<strong>{{ comment.actor.name }}</strong> <small>@{{ comment.actor.preferredUsername }}</small>
</span>
<span v-else>{{ $t('Unknown actor') }}</span>
<br>
<p v-html="comment.text"></p>
<p v-html="comment.text" />
</div>
<b-button
type="is-danger"
@click="confirmCommentDelete(comment)"
icon-left="delete"
size="is-small">{{ $t('Delete') }}</b-button>
</div>
</article>
</div>
@ -131,21 +142,23 @@
<b-field :label="$t('New note')">
<b-input type="textarea" v-model="noteContent"></b-input>
</b-field>
<b-button type="submit" @click="addNote">{{ $t('Ajouter une note') }}</b-button>
<b-button type="submit" @click="addNote">{{ $t('Add a note') }}</b-button>
</form>
</div>
</section>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { CREATE_REPORT_NOTE, REPORT, REPORTS, UPDATE_REPORT } from '@/graphql/report';
import { CREATE_REPORT_NOTE, REPORT, UPDATE_REPORT } from '@/graphql/report';
import { IReport, IReportNote, ReportStatusEnum } from '@/types/report.model';
import { RouteName } from '@/router';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import { IPerson } from '@/types/actor';
import { IPerson, ActorType } from '@/types/actor';
import { DELETE_EVENT } from '@/graphql/event';
import { uniq } from 'lodash';
import { nl2br } from '@/utils/html';
import { DELETE_COMMENT } from '@/graphql/comment';
import { IComment } from '@/types/comment.model';
@Component({
apollo: {
@ -164,6 +177,12 @@ import { nl2br } from '@/utils/html';
query: CURRENT_ACTOR_CLIENT,
},
},
metaInfo() {
return {
title: this.$t('Report') as string,
titleTemplate: '%s | Mobilizon',
};
},
})
export default class Report extends Vue {
@Prop({ required: true }) reportId!: number;
@ -173,6 +192,7 @@ export default class Report extends Vue {
ReportStatusEnum = ReportStatusEnum;
RouteName = RouteName;
ActorType = ActorType;
nl2br = nl2br;
noteContent: string = '';
@ -210,7 +230,7 @@ export default class Report extends Vue {
}
}
confirmDelete() {
confirmEventDelete() {
this.$buefy.dialog.confirm({
title: this.$t('Deleting event') as string,
message: this.$t('Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.') as string,
@ -221,6 +241,17 @@ export default class Report extends Vue {
});
}
confirmCommentDelete(comment: IComment) {
this.$buefy.dialog.confirm({
title: this.$t('Deleting comment') as string,
message: this.$t('Are you sure you want to <b>delete</b> this comment? This action cannot be undone.') as string,
confirmText: this.$t('Delete Comment') as string,
type: 'is-danger',
hasIcon: true,
onConfirm: () => this.deleteComment(comment),
});
}
async deleteEvent() {
if (!this.report.event || !this.report.event.id) return;
const eventTitle = this.report.event.title;
@ -245,6 +276,21 @@ export default class Report extends Vue {
}
}
async deleteComment(comment: IComment) {
try {
await this.$apollo.mutate({
mutation: DELETE_COMMENT,
variables: {
commentId: comment.id,
actorId: this.currentActor.id,
},
});
this.$notifier.success(this.$t('Comment deleted') as string);
} catch (error) {
console.error(error);
}
}
async updateReport(status: ReportStatusEnum) {
try {
await this.$apollo.mutate({
@ -289,10 +335,6 @@ export default class Report extends Vue {
<style lang="scss" scoped>
@import "@/variables.scss";
.container li {
margin: 10px auto;
}
tbody td img.image, .note img.image {
display: inline;
height: 1.5em;

View File

@ -9,15 +9,15 @@
<b-field>
<b-radio-button v-model="filterReports"
:native-value="ReportStatusEnum.OPEN">
Ouvert
{{ $t('Open') }}
</b-radio-button>
<b-radio-button v-model="filterReports"
:native-value="ReportStatusEnum.RESOLVED">
Résolus
{{ $t('Resolved') }}
</b-radio-button>
<b-radio-button v-model="filterReports"
:native-value="ReportStatusEnum.CLOSED">
Fermés
{{ $t('Closed') }}
</b-radio-button>
</b-field>
<ul v-if="reports.length > 0">
@ -28,9 +28,9 @@
</li>
</ul>
<div v-else>
<b-message v-if="filterReports === ReportStatusEnum.OPEN" type="is-info">No open reports yet</b-message>
<b-message v-if="filterReports === ReportStatusEnum.RESOLVED" type="is-info">No resolved reports yet</b-message>
<b-message v-if="filterReports === ReportStatusEnum.CLOSED" type="is-info">No closed reports yet</b-message>
<b-message v-if="filterReports === ReportStatusEnum.OPEN" type="is-info">{{ $t('No open reports yet') }}</b-message>
<b-message v-if="filterReports === ReportStatusEnum.RESOLVED" type="is-info">{{ $t('No resolved reports yet') }}</b-message>
<b-message v-if="filterReports === ReportStatusEnum.CLOSED" type="is-info">{{ $t('No closed reports yet') }}</b-message>
</div>
</section>
</template>
@ -80,8 +80,3 @@ export default class ReportList extends Vue {
}
}
</script>
<style lang="scss">
.container li {
margin: 10px auto;
}
</style>

View File

@ -1,10 +1,10 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { ApolloLink, Observable } from 'apollo-link';
import { ApolloLink, Observable, split } from 'apollo-link';
import { defaultDataIdFromObject, InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import { onError } from 'apollo-link-error';
import { createLink } from 'apollo-absinthe-upload-link';
import { GRAPHQL_API_ENDPOINT, GRAPHQL_API_FULL_PATH } from './api/_entrypoint';
import { GRAPHQL_API_ENDPOINT, GRAPHQL_API_FULL_PATH, MOBILIZON_INSTANCE_HOST } from './api/_entrypoint';
import { ApolloClient } from 'apollo-client';
import { buildCurrentUserResolver } from '@/apollo/user';
import { isServerError } from '@/types/apollo';
@ -13,13 +13,18 @@ import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN } from '@/constants';
import { logout, saveTokenData } from '@/utils/auth';
import { SnackbarProgrammatic as Snackbar } from 'buefy';
import { defaultError, errors, IError, refreshSuggestion } from '@/utils/errors';
import { Socket as PhoenixSocket } from 'phoenix';
import * as AbsintheSocket from '@absinthe/socket';
import { createAbsintheSocketLink } from '@absinthe/socket-apollo-link';
import { getMainDefinition } from 'apollo-utilities';
// Install the vue plugin
Vue.use(VueApollo);
// Http endpoint
// Endpoints
const httpServer = GRAPHQL_API_ENDPOINT || 'http://localhost:4000';
const httpEndpoint = GRAPHQL_API_FULL_PATH || `${httpServer}/api`;
const wsEndpoint = `ws${httpServer.substring(httpServer.indexOf(':'))}/graphql_socket`;
const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData: {
@ -60,10 +65,6 @@ const authMiddleware = new ApolloLink((operation, forward) => {
return null;
});
const uploadLink = createLink({
uri: httpEndpoint,
});
let refreshingTokenPromise: Promise<boolean> | undefined;
let alreadyRefreshedToken = false;
const errorLink = onError(({ graphQLErrors, networkError, forward, operation }) => {
@ -126,9 +127,38 @@ const computeErrorMessage = (message) => {
return error.suggestRefresh === false ? error.value : `${error.value}<br>${refreshSuggestion}`;
};
const link = authMiddleware
const uploadLink = createLink({
uri: httpEndpoint,
});
const phoenixSocket = new PhoenixSocket(wsEndpoint, {
params: () => {
const token = localStorage.getItem(AUTH_ACCESS_TOKEN);
if (token) {
return { token };
}
return {};
},
});
const absintheSocket = AbsintheSocket.create(phoenixSocket);
const wsLink = createAbsintheSocketLink(absintheSocket);
const link = split(
// split based on operation type
({ query }) => {
const definition = getMainDefinition(query);
return definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription';
},
wsLink,
uploadLink,
);
const fullLink = authMiddleware
.concat(errorLink)
.concat(uploadLink);
.concat(link);
const cache = new InMemoryCache({
fragmentMatcher,
@ -143,7 +173,7 @@ const cache = new InMemoryCache({
const apolloClient = new ApolloClient({
cache,
link,
link: fullLink,
connectToDevTools: true,
resolvers: buildCurrentUserResolver(cache),
});

View File

@ -2,6 +2,31 @@
# yarn lockfile v1
"@absinthe/socket-apollo-link@^0.2.1":
version "0.2.1"
resolved "https://registry.yarnpkg.com/@absinthe/socket-apollo-link/-/socket-apollo-link-0.2.1.tgz#449c93109c903403b948abb8e911d9847cca9125"
integrity sha512-QxEazdjUXth+XMTAdlODZwS5h7fUAq9LEIH5O/EN0c/pS7Q3dFrTM1ZiP6n/0VdSEc+xBZyTisN63N2cPgE8ZQ==
dependencies:
"@absinthe/socket" "0.2.1"
"@babel/runtime" "7.2.0"
apollo-link "1.2.5"
core-js "2.6.0"
flow-static-land "0.2.8"
graphql "14.0.2"
zen-observable "0.8.11"
"@absinthe/socket@0.2.1", "@absinthe/socket@^0.2.1":
version "0.2.1"
resolved "https://registry.yarnpkg.com/@absinthe/socket/-/socket-0.2.1.tgz#dd0d7bfc8e149f8376429c7fc2e87ac958578b91"
integrity sha512-rCuMRG4WndooGR+QfU5v+xL6U8YKEXFyvjqYt0qTHupAh+k+tpD6a5dlxcLO0g38p/hb1I12OzKvl+0G1XYCkA==
dependencies:
"@babel/runtime" "7.2.0"
"@jumpn/utils-array" "0.3.4"
"@jumpn/utils-composite" "0.7.0"
"@jumpn/utils-graphql" "0.6.0"
core-js "2.6.0"
zen-observable "0.8.11"
"@babel/code-frame@7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.0.0.tgz#06e2ab19bdb535385559aabb5ba59729482800f8"
@ -704,6 +729,13 @@
core-js-pure "^3.0.0"
regenerator-runtime "^0.13.2"
"@babel/runtime@7.2.0":
version "7.2.0"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.2.0.tgz#b03e42eeddf5898e00646e4c840fa07ba8dcad7f"
integrity sha512-oouEibCbHMVdZSDlJBO6bZmID/zA/G/Qx3H1d3rSNPTD+L8UNKvCat7aKWSJ74zYbm5zWGh0GQN0hKj8zYFTCg==
dependencies:
regenerator-runtime "^0.12.0"
"@babel/runtime@^7.0.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.6.3":
version "7.7.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.7.4.tgz#b23a856751e4bf099262f867767889c0e3fe175b"
@ -831,6 +863,35 @@
cssnano-preset-default "^4.0.0"
postcss "^7.0.0"
"@jumpn/utils-array@0.3.4":
version "0.3.4"
resolved "https://registry.yarnpkg.com/@jumpn/utils-array/-/utils-array-0.3.4.tgz#fb4310120108f659dab54075ef93abc56137de5e"
integrity sha1-+0MQEgEI9lnatUB175OrxWE33l4=
dependencies:
babel-polyfill "6.26.0"
babel-runtime "6.26.0"
flow-static-land "0.2.7"
"@jumpn/utils-composite@0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@jumpn/utils-composite/-/utils-composite-0.7.0.tgz#1979db00dd9465ebc33826adab17f1dd48e6fd0c"
integrity sha512-kamRVYJLNvjMrnKKeu2RSFQHLUO/IYFo05gLI7GQcCk063mJzsjCCfRycCievIBI+5Sg8C7A5gwRYxkBA5jY8w==
dependencies:
"@jumpn/utils-array" "0.3.4"
babel-polyfill "6.26.0"
babel-runtime "6.26.0"
fast-deep-equal "1.0.0"
flow-static-land "0.2.8"
"@jumpn/utils-graphql@0.6.0":
version "0.6.0"
resolved "https://registry.yarnpkg.com/@jumpn/utils-graphql/-/utils-graphql-0.6.0.tgz#9afd384c14e3f4caf68fa3ebeb3270218b18e931"
integrity sha512-I5OSEh8Ed4FdLIcUTYzWdpO9noQOoWptdgF8yOZ0xhDD7h7E9IgPYxfy36qbC6v9xlpGTwQMu3Wn8ulkinG/MQ==
dependencies:
"@babel/runtime" "7.2.0"
core-js "2.6.0"
graphql "14.0.2"
"@kbrandwijk/swagger-to-graphql@2.4.3":
version "2.4.3"
resolved "https://registry.yarnpkg.com/@kbrandwijk/swagger-to-graphql/-/swagger-to-graphql-2.4.3.tgz#7c0fb2410eb0b6b9cc81fad28cc20f9386153cf1"
@ -1900,6 +1961,22 @@ apollo-link-http@^1.3.2, apollo-link-http@^1.5.16:
apollo-link-http-common "^0.2.15"
tslib "^1.9.3"
apollo-link-ws@^1.0.19:
version "1.0.19"
resolved "https://registry.yarnpkg.com/apollo-link-ws/-/apollo-link-ws-1.0.19.tgz#dfa871d4df883a8777c9556c872fc892e103daa5"
integrity sha512-mRXmeUkc55ixOdYRtfq5rq3o9sboKghKABKroDVhJnkdS56zthBEWMAD+phajujOUbqByxjok0te8ABqByBdeQ==
dependencies:
apollo-link "^1.2.13"
tslib "^1.9.3"
apollo-link@1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.2.5.tgz#f54932d6b8f1412a35e088bc199a116bce3f1f16"
integrity sha512-GJHEE4B06oEB58mpRRwW6ISyvgX2aCqCLjpcE3M/6/4e+ZVeX7fRGpMJJDq2zZ8n7qWdrEuY315JfxzpsJmUhA==
dependencies:
apollo-utilities "^1.0.0"
zen-observable-ts "^0.8.12"
apollo-link@^1.0.0, apollo-link@^1.0.7, apollo-link@^1.2.11, apollo-link@^1.2.13:
version "1.2.13"
resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.2.13.tgz#dff00fbf19dfcd90fddbc14b6a3f9a771acac6c4"
@ -1910,7 +1987,7 @@ apollo-link@^1.0.0, apollo-link@^1.0.7, apollo-link@^1.2.11, apollo-link@^1.2.13
tslib "^1.9.3"
zen-observable-ts "^0.8.20"
apollo-utilities@1.3.2, apollo-utilities@^1.3.0, apollo-utilities@^1.3.2:
apollo-utilities@1.3.2, apollo-utilities@^1.0.0, apollo-utilities@^1.3.0, apollo-utilities@^1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.2.tgz#8cbdcf8b012f664cd6cb5767f6130f5aed9115c9"
integrity sha512-JWNHj8XChz7S4OZghV6yc9FNnzEXj285QYp/nLNh943iObycI5GTDO3NGR9Dth12LRrSFMeDOConPfPln+WGfg==
@ -2205,7 +2282,16 @@ babel-plugin-transform-object-rest-spread@^6.26.0:
babel-plugin-syntax-object-rest-spread "^6.8.0"
babel-runtime "^6.26.0"
babel-runtime@^6.25.0, babel-runtime@^6.26.0:
babel-polyfill@6.26.0:
version "6.26.0"
resolved "https://registry.yarnpkg.com/babel-polyfill/-/babel-polyfill-6.26.0.tgz#379937abc67d7895970adc621f284cd966cf2153"
integrity sha1-N5k3q8Z9eJWXCtxiHyhM2WbPIVM=
dependencies:
babel-runtime "^6.26.0"
core-js "^2.5.0"
regenerator-runtime "^0.10.5"
babel-runtime@6.26.0, babel-runtime@^6.25.0, babel-runtime@^6.26.0:
version "6.26.0"
resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4=
@ -3535,11 +3621,21 @@ core-js-pure@^3.0.0:
resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.4.2.tgz#ffd4ea4dc1f8517f75d4a929986a214629477417"
integrity sha512-6+iSif/3zO0bSkhjVY9o4MTdv36X+rO6rqs/UxQ+uxBevmC4fsfwyQwFVdZXXONmLlKVLiXCG8PDvQ2Gn/iteA==
core-js@2.6.0:
version "2.6.0"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.0.tgz#1e30793e9ee5782b307e37ffa22da0eacddd84d4"
integrity sha512-kLRC6ncVpuEW/1kwrOXYX6KQASCVtrh1gQr/UiaVgFlf9WE5Vp+lNe5+h3LuMr5PAucWnnEXwH0nQHRH/gpGtw==
core-js@^2.4.0, core-js@^2.5.3, core-js@^2.5.7, core-js@^2.6.10, core-js@^2.6.5:
version "2.6.10"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.10.tgz#8a5b8391f8cc7013da703411ce5b585706300d7f"
integrity sha512-I39t74+4t+zau64EN1fE5v2W31Adtc/REhzWN+gWRRXg6WH5qAsZm62DHpQ1+Yhe4047T55jvzz7MUqF/dBBlA==
core-js@^2.5.0:
version "2.6.11"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c"
integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==
core-js@^3.3.2, core-js@^3.3.5:
version "3.4.2"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.4.2.tgz#ee2b1a60b50388d8ddcda8cdb44a92c7a9ea76df"
@ -5009,6 +5105,11 @@ extsprintf@^1.2.0:
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
fast-deep-equal@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.0.0.tgz#96256a3bc975595eb36d82e9929d060d893439ff"
integrity sha1-liVqO8l1WV6zbYLpkp0GDYk0Of8=
fast-deep-equal@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz#c053477817c86b51daa853c81e059b733d023614"
@ -5263,6 +5364,16 @@ flatted@^2.0.0:
resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.1.tgz#69e57caa8f0eacbc281d2e2cb458d46fdb449e08"
integrity sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg==
flow-static-land@0.2.7:
version "0.2.7"
resolved "https://registry.yarnpkg.com/flow-static-land/-/flow-static-land-0.2.7.tgz#937f9dcb2780889a609155e5d1a55a993bc2ffb3"
integrity sha1-k3+dyyeAiJpgkVXl0aVamTvC/7M=
flow-static-land@0.2.8:
version "0.2.8"
resolved "https://registry.yarnpkg.com/flow-static-land/-/flow-static-land-0.2.8.tgz#49617e531396928bae6eb5d8ba32e7071637e5b9"
integrity sha512-pOZFExu2rbscCgcEo7nL7FNhBubMi18dn1Un4lm8LOmQkYhgsHLsrBGMWmuJXRWcYMrOC7I/bPsiqqVjdD3K1g==
flush-write-stream@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/flush-write-stream/-/flush-write-stream-1.1.1.tgz#8dd7d873a1babc207d94ead0c2e0e44276ebf2e8"
@ -5939,6 +6050,13 @@ graphql@0.11.3:
dependencies:
iterall "^1.1.0"
graphql@14.0.2:
version "14.0.2"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.0.2.tgz#7dded337a4c3fd2d075692323384034b357f5650"
integrity sha512-gUC4YYsaiSJT1h40krG3J+USGlwhzNTXSb4IOZljn9ag5Tj+RkoXrWp+Kh7WyE3t1NCfab5kzCuxBIvOMERMXw==
dependencies:
iterall "^1.2.2"
graphql@^0.13.1:
version "0.13.2"
resolved "https://registry.yarnpkg.com/graphql/-/graphql-0.13.2.tgz#4c740ae3c222823e7004096f832e7b93b2108270"
@ -9429,6 +9547,11 @@ performance-now@^2.1.0:
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
phoenix@^1.4.11:
version "1.4.11"
resolved "https://registry.yarnpkg.com/phoenix/-/phoenix-1.4.11.tgz#3e70cd2461600ef3b0d4308e8fef7d2005fe000a"
integrity sha512-UkuqKB/+Uy9LNt15v1PpPeoLizcYwF4cFTi1wiMZ2TVX5+2Pj7MbRDFmoUFPc+Z1jsOE/TdQ6kzZohErWMiYlQ==
picomatch@^2.0.5:
version "2.1.1"
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.1.1.tgz#ecdfbea7704adb5fe6fb47f9866c4c0e15e905c5"
@ -10729,11 +10852,21 @@ regenerate@^1.4.0:
resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11"
integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==
regenerator-runtime@^0.10.5:
version "0.10.5"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz#336c3efc1220adcedda2c9fab67b5a7955a33658"
integrity sha1-M2w+/BIgrc7dosn6tntaeVWjNlg=
regenerator-runtime@^0.11.0:
version "0.11.1"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
regenerator-runtime@^0.12.0:
version "0.12.1"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de"
integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg==
regenerator-runtime@^0.13.2:
version "0.13.3"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5"
@ -14132,7 +14265,7 @@ z-schema@^3.18.2:
optionalDependencies:
commander "^2.7.1"
zen-observable-ts@^0.8.20:
zen-observable-ts@^0.8.12, zen-observable-ts@^0.8.20:
version "0.8.20"
resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.8.20.tgz#44091e335d3fcbc97f6497e63e7f57d5b516b163"
integrity sha512-2rkjiPALhOtRaDX6pWyNqK1fnP5KkJJybYebopNSn6wDG1lxBoFs2+nwwXKoA6glHIrtwrfBBy6da0stkKtTAA==
@ -14140,6 +14273,11 @@ zen-observable-ts@^0.8.20:
tslib "^1.9.3"
zen-observable "^0.8.0"
zen-observable@0.8.11:
version "0.8.11"
resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.11.tgz#d3415885eeeb42ee5abb9821c95bb518fcd6d199"
integrity sha512-N3xXQVr4L61rZvGMpWe8XoCGX8vhU35dPyQ4fm5CY/KDlG0F75un14hjbckPXTDuKUY6V0dqR2giT6xN8Y4GEQ==
zen-observable@^0.8.0:
version "0.8.15"
resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.15.tgz#96415c512d8e3ffd920afd3889604e30b9eaac15"

View File

@ -30,7 +30,7 @@ defmodule Mix.Tasks.Mobilizon.Relay do
Common.start_mobilizon()
case Relay.follow(target) do
{:ok, _activity} ->
{:ok, _activity, _follow} ->
# put this task to sleep to allow the genserver to push out the messages
:timer.sleep(500)
@ -43,7 +43,7 @@ defmodule Mix.Tasks.Mobilizon.Relay do
Common.start_mobilizon()
case Relay.unfollow(target) do
{:ok, _activity} ->
{:ok, _activity, _follow} ->
# put this task to sleep to allow the genserver to push out the messages
:timer.sleep(500)

View File

@ -37,6 +37,7 @@ defmodule Mobilizon do
# supervisors
Mobilizon.Storage.Repo,
MobilizonWeb.Endpoint,
{Absinthe.Subscription, [MobilizonWeb.Endpoint]},
{Oban, Application.get_env(:mobilizon, Oban)},
# workers
Guardian.DB.Token.SweeperServer,
@ -44,7 +45,8 @@ defmodule Mobilizon do
cachex_spec(:feed, 2500, 60, 60, &Feed.create_cache/1),
cachex_spec(:ics, 2500, 60, 60, &ICalendar.create_cache/1),
cachex_spec(:statistics, 10, 60, 60),
cachex_spec(:activity_pub, 2500, 3, 15)
cachex_spec(:activity_pub, 2500, 3, 15),
internal_actor()
]
Supervisor.start_link(children, strategy: :one_for_one, name: Mobilizon.Supervisor)
@ -88,4 +90,12 @@ defmodule Mobilizon do
@spec fallback_options(function | nil) :: keyword
defp fallback_options(nil), do: []
defp fallback_options(fallback), do: [fallback: fallback(default: fallback)]
defp internal_actor() do
%{
id: :internal_actor_init,
start: {Task, :start_link, [&Mobilizon.Service.ActivityPub.Relay.init/0]},
restart: :temporary
}
end
end

View File

@ -7,9 +7,9 @@ defmodule Mobilizon.Actors.Actor do
import Ecto.Changeset
alias Mobilizon.{Actors, Config, Crypto}
alias Mobilizon.{Actors, Config, Crypto, Share}
alias Mobilizon.Actors.{ActorOpenness, ActorType, ActorVisibility, Follower, Member}
alias Mobilizon.Events.{Event, FeedToken}
alias Mobilizon.Events.{Event, FeedToken, Comment}
alias Mobilizon.Media.File
alias Mobilizon.Reports.{Note, Report}
alias Mobilizon.Users.User
@ -43,11 +43,14 @@ defmodule Mobilizon.Actors.Actor do
followers: [Follower.t()],
followings: [Follower.t()],
organized_events: [Event.t()],
comments: [Comment.t()],
feed_tokens: [FeedToken.t()],
created_reports: [Report.t()],
subject_reports: [Report.t()],
report_notes: [Note.t()],
mentions: [Mention.t()],
shares: [Share.t()],
owner_shares: [Share.t()],
memberships: [t]
}
@ -137,11 +140,14 @@ defmodule Mobilizon.Actors.Actor do
has_many(:followers, Follower, foreign_key: :target_actor_id)
has_many(:followings, Follower, foreign_key: :actor_id)
has_many(:organized_events, Event, foreign_key: :organizer_actor_id)
has_many(:comments, Comment, foreign_key: :actor_id)
has_many(:feed_tokens, FeedToken, foreign_key: :actor_id)
has_many(:created_reports, Report, foreign_key: :reporter_id)
has_many(:subject_reports, Report, foreign_key: :reported_id)
has_many(:report_notes, Note, foreign_key: :moderator_id)
has_many(:mentions, Mention)
has_many(:shares, Share, foreign_key: :actor_id)
has_many(:owner_shares, Share, foreign_key: :owner_actor_id)
many_to_many(:memberships, __MODULE__, join_through: Member)
timestamps()
@ -217,6 +223,19 @@ defmodule Mobilizon.Actors.Actor do
|> validate_required(@update_required_attrs)
end
@doc false
@spec delete_changeset(t) :: Ecto.Changeset.t()
def delete_changeset(%__MODULE__{} = actor) do
actor
|> change()
|> put_change(:name, nil)
|> put_change(:summary, nil)
|> put_change(:suspended, true)
|> put_change(:avatar, nil)
|> put_change(:banner, nil)
|> put_change(:user_id, nil)
end
@doc """
Changeset for person registration.
"""

View File

@ -12,6 +12,8 @@ defmodule Mobilizon.Actors do
alias Mobilizon.{Crypto, Events}
alias Mobilizon.Media.File
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Service.Workers.BackgroundWorker
alias Mobilizon.Service.ActivityPub
require Logger
@ -47,6 +49,7 @@ defmodule Mobilizon.Actors do
@public_visibility [:public, :unlisted]
@administrator_roles [:creator, :administrator]
@actor_preloads [:user, :organized_events, :comments]
@doc """
Gets a single actor.
@ -224,16 +227,24 @@ defmodule Mobilizon.Actors do
end
end
def delete_actor(%Actor{} = actor) do
BackgroundWorker.enqueue("delete_actor", %{"actor_id" => actor.id})
end
@doc """
Deletes an actor.
"""
@spec delete_actor(Actor.t()) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def delete_actor(%Actor{domain: nil} = actor) do
@spec perform(atom(), Actor.t()) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def perform(:delete_actor, %Actor{} = actor) do
actor = Repo.preload(actor, @actor_preloads)
transaction =
Multi.new()
|> Multi.delete(:actor, actor)
|> Multi.run(:remove_banner, fn _, %{actor: %Actor{}} -> remove_banner(actor) end)
|> Multi.run(:remove_avatar, fn _, %{actor: %Actor{}} -> remove_avatar(actor) end)
|> Multi.run(:delete_organized_events, fn _, _ -> delete_actor_organized_events(actor) end)
|> Multi.run(:empty_comments, fn _, _ -> delete_actor_empty_comments(actor) end)
|> Multi.run(:remove_banner, fn _, _ -> remove_banner(actor) end)
|> Multi.run(:remove_avatar, fn _, _ -> remove_avatar(actor) end)
|> Multi.update(:actor, Actor.delete_changeset(actor))
|> Repo.transaction()
case transaction do
@ -245,8 +256,6 @@ defmodule Mobilizon.Actors do
end
end
def delete_actor(%Actor{} = actor), do: Repo.delete(actor)
@doc """
Returns the list of actors.
"""
@ -486,9 +495,9 @@ defmodule Mobilizon.Actors do
|> Repo.insert()
end
@spec get_or_create_actor_by_url(String.t(), String.t()) ::
@spec get_or_create_instance_actor_by_url(String.t(), String.t()) ::
{:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def get_or_create_actor_by_url(url, preferred_username \\ "relay") do
def get_or_create_instance_actor_by_url(url, preferred_username \\ "relay") do
case get_actor_by_url(url) do
{:ok, %Actor{} = actor} ->
{:ok, actor}
@ -571,9 +580,12 @@ defmodule Mobilizon.Actors do
"""
@spec update_follower(Follower.t(), map) :: {:ok, Follower.t()} | {:error, Ecto.Changeset.t()}
def update_follower(%Follower{} = follower, attrs) do
follower
|> Follower.changeset(attrs)
|> Repo.update()
with {:ok, %Follower{} = follower} <-
follower
|> Follower.changeset(attrs)
|> Repo.update() do
{:ok, Repo.preload(follower, [:actor, :target_actor])}
end
end
@doc """
@ -597,10 +609,10 @@ defmodule Mobilizon.Actors do
Returns the list of followers for an actor.
If actor A and C both follow actor B, actor B's followers are A and C.
"""
@spec list_followers_for_actor(Actor.t()) :: [Follower.t()]
def list_followers_for_actor(%Actor{id: actor_id}) do
@spec list_followers_actors_for_actor(Actor.t()) :: [Actor.t()]
def list_followers_actors_for_actor(%Actor{id: actor_id}) do
actor_id
|> followers_for_actor_query()
|> follower_actors_for_actor_query()
|> Repo.all()
end
@ -610,18 +622,28 @@ defmodule Mobilizon.Actors do
@spec list_external_followers_for_actor(Actor.t()) :: [Follower.t()]
def list_external_followers_for_actor(%Actor{id: actor_id}) do
actor_id
|> followers_for_actor_query()
|> filter_external()
|> list_external_follower_actors_for_actor_query()
|> Repo.all()
end
@doc """
Returns the paginated list of external followers for an actor.
"""
@spec list_external_followers_for_actor_paginated(Actor.t(), integer | nil, integer | nil) ::
Page.t()
def list_external_followers_for_actor_paginated(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
actor_id
|> list_external_followers_for_actor_query()
|> Page.build_page(page, limit)
end
@doc """
Build a page struct for followers of an actor.
"""
@spec build_followers_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t()
def build_followers_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
actor_id
|> followers_for_actor_query()
|> follower_actors_for_actor_query()
|> Page.build_page(page, limit)
end
@ -632,17 +654,32 @@ defmodule Mobilizon.Actors do
@spec list_followings_for_actor(Actor.t()) :: [Follower.t()]
def list_followings_for_actor(%Actor{id: actor_id}) do
actor_id
|> followings_for_actor_query()
|> followings_actors_for_actor_query()
|> Repo.all()
end
@doc """
Returns the list of external followings for an actor.
"""
@spec list_external_followings_for_actor_paginated(Actor.t(), integer | nil, integer | nil) ::
Page.t()
def list_external_followings_for_actor_paginated(
%Actor{id: actor_id},
page \\ nil,
limit \\ nil
) do
actor_id
|> list_external_followings_for_actor_query()
|> Page.build_page(page, limit)
end
@doc """
Build a page struct for followings of an actor.
"""
@spec build_followings_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t()
def build_followings_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
actor_id
|> followings_for_actor_query()
|> followings_actors_for_actor_query()
|> Page.build_page(page, limit)
end
@ -747,7 +784,7 @@ defmodule Mobilizon.Actors do
defp actor_with_preload_query(actor_id) do
from(
a in Actor,
where: a.id == ^actor_id,
where: a.id == ^actor_id and not a.suspended,
preload: [:organized_events, :followers, :followings]
)
end
@ -885,12 +922,13 @@ defmodule Mobilizon.Actors do
defp follower_by_followed_and_following_query(followed_id, follower_id) do
from(
f in Follower,
where: f.target_actor_id == ^followed_id and f.actor_id == ^follower_id
where: f.target_actor_id == ^followed_id and f.actor_id == ^follower_id,
preload: [:actor, :target_actor]
)
end
@spec followers_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp followers_for_actor_query(actor_id) do
@spec follower_actors_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp follower_actors_for_actor_query(actor_id) do
from(
a in Actor,
join: f in Follower,
@ -899,8 +937,18 @@ defmodule Mobilizon.Actors do
)
end
@spec followings_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp followings_for_actor_query(actor_id) do
@spec follower_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp follower_for_actor_query(actor_id) do
from(
f in Follower,
join: a in Actor,
on: a.id == f.actor_id,
where: f.target_actor_id == ^actor_id
)
end
@spec followings_actors_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp followings_actors_for_actor_query(actor_id) do
from(
a in Actor,
join: f in Follower,
@ -909,6 +957,38 @@ defmodule Mobilizon.Actors do
)
end
@spec followings_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp followings_for_actor_query(actor_id) do
from(
f in Follower,
join: a in Actor,
on: a.id == f.target_actor_id,
where: f.actor_id == ^actor_id
)
end
@spec list_external_follower_actors_for_actor_query(integer) :: Ecto.Query.t()
defp list_external_follower_actors_for_actor_query(actor_id) do
actor_id
|> follower_actors_for_actor_query()
|> filter_external()
end
@spec list_external_followers_for_actor_query(integer) :: Ecto.Query.t()
defp list_external_followers_for_actor_query(actor_id) do
actor_id
|> follower_for_actor_query()
|> filter_follower_actors_external()
end
@spec list_external_followings_for_actor_query(integer) :: Ecto.Query.t()
defp list_external_followings_for_actor_query(actor_id) do
actor_id
|> followings_for_actor_query()
|> filter_follower_actors_external()
|> order_by(desc: :updated_at)
end
@spec filter_local(Ecto.Query.t()) :: Ecto.Query.t()
defp filter_local(query) do
from(a in query, where: is_nil(a.domain))
@ -919,8 +999,16 @@ defmodule Mobilizon.Actors do
from(a in query, where: not is_nil(a.domain))
end
@spec filter_follower_actors_external(Ecto.Query.t()) :: Ecto.Query.t()
defp filter_follower_actors_external(query) do
query
|> where([_f, a], not is_nil(a.domain))
|> preload([f, a], [:target_actor, :actor])
end
@spec filter_by_type(Ecto.Query.t(), ActorType.t()) :: Ecto.Query.t()
defp filter_by_type(query, type) when type in [:Person, :Group] do
defp filter_by_type(query, type)
when type in [:Person, :Group, :Application, :Service, :Organisation] do
from(a in query, where: a.type == ^type)
end
@ -943,4 +1031,36 @@ defmodule Mobilizon.Actors do
@spec preload_followers(Actor.t(), boolean) :: Actor.t()
defp preload_followers(actor, true), do: Repo.preload(actor, [:followers])
defp preload_followers(actor, false), do: actor
defp delete_actor_organized_events(%Actor{organized_events: organized_events}) do
res =
Enum.map(organized_events, fn event ->
event =
Repo.preload(event, [:organizer_actor, :participants, :picture, :mentions, :comments])
ActivityPub.delete(event, false)
end)
if Enum.all?(res, fn {status, _, _} -> status == :ok end) do
{:ok, res}
else
{:error, res}
end
end
defp delete_actor_empty_comments(%Actor{comments: comments}) do
res =
Enum.map(comments, fn comment ->
comment =
Repo.preload(comment, [:actor, :mentions, :event, :in_reply_to_comment, :origin_comment])
ActivityPub.delete(comment, false)
end)
if Enum.all?(res, fn {status, _, _} -> status == :ok end) do
{:ok, res}
else
{:error, res}
end
end
end

View File

@ -19,11 +19,15 @@ defmodule Mobilizon.Actors.Follower do
@required_attrs [:url, :approved, :target_actor_id, :actor_id]
@attrs @required_attrs
@timestamps_opts [type: :utc_datetime]
@primary_key {:id, :binary_id, autogenerate: true}
schema "followers" do
field(:approved, :boolean, default: false)
field(:url, :string)
timestamps()
belongs_to(:target_actor, Actor)
belongs_to(:actor, Actor)
end

View File

@ -32,7 +32,6 @@ defmodule Mobilizon.Events.Comment do
# When deleting an event we only nihilify everything
@required_attrs [:url]
@creation_required_attrs @required_attrs ++ [:text, :actor_id]
@deletion_required_attrs @required_attrs ++ [:deleted_at]
@optional_attrs [
:text,
:actor_id,
@ -81,11 +80,13 @@ defmodule Mobilizon.Events.Comment do
|> validate_required(@creation_required_attrs)
end
@spec delete_changeset(t, map) :: Ecto.Changeset.t()
def delete_changeset(%__MODULE__{} = comment, attrs) do
@spec delete_changeset(t) :: Ecto.Changeset.t()
def delete_changeset(%__MODULE__{} = comment) do
comment
|> common_changeset(attrs)
|> validate_required(@deletion_required_attrs)
|> change()
|> put_change(:text, nil)
|> put_change(:actor_id, nil)
|> put_change(:deleted_at, DateTime.utc_now() |> DateTime.truncate(:second))
end
@doc """

View File

@ -13,6 +13,8 @@ defmodule Mobilizon.Events.Event do
alias Mobilizon.Addresses
alias Mobilizon.Events
alias Mobilizon.Events.{
Comment,
EventOptions,
@ -73,6 +75,7 @@ defmodule Mobilizon.Events.Event do
:category,
:status,
:draft,
:local,
:visibility,
:join_options,
:publish_at,
@ -190,13 +193,16 @@ defmodule Mobilizon.Events.Event do
def can_be_managed_by(_event, _actor), do: {:event_can_be_managed, false}
@spec put_tags(Changeset.t(), map) :: Changeset.t()
defp put_tags(%Changeset{} = changeset, %{tags: tags}),
do: put_assoc(changeset, :tags, Enum.map(tags, &process_tag/1))
defp put_tags(%Changeset{} = changeset, %{tags: tags}) do
put_assoc(changeset, :tags, Enum.map(tags, &process_tag/1))
end
defp put_tags(%Changeset{} = changeset, _), do: changeset
# We need a changeset instead of a raw struct because of slug which is generated in changeset
defp process_tag(%{id: _id} = tag), do: tag
defp process_tag(%{id: id} = _tag) do
Events.get_tag(id)
end
defp process_tag(tag) do
Tag.changeset(%Tag{}, tag)

View File

@ -39,6 +39,21 @@ defmodule Mobilizon.Events.EventParticipantStats do
@doc false
@spec changeset(t, map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = event_options, attrs) do
cast(event_options, attrs, @attrs)
event_options
|> cast(attrs, @attrs)
|> validate_stats()
end
defp validate_stats(%Ecto.Changeset{} = changeset) do
changeset
|> validate_number(:not_approved, greater_than_or_equal_to: 0)
|> validate_number(:rejected, greater_than_or_equal_to: 0)
|> validate_number(:participant, greater_than_or_equal_to: 0)
|> validate_number(:moderator, greater_than_or_equal_to: 0)
|> validate_number(:administrator, greater_than_or_equal_to: 0)
|> validate_number(:creator, greater_than_or_equal_to: 0)
# TODO: Replace me with something like the following
# Enum.reduce(@attrs, fn key, changeset -> validate_number(changeset, key, greater_than_or_equal_to: 0) end)
end
end

View File

@ -97,6 +97,7 @@ defmodule Mobilizon.Events do
@comment_preloads [
:actor,
:event,
:attributed_to,
:in_reply_to_comment,
:origin_comment,
@ -722,6 +723,13 @@ defmodule Mobilizon.Events do
|> Repo.all()
end
@spec list_actors_participants_for_event(String.t()) :: [Actor.t()]
def list_actors_participants_for_event(id) do
id
|> list_participant_actors_for_event_query
|> Repo.all()
end
@doc """
Returns the list of participations for an actor.
@ -864,30 +872,15 @@ defmodule Mobilizon.Events do
|> Multi.run(:update_event_participation_stats, fn _repo,
%{
participant:
%Participant{
role: role,
event_id: event_id
} = _participant
%Participant{role: new_role} =
participant
} ->
with {:update_event_participation_stats, true} <-
{:update_event_participation_stats, update_event_participation_stats},
{:ok, %Event{} = event} <- get_event(event_id),
%EventParticipantStats{} = participant_stats <-
Map.get(event, :participant_stats),
%EventParticipantStats{} = participant_stats <-
Map.update(participant_stats, role, 0, &(&1 + 1)),
{:ok, %Event{} = event} <-
event
|> Event.update_changeset(%{
participant_stats: Map.from_struct(participant_stats)
})
|> Repo.update() do
{:ok, event}
else
{:update_event_participation_stats, false} -> {:ok, nil}
{:error, :event_not_found} -> {:error, :event_not_found}
err -> {:error, err}
end
update_participant_stats(
participant,
nil,
new_role,
update_event_participation_stats
)
end)
|> Repo.transaction() do
{:ok, Repo.preload(participant, [:event, :actor])}
@ -899,10 +892,21 @@ defmodule Mobilizon.Events do
"""
@spec update_participant(Participant.t(), map) ::
{:ok, Participant.t()} | {:error, Changeset.t()}
def update_participant(%Participant{} = participant, attrs) do
participant
|> Participant.changeset(attrs)
|> Repo.update()
def update_participant(%Participant{role: old_role} = participant, attrs) do
with {:ok, %{participant: %Participant{} = participant}} <-
Multi.new()
|> Multi.update(:participant, Participant.changeset(participant, attrs))
|> Multi.run(:update_event_participation_stats, fn _repo,
%{
participant:
%Participant{role: new_role} =
participant
} ->
update_participant_stats(participant, old_role, new_role)
end)
|> Repo.transaction() do
{:ok, Repo.preload(participant, [:event, :actor])}
end
end
@doc """
@ -910,7 +914,71 @@ defmodule Mobilizon.Events do
"""
@spec delete_participant(Participant.t()) ::
{:ok, Participant.t()} | {:error, Changeset.t()}
def delete_participant(%Participant{} = participant), do: Repo.delete(participant)
def delete_participant(%Participant{role: old_role} = participant) do
with {:ok, %{participant: %Participant{} = participant}} <-
Multi.new()
|> Multi.delete(:participant, participant)
|> Multi.run(:update_event_participation_stats, fn _repo,
%{
participant:
%Participant{} = participant
} ->
update_participant_stats(participant, old_role, nil)
end)
|> Repo.transaction() do
{:ok, participant}
end
end
defp update_participant_stats(
%Participant{
event_id: event_id
} = _participant,
old_role,
new_role,
update_event_participation_stats \\ true
) do
with {:update_event_participation_stats, true} <-
{:update_event_participation_stats, update_event_participation_stats},
{:ok, %Event{} = event} <- get_event(event_id),
%EventParticipantStats{} = participant_stats <-
Map.get(event, :participant_stats),
%EventParticipantStats{} = participant_stats <-
do_update_participant_stats(participant_stats, old_role, new_role),
{:ok, %Event{} = event} <-
event
|> Event.update_changeset(%{
participant_stats: Map.from_struct(participant_stats)
})
|> Repo.update() do
{:ok, event}
else
{:update_event_participation_stats, false} ->
{:ok, nil}
{:error, :event_not_found} ->
{:error, :event_not_found}
err ->
{:error, err}
end
end
defp do_update_participant_stats(participant_stats, old_role, new_role) do
participant_stats
|> decrease_participant_stats(old_role)
|> increase_participant_stats(new_role)
end
defp increase_participant_stats(participant_stats, nil), do: participant_stats
defp increase_participant_stats(participant_stats, role),
do: Map.update(participant_stats, role, 0, &(&1 + 1))
defp decrease_participant_stats(participant_stats, nil), do: participant_stats
defp decrease_participant_stats(participant_stats, role),
do: Map.update(participant_stats, role, 0, &(&1 - 1))
@doc """
Gets a single session.
@ -1170,11 +1238,7 @@ defmodule Mobilizon.Events do
@spec delete_comment(Comment.t()) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def delete_comment(%Comment{} = comment) do
comment
|> Comment.delete_changeset(%{
text: nil,
actor_id: nil,
deleted_at: DateTime.utc_now()
})
|> Comment.delete_changeset()
|> Repo.update()
end
@ -1561,14 +1625,22 @@ defmodule Mobilizon.Events do
defp list_participants_for_event_query(event_id) do
from(
p in Participant,
join: e in Event,
on: p.event_id == e.id,
where: e.id == ^event_id,
where: p.event_id == ^event_id,
preload: [:actor]
)
end
@spec list_participants_for_event_query(String.t()) :: Ecto.Query.t()
@spec list_participant_actors_for_event_query(String.t()) :: Ecto.Query.t()
defp list_participant_actors_for_event_query(event_id) do
from(
a in Actor,
join: p in Participant,
on: p.actor_id == a.id,
where: p.event_id == ^event_id
)
end
@spec list_local_emails_user_participants_for_event_query(String.t()) :: Ecto.Query.t()
def list_local_emails_user_participants_for_event_query(event_id) do
Participant
|> join(:inner, [p], a in Actor, on: p.actor_id == a.id and is_nil(a.domain))

View File

@ -57,6 +57,7 @@ defmodule Mobilizon.Events.Participant do
|> cast(attrs, @attrs)
|> ensure_url()
|> validate_required(@required_attrs)
|> unique_constraint(:actor_id, name: :participants_event_id_actor_id_index)
end
# If there's a blank URL that's because we're doing the first insert

75
lib/mobilizon/share.ex Normal file
View File

@ -0,0 +1,75 @@
defmodule Mobilizon.Share do
@moduledoc """
Holds the list of shares made to external actors
"""
use Ecto.Schema
import Ecto.Changeset
import Ecto.Query
alias Mobilizon.Storage.Repo
alias Mobilizon.Actors.Actor
@type t :: %__MODULE__{
uri: String.t(),
actor: Actor.t()
}
@required_attrs [:uri, :actor_id, :owner_actor_id]
@optional_attrs []
@attrs @required_attrs ++ @optional_attrs
schema "shares" do
field(:uri, :string)
belongs_to(:actor, Actor)
belongs_to(:owner_actor, Actor)
timestamps()
end
@doc false
def changeset(share, attrs) do
share
|> cast(attrs, @attrs)
|> validate_required(@required_attrs)
|> foreign_key_constraint(:actor_id)
|> unique_constraint(:uri, name: :shares_uri_actor_id_index)
end
@spec create(String.t(), integer(), integer()) ::
{:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
def create(uri, actor_id, owner_actor_id) do
%__MODULE__{}
|> changeset(%{actor_id: actor_id, owner_actor_id: owner_actor_id, uri: uri})
|> Repo.insert(on_conflict: :nothing)
end
@spec get(String.t(), integer()) :: Ecto.Schema.t() | nil
def get(uri, actor_id) do
__MODULE__
|> where(actor_id: ^actor_id, uri: ^uri)
|> Repo.one()
end
@spec get_actors_by_share_uri(String.t()) :: [Ecto.Schema.t()]
def get_actors_by_share_uri(uri) do
Actor
|> join(:inner, [a], s in __MODULE__, on: s.actor_id == a.id)
|> where([_a, s], s.uri == ^uri)
|> Repo.all()
end
@spec get_actors_by_owner_actor_id(integer()) :: [Ecto.Schema.t()]
def get_actors_by_owner_actor_id(actor_id) do
Actor
|> join(:inner, [a], s in __MODULE__, on: s.actor_id == a.id)
|> where([_a, s], s.owner_actor_id == ^actor_id)
|> Repo.all()
end
@spec delete_all_by_uri(String.t()) :: {integer(), nil | [term()]}
def delete_all_by_uri(uri) do
__MODULE__
|> where(uri: ^uri)
|> Repo.delete_all()
end
end

View File

@ -28,7 +28,7 @@ defmodule Mobilizon.Tombstone do
def changeset(%__MODULE__{} = tombstone, attrs) do
tombstone
|> cast(attrs, @attrs)
|> validate_required(@attrs)
|> validate_required(@required_attrs)
end
@spec create_tombstone(map()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}

View File

@ -12,8 +12,8 @@ defmodule MobilizonWeb.API.Follows do
def follow(%Actor{} = follower, %Actor{} = followed) do
case ActivityPub.follow(follower, followed) do
{:ok, activity, _} ->
{:ok, activity}
{:ok, activity, follow} ->
{:ok, activity, follow}
e ->
Logger.warn("Error while following actor: #{inspect(e)}")
@ -23,8 +23,8 @@ defmodule MobilizonWeb.API.Follows do
def unfollow(%Actor{} = follower, %Actor{} = followed) do
case ActivityPub.unfollow(follower, followed) do
{:ok, activity, _} ->
{:ok, activity}
{:ok, activity, follow} ->
{:ok, activity, follow}
e ->
Logger.warn("Error while unfollowing actor: #{inspect(e)}")
@ -33,15 +33,35 @@ defmodule MobilizonWeb.API.Follows do
end
def accept(%Actor{} = follower, %Actor{} = followed) do
Logger.debug("We're trying to accept a follow")
with %Follower{approved: false} = follow <-
Actors.is_following(follower, followed),
{:ok, %Activity{} = activity, %Follower{approved: true}} <-
{:ok, %Activity{} = activity, %Follower{approved: true} = follow} <-
ActivityPub.accept(
:follow,
follow,
%{approved: true}
true
) do
{:ok, activity}
{:ok, activity, follow}
else
%Follower{approved: true} ->
{:error, "Follow already accepted"}
end
end
def reject(%Actor{} = follower, %Actor{} = followed) do
Logger.debug("We're trying to reject a follow")
with %Follower{} = follow <-
Actors.is_following(follower, followed),
{:ok, %Activity{} = activity, %Follower{} = follow} <-
ActivityPub.reject(
:follow,
follow,
true
) do
{:ok, activity, follow}
else
%Follower{approved: true} ->
{:error, "Follow already accepted"}

View File

@ -17,7 +17,8 @@ defmodule MobilizonWeb.API.Groups do
args |> Map.get(:preferred_username) |> HtmlSanitizeEx.strip_tags() |> String.trim(),
{:existing_group, nil} <-
{:existing_group, Actors.get_local_group_by_title(preferred_username)},
{:ok, %Activity{} = activity, %Actor{} = group} <- ActivityPub.create(:group, args, true) do
{:ok, %Activity{} = activity, %Actor{} = group} <-
ActivityPub.create(:group, args, true, %{"actor" => args.creator_actor.url}) do
{:ok, activity, group}
else
{:existing_group, _} ->

View File

@ -4,7 +4,6 @@ defmodule MobilizonWeb.API.Participations do
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Events
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Service.ActivityPub
alias MobilizonWeb.Email.Participation
@ -36,16 +35,13 @@ defmodule MobilizonWeb.API.Participations do
%Participant{} = participation,
%Actor{} = moderator
) do
with {:ok, activity, _} <-
with {:ok, activity, %Participant{role: :participant} = participation} <-
ActivityPub.accept(
:join,
participation,
%{role: :participant},
true,
%{"to" => [moderator.url]}
%{"actor" => moderator.url}
),
{:ok, %Participant{role: :participant} = participation} <-
Events.update_participant(participation, %{"role" => :participant}),
:ok <- Participation.send_emails_to_local_user(participation) do
{:ok, activity, participation}
end
@ -55,17 +51,12 @@ defmodule MobilizonWeb.API.Participations do
%Participant{} = participation,
%Actor{} = moderator
) do
with {:ok, activity, _} <-
with {:ok, activity, %Participant{role: :rejected} = participation} <-
ActivityPub.reject(
%{
to: [participation.actor.url],
actor: moderator.url,
object: participation.url
},
"#{MobilizonWeb.Endpoint.url()}/reject/join/#{participation.id}"
:join,
participation,
%{"actor" => moderator.url}
),
{:ok, %Participant{role: :rejected} = participation} <-
Events.update_participant(participation, %{"role" => :rejected}),
:ok <- Participation.send_emails_to_local_user(participation) do
{:ok, activity, participation}
end

View File

@ -17,7 +17,7 @@ defmodule MobilizonWeb.API.Reports do
Create a report/flag on an actor, and optionally on an event or on comments.
"""
def report(args) do
case {:make_activity, ActivityPub.flag(args, Map.get(args, :local, false) == false)} do
case {:make_activity, ActivityPub.flag(args, Map.get(args, :forward, false) == true)} do
{:make_activity, {:ok, %Activity{} = activity, %Report{} = report}} ->
{:ok, activity, report}

View File

@ -3,10 +3,12 @@ defmodule MobilizonWeb.Cache.ActivityPub do
The ActivityPub related functions.
"""
alias Mobilizon.{Actors, Events, Service}
alias Mobilizon.{Actors, Events, Service, Tombstone}
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.{Comment, Event}
alias Service.ActivityPub
alias MobilizonWeb.Router.Helpers, as: Routes
alias MobilizonWeb.Endpoint
@cache :activity_pub
@ -39,7 +41,12 @@ defmodule MobilizonWeb.Cache.ActivityPub do
{:commit, event}
nil ->
{:ignore, nil}
with url <- Routes.page_url(Endpoint, :event, uuid),
%Tombstone{} = tomstone <- Tombstone.find_tombstone(url) do
tomstone
else
_ -> {:ignore, nil}
end
end
end)
end

View File

@ -0,0 +1,28 @@
defmodule MobilizonWeb.GraphQLSocket do
use Phoenix.Socket
use Absinthe.Phoenix.Socket,
schema: MobilizonWeb.Schema
alias Mobilizon.Users.User
def connect(%{"token" => token}, socket) do
with {:ok, authed_socket} <-
Guardian.Phoenix.Socket.authenticate(socket, MobilizonWeb.Guardian, token),
%User{} = user <- Guardian.Phoenix.Socket.current_resource(authed_socket) do
authed_socket =
Absinthe.Phoenix.Socket.put_options(socket,
context: %{
current_user: user
}
)
{:ok, authed_socket}
else
{:error, _} ->
:error
end
end
def id(_socket), do: nil
end

View File

@ -17,6 +17,7 @@ defmodule MobilizonWeb.ActivityPubController do
action_fallback(:errors)
plug(MobilizonWeb.Plugs.Federating when action in [:inbox, :relay])
plug(:relay_active? when action in [:relay])
def relay_active?(conn, _) do
@ -114,7 +115,7 @@ defmodule MobilizonWeb.ActivityPubController do
end
def relay(conn, _params) do
with {:commit, %Actor{} = actor} <- Cache.get_relay() do
with {status, %Actor{} = actor} when status in [:commit, :ok] <- Cache.get_relay() do
conn
|> put_resp_header("content-type", "application/activity+json")
|> json(ActorView.render("actor.json", %{actor: actor}))

View File

@ -28,13 +28,22 @@ defmodule MobilizonWeb.PageController do
defp render_or_error(conn, check_fn, status, object_type, object) do
if check_fn.(status, object) do
render(conn, object_type, object: object)
case object do
%Mobilizon.Tombstone{} ->
conn
|> put_status(:gone)
|> render(object_type, object: object)
_ ->
render(conn, object_type, object: object)
end
else
{:error, :not_found}
end
end
defp is_visible?(%{visibility: v}), do: v in [:public, :unlisted]
defp is_visible?(%Mobilizon.Tombstone{}), do: true
defp ok_status?(status), do: status in [:ok, :commit]
defp ok_status?(status, _), do: ok_status?(status)

View File

@ -9,6 +9,7 @@ defmodule MobilizonWeb.WebFingerController do
"""
use MobilizonWeb, :controller
plug(MobilizonWeb.Plugs.Federating)
alias Mobilizon.Service.WebFinger
@doc """

View File

@ -3,6 +3,7 @@ defmodule MobilizonWeb.Endpoint do
Endpoint for Mobilizon app
"""
use Phoenix.Endpoint, otp_app: :mobilizon
use Absinthe.Phoenix.Endpoint
# For e2e tests
if Application.get_env(:mobilizon, :sql_sandbox) do
@ -13,6 +14,11 @@ defmodule MobilizonWeb.Endpoint do
)
end
socket("/graphql_socket", MobilizonWeb.GraphQLSocket,
websocket: true,
longpoll: false
)
plug(MobilizonWeb.Plugs.UploadedMedia)
# Serve at "/" the static files from "priv/static" directory.

View File

@ -22,30 +22,36 @@ defmodule MobilizonWeb.HTTPSignaturePlug do
end
def call(conn, _opts) do
[signature | _] = get_req_header(conn, "signature")
case get_req_header(conn, "signature") do
[signature | _] ->
if signature do
# set (request-target) header to the appropriate value
# we also replace the digest header with the one we computed
conn =
conn
|> put_req_header(
"(request-target)",
String.downcase("#{conn.method}") <> " #{conn.request_path}"
)
if signature do
# set (request-target) header to the appropriate value
# we also replace the digest header with the one we computed
conn =
conn
|> put_req_header(
"(request-target)",
String.downcase("#{conn.method}") <> " #{conn.request_path}"
)
conn =
if conn.assigns[:digest] do
conn
|> put_req_header("digest", conn.assigns[:digest])
else
conn
end
conn =
if conn.assigns[:digest] do
conn
|> put_req_header("digest", conn.assigns[:digest])
signature_valid = HTTPSignatures.validate_conn(conn)
Logger.debug("Is signature valid ? #{inspect(signature_valid)}")
assign(conn, :valid_signature, signature_valid)
else
Logger.debug("No signature header!")
conn
end
assign(conn, :valid_signature, HTTPSignatures.validate_conn(conn))
else
Logger.debug("No signature header!")
conn
_ ->
conn
end
end
end

View File

@ -0,0 +1,27 @@
# Portions of this file are derived from Pleroma:
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule MobilizonWeb.Plugs.Federating do
@moduledoc """
Restrict ActivityPub routes when not federating
"""
import Plug.Conn
def init(options) do
options
end
def call(conn, _opts) do
if Mobilizon.Config.get([:instance, :federating]) do
conn
else
conn
|> put_status(404)
|> Phoenix.Controller.put_view(MobilizonWeb.ErrorView)
|> Phoenix.Controller.render("404.json")
|> halt()
end
end
end

View File

@ -0,0 +1,79 @@
# Portions of this file are derived from Pleroma:
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule MobilizonWeb.Plugs.MappedSignatureToIdentity do
@moduledoc """
Get actor identity from Signature when handing fetches
"""
alias Mobilizon.Service.HTTPSignatures.Signature
alias Mobilizon.Actors.Actor
alias Mobilizon.Service.ActivityPub.Utils
alias Mobilizon.Service.ActivityPub
import Plug.Conn
require Logger
def init(options), do: options
@spec key_id_from_conn(Plug.Conn.t()) :: String.t() | nil
defp key_id_from_conn(conn) do
case HTTPSignatures.signature_for_conn(conn) do
%{"keyId" => key_id} ->
Signature.key_id_to_actor_url(key_id)
_ ->
nil
end
end
@spec actor_from_key_id(Plug.Conn.t()) :: Actor.t() | nil
defp actor_from_key_id(conn) do
with key_actor_id when is_binary(key_actor_id) <- key_id_from_conn(conn),
{:ok, %Actor{} = actor} <- ActivityPub.get_or_fetch_actor_by_url(key_actor_id) do
actor
else
_ ->
nil
end
end
def call(%{assigns: %{actor: _}} = conn, _opts), do: conn
# if this has payload make sure it is signed by the same actor that made it
def call(%{assigns: %{valid_signature: true}, params: %{"actor" => actor}} = conn, _opts) do
with actor_id <- Utils.get_url(actor),
{:actor, %Actor{} = actor} <- {:actor, actor_from_key_id(conn)},
{:actor_match, true} <- {:actor_match, actor.url == actor_id} do
assign(conn, :actor, actor)
else
{:actor_match, false} ->
Logger.debug("Failed to map identity from signature (payload actor mismatch)")
Logger.debug("key_id=#{key_id_from_conn(conn)}, actor=#{actor}")
assign(conn, :valid_signature, false)
# remove me once testsuite uses mapped capabilities instead of what we do now
{:actor, nil} ->
Logger.debug("Failed to map identity from signature (lookup failure)")
Logger.debug("key_id=#{key_id_from_conn(conn)}, actor=#{actor}")
conn
end
end
# no payload, probably a signed fetch
def call(%{assigns: %{valid_signature: true}} = conn, _opts) do
case actor_from_key_id(conn) do
%Actor{} = actor ->
assign(conn, :actor, actor)
_ ->
Logger.debug("Failed to map identity from signature (no payload actor mismatch)")
Logger.debug("key_id=#{key_id_from_conn(conn)}")
assign(conn, :valid_signature, false)
end
end
# no signature at all
def call(conn, _opts), do: conn
end

View File

@ -6,11 +6,15 @@ defmodule MobilizonWeb.Resolvers.Admin do
import Mobilizon.Users.Guards
alias Mobilizon.Admin.ActionLog
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Events
alias Mobilizon.Events.{Event, Comment}
alias Mobilizon.Reports.{Note, Report}
alias Mobilizon.Service.Statistics
alias Mobilizon.Users.User
alias Mobilizon.Storage.Page
alias Mobilizon.Service.ActivityPub.Relay
def list_action_logs(
_parent,
@ -136,4 +140,76 @@ defmodule MobilizonWeb.Resolvers.Admin do
def get_dashboard(_parent, _args, _resolution) do
{:error, "You need to be logged-in and an administrator to access dashboard statistics"}
end
def list_relay_followers(_parent, %{page: page, limit: limit}, %{
context: %{current_user: %User{role: role}}
})
when is_admin(role) do
with %Actor{} = relay_actor <- Relay.get_actor() do
%Page{} =
page = Actors.list_external_followers_for_actor_paginated(relay_actor, page, limit)
{:ok, page}
end
end
def list_relay_followings(_parent, %{page: page, limit: limit}, %{
context: %{current_user: %User{role: role}}
})
when is_admin(role) do
with %Actor{} = relay_actor <- Relay.get_actor() do
%Page{} =
page = Actors.list_external_followings_for_actor_paginated(relay_actor, page, limit)
{:ok, page}
end
end
def create_relay(_parent, %{address: address}, %{context: %{current_user: %User{role: role}}})
when is_admin(role) do
case Relay.follow(address) do
{:ok, _activity, follow} ->
{:ok, follow}
{:error, {:error, err}} when is_bitstring(err) ->
{:error, err}
end
end
def remove_relay(_parent, %{address: address}, %{context: %{current_user: %User{role: role}}})
when is_admin(role) do
case Relay.unfollow(address) do
{:ok, _activity, follow} ->
{:ok, follow}
{:error, {:error, err}} when is_bitstring(err) ->
{:error, err}
end
end
def accept_subscription(_parent, %{address: address}, %{
context: %{current_user: %User{role: role}}
})
when is_admin(role) do
case Relay.accept(address) do
{:ok, _activity, follow} ->
{:ok, follow}
{:error, {:error, err}} when is_bitstring(err) ->
{:error, err}
end
end
def reject_subscription(_parent, %{address: address}, %{
context: %{current_user: %User{role: role}}
})
when is_admin(role) do
case Relay.reject(address) do
{:ok, _activity, follow} ->
{:ok, follow}
{:error, {:error, err}} when is_bitstring(err) ->
{:error, err}
end
end
end

View File

@ -245,6 +245,9 @@ defmodule MobilizonWeb.Resolvers.Event do
{:error,
"Participant #{id} can't be approved since it's already a participant (with role #{role})"}
{:has_participation, nil} ->
{:error, "Participant not found"}
{:actor_approve_permission, _} ->
{:error, "Provided moderator actor ID doesn't have permission on this event"}

View File

@ -47,8 +47,8 @@ defmodule MobilizonWeb.Resolvers.Group do
%{context: %{current_user: user}}
) do
with creator_actor_id <- Map.get(args, :creator_actor_id),
{:is_owned, %Actor{} = actor} <- User.owns_actor(user, creator_actor_id),
args <- Map.put(args, :creator_actor, actor),
{:is_owned, %Actor{} = creator_actor} <- User.owns_actor(user, creator_actor_id),
args <- Map.put(args, :creator_actor, creator_actor),
{:ok, _activity, %Actor{type: :Group} = group} <-
API.Groups.create_group(args) do
{:ok, group}

View File

@ -97,7 +97,7 @@ defmodule MobilizonWeb.Resolvers.Person do
{:find_actor, Actors.get_actor(id)},
{:is_owned, %Actor{}} <- User.owns_actor(user, actor.id),
args <- save_attached_pictures(args),
{:ok, actor} <- Actors.update_actor(actor, args) do
{:ok, _activity, %Actor{} = actor} <- ActivityPub.update(:actor, actor, args, true) do
{:ok, actor}
else
{:find_actor, nil} ->

View File

@ -16,6 +16,7 @@ defmodule MobilizonWeb.Router do
pipeline :activity_pub_signature do
plug(:accepts, ["activity-json", "html"])
plug(MobilizonWeb.HTTPSignaturePlug)
plug(MobilizonWeb.Plugs.MappedSignatureToIdentity)
end
pipeline :relay do
@ -91,6 +92,8 @@ defmodule MobilizonWeb.Router do
scope "/", MobilizonWeb do
pipe_through(:activity_pub_and_html)
pipe_through(:activity_pub_signature)
get("/@:name", PageController, :actor)
get("/events/:uuid", PageController, :event)
get("/comments/:uuid", PageController, :comment)

View File

@ -20,6 +20,7 @@ defmodule MobilizonWeb.Schema do
import_types(MobilizonWeb.Schema.ActorInterface)
import_types(MobilizonWeb.Schema.Actors.PersonType)
import_types(MobilizonWeb.Schema.Actors.GroupType)
import_types(MobilizonWeb.Schema.Actors.ApplicationType)
import_types(MobilizonWeb.Schema.CommentType)
import_types(MobilizonWeb.Schema.SearchType)
import_types(MobilizonWeb.Schema.ConfigType)
@ -140,5 +141,13 @@ defmodule MobilizonWeb.Schema do
import_fields(:feed_token_mutations)
import_fields(:picture_mutations)
import_fields(:report_mutations)
import_fields(:admin_mutations)
end
@desc """
Root subscription
"""
subscription do
import_fields(:person_subscriptions)
end
end

View File

@ -3,13 +3,10 @@ defmodule MobilizonWeb.Schema.ActorInterface do
Schema representation for Actor
"""
use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Actors.Actor
alias Mobilizon.{Events}
import_types(MobilizonWeb.Schema.Actors.FollowerType)
import_types(MobilizonWeb.Schema.EventType)
# import_types(MobilizonWeb.Schema.PictureType)
@desc "An ActivityPub actor"
interface :actor do
@ -21,7 +18,6 @@ defmodule MobilizonWeb.Schema.ActorInterface do
field(:local, :boolean, description: "If the actor is from this instance")
field(:summary, :string, description: "The actor's summary")
field(:preferred_username, :string, description: "The actor's preferred username")
field(:keys, :string, description: "The actors RSA Keys")
field(:manually_approves_followers, :boolean,
description: "Whether the actors manually approves followers"
@ -38,17 +34,6 @@ defmodule MobilizonWeb.Schema.ActorInterface do
field(:followersCount, :integer, description: "Number of followers for this actor")
field(:followingCount, :integer, description: "Number of actors following this actor")
# This one should have a privacy setting
field(:organized_events, list_of(:event),
resolve: dataloader(Events),
description: "A list of the events this actor has organized"
)
# This one is for the person itself **only**
# field(:feed, list_of(:event), description: "List of events the actor sees in his or her feed")
# field(:memberships, list_of(:member))
resolve_type(fn
%Actor{type: :Person}, _ ->
:person
@ -56,6 +41,9 @@ defmodule MobilizonWeb.Schema.ActorInterface do
%Actor{type: :Group}, _ ->
:group
%Actor{type: :Application}, _ ->
:application
_, _ ->
nil
end)

View File

@ -0,0 +1,38 @@
defmodule MobilizonWeb.Schema.Actors.ApplicationType do
@moduledoc """
Schema representation for Group.
"""
use Absinthe.Schema.Notation
@desc """
Represents an application
"""
object :application do
interfaces([:actor])
field(:id, :id, description: "Internal ID for this application")
field(:url, :string, description: "The ActivityPub actor's URL")
field(:type, :actor_type, description: "The type of Actor (Person, Group,…)")
field(:name, :string, description: "The actor's displayed name")
field(:domain, :string, description: "The actor's domain if (null if it's this instance)")
field(:local, :boolean, description: "If the actor is from this instance")
field(:summary, :string, description: "The actor's summary")
field(:preferred_username, :string, description: "The actor's preferred username")
field(:manually_approves_followers, :boolean,
description: "Whether the actors manually approves followers"
)
field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar, :picture, description: "The actor's avatar picture")
field(:banner, :picture, description: "The actor's banner picture")
# These one should have a privacy setting
field(:following, list_of(:follower), description: "List of followings")
field(:followers, list_of(:follower), description: "List of followers")
field(:followersCount, :integer, description: "Number of followers for this actor")
field(:followingCount, :integer, description: "Number of actors following this actor")
end
end

View File

@ -14,5 +14,13 @@ defmodule MobilizonWeb.Schema.Actors.FollowerType do
field(:approved, :boolean,
description: "Whether the follow has been approved by the target actor"
)
field(:inserted_at, :datetime, description: "When the follow was created")
field(:updated_at, :datetime, description: "When the follow was updated")
end
object :paginated_follower_list do
field(:elements, list_of(:follower), description: "A list of followers")
field(:total, :integer, description: "The total number of elements in the list")
end
end

View File

@ -27,7 +27,6 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
field(:local, :boolean, description: "If the actor is from this instance")
field(:summary, :string, description: "The actor's summary")
field(:preferred_username, :string, description: "The actor's preferred username")
field(:keys, :string, description: "The actors RSA Keys")
field(:manually_approves_followers, :boolean,
description: "Whether the actors manually approves followers"

View File

@ -27,7 +27,6 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do
field(:local, :boolean, description: "If the actor is from this instance")
field(:summary, :string, description: "The actor's summary")
field(:preferred_username, :string, description: "The actor's preferred username")
field(:keys, :string, description: "The actors RSA Keys")
field(:manually_approves_followers, :boolean,
description: "Whether the actors manually approves followers"
@ -160,4 +159,14 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do
resolve(handle_errors(&Person.register_person/3))
end
end
object :person_subscriptions do
field :event_person_participation_changed, :person do
arg(:person_id, non_null(:id))
config(fn args, _ ->
{:ok, topic: args.person_id}
end)
end
end
end

View File

@ -71,5 +71,49 @@ defmodule MobilizonWeb.Schema.AdminType do
field :dashboard, type: :dashboard do
resolve(&Admin.get_dashboard/3)
end
field :relay_followers, type: :paginated_follower_list do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&Admin.list_relay_followers/3)
end
field :relay_followings, type: :paginated_follower_list do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
arg(:order_by, :string, default_value: :updated_at)
arg(:direction, :string, default_value: :desc)
resolve(&Admin.list_relay_followings/3)
end
end
object :admin_mutations do
@desc "Add a relay subscription"
field :add_relay, type: :follower do
arg(:address, non_null(:string))
resolve(&Admin.create_relay/3)
end
@desc "Delete a relay subscription"
field :remove_relay, type: :follower do
arg(:address, non_null(:string))
resolve(&Admin.remove_relay/3)
end
@desc "Accept a relay subscription"
field :accept_relay, type: :follower do
arg(:address, non_null(:string))
resolve(&Admin.accept_subscription/3)
end
@desc "Reject a relay subscription"
field :reject_relay, type: :follower do
arg(:address, non_null(:string))
resolve(&Admin.reject_subscription/3)
end
end
end

View File

@ -75,6 +75,7 @@ defmodule MobilizonWeb.Schema.ReportType do
arg(:reported_id, non_null(:id))
arg(:event_id, :id, default_value: nil)
arg(:comments_ids, list_of(:id), default_value: [])
arg(:forward, :boolean, default_value: false)
resolve(&Report.create_report/3)
end

View File

@ -35,7 +35,11 @@
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p style="margin: 0;">
<%= gettext "%{reporter_name} (%{reporter_username}) reported the following content.", reporter_name: @report.reporter.name, reporter_username: Mobilizon.Actors.Actor.preferred_username_and_domain(@report.reporter) %>
<%= if @report.reporter.type == :Application and @report.reporter.preferred_username == "relay" do %>
<%= gettext "Someone on %{instance} reported the following content.", instance: @report.reporter.domain %>
<% else %>
<%= gettext "%{reporter_name} (%{reporter_username}) reported the following content.", reporter_name: @report.reporter.name, reporter_username: Mobilizon.Actors.Actor.preferred_username_and_domain(@report.reporter) %>
<% end %>
</p>
</td>
</tr>
@ -59,10 +63,10 @@
<%= if Map.has_key?(@report, :comments) && length(@report.comments) > 0 do %>
<tr>
<td bgcolor="#ffffff" align="left" style="padding: 20px 30px 0px 30px; color: #666666; font-family: 'Lato', Helvetica, Arial, sans-serif; font-size: 18px; font-weight: 400; line-height: 25px;" >
<p><%= gettext "Comments" %></p>
<h3><%= gettext "Comments" %></h3>
<%= for comment <- @report.comments do %>
<p style="margin: 0;">
<%= comment.text %>
<%= HtmlSanitizeEx.strip_tags(comment.text) %>
</p>
<% end %>
<table cellspacing="0" cellpadding="0" border="0" width="100%" style="width: 100% !important;">

View File

@ -4,20 +4,26 @@
<%= if Map.has_key?(@report, :event) do %>
<%= gettext "Event" %>
<%= @report.event.title %>
<% end %>
<%= if Map.has_key?(@report, :comments) && length(@report.comments) > 0 do %>
<%= gettext "Comments" %>
<%= for comment <- @report.comments do %>
<%= comment.text %>
<% end %>
<% end %>
<%= if @report.content do %>
<%= gettext "Reason" %>
<%= @report.content %>
<% end %>
View the report: <%= moderation_report_url(MobilizonWeb.Endpoint, :index, @report.id) %>

View File

@ -148,6 +148,21 @@ defmodule MobilizonWeb.Upload do
end
end
defp prepare_upload(%{body: body, name: name} = _file, opts) do
with :ok <- check_binary_size(body, opts.size_limit),
tmp_path <- tempfile_for_image(body),
{:ok, content_type, name} <- MIME.file_mime_type(tmp_path, name) do
{:ok,
%__MODULE__{
id: UUID.generate(),
name: name,
tempfile: tmp_path,
content_type: content_type,
size: byte_size(body)
}}
end
end
defp check_file_size(path, size_limit) when is_integer(size_limit) and size_limit > 0 do
with {:ok, %{size: size}} <- File.stat(path),
true <- size <= size_limit do
@ -160,6 +175,23 @@ defmodule MobilizonWeb.Upload do
defp check_file_size(_, _), do: :ok
defp check_binary_size(binary, size_limit)
when is_integer(size_limit) and size_limit > 0 and byte_size(binary) >= size_limit do
{:error, :file_too_large}
end
defp check_binary_size(_, _), do: :ok
# Creates a tempfile using the Plug.Upload Genserver which cleans them up
# automatically.
defp tempfile_for_image(data) do
{:ok, tmp_path} = Plug.Upload.random_file("temp_files")
{:ok, tmp_file} = File.open(tmp_path, [:write, :raw, :binary])
IO.binwrite(tmp_file, data)
tmp_path
end
defp url_from_spec(%__MODULE__{name: name}, base_url, {:file, path}) do
path =
URI.encode(path, &char_unescaped?/1) <>

View File

@ -4,44 +4,13 @@ defmodule MobilizonWeb.ActivityPub.ActorView do
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Service.ActivityPub
alias Mobilizon.Service.ActivityPub.{Activity, Utils}
alias Mobilizon.Service.ActivityPub.{Activity, Utils, Convertible}
@private_visibility_empty_collection %{elements: [], total: 0}
def render("actor.json", %{actor: actor}) do
public_key = Utils.pem_to_public_key_pem(actor.keys)
%{
"id" => actor.url,
"type" => to_string(actor.type),
"following" => actor.following_url,
"followers" => actor.followers_url,
"inbox" => actor.inbox_url,
"outbox" => actor.outbox_url,
"preferredUsername" => actor.preferred_username,
"name" => actor.name,
"summary" => actor.summary,
"url" => actor.url,
"manuallyApprovesFollowers" => actor.manually_approves_followers,
"publicKey" => %{
"id" => "#{actor.url}#main-key",
"owner" => actor.url,
"publicKeyPem" => public_key
},
# TODO : Make have actors have an uuid
# "uuid" => actor.uuid
"endpoints" => %{
"sharedInbox" => actor.shared_inbox_url
}
# "icon" => %{
# "type" => "Image",
# "url" => User.avatar_url(actor)
# },
# "image" => %{
# "type" => "Image",
# "url" => User.banner_url(actor)
# }
}
actor
|> Convertible.model_to_as()
|> Map.merge(Utils.make_json_ld_header())
end

View File

@ -4,69 +4,34 @@ defmodule MobilizonWeb.PageView do
"""
use MobilizonWeb, :view
alias Mobilizon.Actors.Actor
alias Mobilizon.Service.ActivityPub.{Converter, Utils}
alias Mobilizon.Tombstone
alias Mobilizon.Service.ActivityPub.{Convertible, Utils}
alias Mobilizon.Service.Metadata
alias Mobilizon.Service.MetadataUtils
alias Mobilizon.Service.Metadata.Instance
alias Mobilizon.Events.{Comment, Event}
def render("actor.activity-json", %{conn: %{assigns: %{object: actor}}}) do
public_key = Utils.pem_to_public_key_pem(actor.keys)
%{
"id" => Actor.build_url(actor.preferred_username, :page),
"type" => "Person",
"following" => Actor.build_url(actor.preferred_username, :following),
"followers" => Actor.build_url(actor.preferred_username, :followers),
"inbox" => Actor.build_url(actor.preferred_username, :inbox),
"outbox" => Actor.build_url(actor.preferred_username, :outbox),
"preferredUsername" => actor.preferred_username,
"name" => actor.name,
"summary" => actor.summary,
"url" => actor.url,
"manuallyApprovesFollowers" => actor.manually_approves_followers,
"publicKey" => %{
"id" => "#{actor.url}#main-key",
"owner" => actor.url,
"publicKeyPem" => public_key
},
# TODO : Make have actors have an uuid
# "uuid" => actor.uuid
"endpoints" => %{
"sharedInbox" => actor.shared_inbox_url
}
# "icon" => %{
# "type" => "Image",
# "url" => User.avatar_url(actor)
# },
# "image" => %{
# "type" => "Image",
# "url" => User.banner_url(actor)
# }
}
def render("actor.activity-json", %{conn: %{assigns: %{object: %Actor{} = actor}}}) do
actor
|> Convertible.model_to_as()
|> Map.merge(Utils.make_json_ld_header())
end
def render("event.activity-json", %{conn: %{assigns: %{object: event}}}) do
def render("event.activity-json", %{conn: %{assigns: %{object: %Event{} = event}}}) do
event
|> Converter.Event.model_to_as()
|> Convertible.model_to_as()
|> Map.merge(Utils.make_json_ld_header())
end
def render("comment.activity-json", %{conn: %{assigns: %{object: comment}}}) do
comment = Converter.Comment.model_to_as(comment)
def render("event.activity-json", %{conn: %{assigns: %{object: %Tombstone{} = event}}}) do
event
|> Convertible.model_to_as()
|> Map.merge(Utils.make_json_ld_header())
end
%{
"actor" => comment["actor"],
"uuid" => comment["uuid"],
# The activity should have attributedTo, not the comment itself
# "attributedTo" => comment.attributed_to,
"type" => "Note",
"id" => comment["id"],
"content" => comment["content"],
"mediaType" => "text/html"
# "published" => Timex.format!(comment.inserted_at, "{ISO:Extended}"),
# "updated" => Timex.format!(comment.updated_at, "{ISO:Extended}")
}
def render("comment.activity-json", %{conn: %{assigns: %{object: %Comment{} = comment}}}) do
comment
|> Convertible.model_to_as()
|> Map.merge(Utils.make_json_ld_header())
end
@ -74,7 +39,9 @@ defmodule MobilizonWeb.PageView do
when page in ["actor.html", "event.html", "comment.html"] do
with {:ok, index_content} <- File.read(index_file_path()) do
tags = object |> Metadata.build_tags() |> MetadataUtils.stringify_tags()
index_content = String.replace(index_content, "<meta name=server-injected-data>", tags)
index_content = replace_meta(index_content, tags)
{:safe, index_content}
end
end
@ -82,7 +49,9 @@ defmodule MobilizonWeb.PageView do
def render("index.html", _assigns) do
with {:ok, index_content} <- File.read(index_file_path()) do
tags = Instance.build_tags() |> MetadataUtils.stringify_tags()
index_content = String.replace(index_content, "<meta name=server-injected-data>", tags)
index_content = replace_meta(index_content, tags)
{:safe, index_content}
end
end
@ -90,4 +59,11 @@ defmodule MobilizonWeb.PageView do
defp index_file_path do
Path.join(Application.app_dir(:mobilizon, "priv/static"), "index.html")
end
# TODO: Find why it's different in dev/prod and during tests
defp replace_meta(index_content, tags) do
index_content
|> String.replace("<meta name=\"server-injected-data\" />", tags)
|> String.replace("<meta name=server-injected-data>", tags)
end
end

View File

@ -11,7 +11,7 @@ defmodule Mobilizon.Service.ActivityPub do
import Mobilizon.Service.ActivityPub.Utils
import Mobilizon.Service.ActivityPub.Visibility
alias Mobilizon.{Actors, Config, Events, Reports, Users}
alias Mobilizon.{Actors, Config, Events, Reports, Users, Share}
alias Mobilizon.Actors.{Actor, Follower}
alias Mobilizon.Events.{Comment, Event, Participant}
alias Mobilizon.Reports.Report
@ -50,6 +50,15 @@ defmodule Mobilizon.Service.ActivityPub do
def fetch_object_from_url(url) do
Logger.info("Fetching object from url #{url}")
date = Mobilizon.Service.HTTPSignatures.Signature.generate_date_header()
headers =
[{:Accept, "application/activity+json"}]
|> maybe_date_fetch(date)
|> sign_fetch(url, date)
Logger.debug("Fetch headers: #{inspect(headers)}")
with {:not_http, true} <- {:not_http, String.starts_with?(url, "http")},
{:existing_event, nil} <- {:existing_event, Events.get_event_by_url(url)},
{:existing_comment, nil} <- {:existing_comment, Events.get_comment_from_url(url)},
@ -58,12 +67,13 @@ defmodule Mobilizon.Service.ActivityPub do
{:ok, %{body: body, status_code: code}} when code in 200..299 <-
HTTPoison.get(
url,
[Accept: "application/activity+json"],
headers,
follow_redirect: true,
timeout: 10_000,
recv_timeout: 20_000
),
{:ok, data} <- Jason.decode(body),
{:origin_check, true} <- {:origin_check, origin_check?(url, data)},
params <- %{
"type" => "Create",
"to" => data["to"],
@ -95,6 +105,10 @@ defmodule Mobilizon.Service.ActivityPub do
{:existing_actor, {:ok, %Actor{url: actor_url}}} ->
{:ok, Actors.get_actor_by_url!(actor_url, true)}
{:origin_check, false} ->
Logger.warn("Object origin check failed")
{:error, "Object origin check failed"}
e ->
{:error, e}
end
@ -114,9 +128,9 @@ defmodule Mobilizon.Service.ActivityPub do
{:ok, %Actor{} = actor} ->
{:ok, actor}
_ ->
err ->
Logger.warn("Could not fetch by AP id")
Logger.debug(inspect(err))
{:error, "Could not fetch by AP id"}
end
end
@ -184,11 +198,13 @@ defmodule Mobilizon.Service.ActivityPub do
end
end
def accept(type, entity, args, local \\ false, additional \\ %{}) do
def accept(type, entity, local \\ true, additional \\ %{}) do
Logger.debug("We're accepting something")
{:ok, entity, update_data} =
case type do
:join -> accept_join(entity, args, additional)
:follow -> accept_follow(entity, args, additional)
:join -> accept_join(entity, additional)
:follow -> accept_follow(entity, additional)
end
with {:ok, activity} <- create_activity(update_data, local),
@ -202,63 +218,24 @@ defmodule Mobilizon.Service.ActivityPub do
end
end
def reject(%{to: to, actor: actor, object: object} = params, activity_wrapper_id \\ nil) do
# only accept false as false value
local = !(params[:local] == false)
def reject(type, entity, local \\ true, additional \\ %{}) do
{:ok, entity, update_data} =
case type do
:join -> reject_join(entity, additional)
:follow -> reject_follow(entity, additional)
end
with data <- %{
"to" => to,
"type" => "Reject",
"actor" => actor,
"object" => object,
"id" => activity_wrapper_id || get_url(object) <> "/activity"
},
{:ok, activity} <- create_activity(data, local),
{:ok, object} <- insert_full_object(data),
with {:ok, activity} <- create_activity(update_data, local),
:ok <- maybe_federate(activity) do
{:ok, activity, object}
{:ok, activity, entity}
else
err ->
Logger.error("Something went wrong while creating an activity")
Logger.debug(inspect(err))
err
end
end
# TODO: This is weird, maybe we shouldn't check here if we can make the activity.
# def like(
# %Actor{url: url} = actor,
# object,
# activity_id \\ nil,
# local \\ true
# ) do
# with nil <- get_existing_like(url, object),
# like_data <- make_like_data(user, object, activity_id),
# {:ok, activity} <- create_activity(like_data, local),
# {:ok, object} <- insert_full_object(data),
# {:ok, object} <- add_like_to_object(activity, object),
# :ok <- maybe_federate(activity) do
# {:ok, activity, object}
# else
# %Activity{} = activity -> {:ok, activity, object}
# error -> {:error, error}
# end
# end
# def unlike(
# %User{} = actor,
# %Object{} = object,
# activity_id \\ nil,
# local \\ true
# ) do
# with %Activity{} = like_activity <- get_existing_like(actor.ap_id, object),
# unlike_data <- make_unlike_data(actor, like_activity, activity_id),
# {:ok, unlike_activity} <- create_activity(unlike_data, local),
# {:ok, _object} <- insert_full_object(data),
# {:ok, _activity} <- Repo.delete(like_activity),
# {:ok, object} <- remove_like_from_object(like_activity, object),
# :ok <- maybe_federate(unlike_activity) do
# {:ok, unlike_activity, like_activity, object}
# else
# _e -> {:ok, object}
# end
# end
def announce(
%Actor{} = actor,
object,
@ -267,9 +244,10 @@ defmodule Mobilizon.Service.ActivityPub do
public \\ true
) do
with true <- is_public?(object),
{:ok, %Actor{id: object_owner_actor_id}} <- Actors.get_actor_by_url(object["actor"]),
{:ok, %Share{} = _share} <- Share.create(object["id"], actor.id, object_owner_actor_id),
announce_data <- make_announce_data(actor, object, activity_id, public),
{:ok, activity} <- create_activity(announce_data, local),
{:ok, object} <- insert_full_object(announce_data),
:ok <- maybe_federate(activity) do
{:ok, activity, object}
else
@ -288,7 +266,6 @@ defmodule Mobilizon.Service.ActivityPub do
with announce_activity <- make_announce_data(actor, object, cancelled_activity_id),
unannounce_data <- make_unannounce_data(actor, announce_activity, activity_id),
{:ok, unannounce_activity} <- create_activity(unannounce_data, local),
{:ok, object} <- insert_full_object(unannounce_data),
:ok <- maybe_federate(unannounce_activity) do
{:ok, unannounce_activity, object}
else
@ -327,9 +304,8 @@ defmodule Mobilizon.Service.ActivityPub do
unfollow_data <-
make_unfollow_data(follower, followed, follow_activity, activity_unfollow_id),
{:ok, activity} <- create_activity(unfollow_data, local),
{:ok, object} <- insert_full_object(unfollow_data),
:ok <- maybe_federate(activity) do
{:ok, activity, object}
{:ok, activity, follow}
else
err ->
Logger.debug("Error while unfollowing an actor #{inspect(err)}")
@ -339,6 +315,7 @@ defmodule Mobilizon.Service.ActivityPub do
def delete(object, local \\ true)
@spec delete(Event.t(), boolean) :: {:ok, Activity.t(), Event.t()}
def delete(%Event{url: url, organizer_actor: actor} = event, local) do
data = %{
"type" => "Delete",
@ -348,15 +325,19 @@ defmodule Mobilizon.Service.ActivityPub do
"id" => url <> "/delete"
}
with {:ok, %Event{} = event} <- Events.delete_event(event),
with audience <-
Audience.calculate_to_and_cc_from_mentions(event),
{:ok, %Event{} = event} <- Events.delete_event(event),
{:ok, %Tombstone{} = _tombstone} <-
Tombstone.create_tombstone(%{uri: event.url, actor_id: actor.id}),
{:ok, activity} <- create_activity(data, local),
Share.delete_all_by_uri(event.url),
{:ok, activity} <- create_activity(Map.merge(data, audience), local),
:ok <- maybe_federate(activity) do
{:ok, activity, event}
end
end
@spec delete(Comment.t(), boolean) :: {:ok, Activity.t(), Comment.t()}
def delete(%Comment{url: url, actor: actor} = comment, local) do
data = %{
"type" => "Delete",
@ -366,10 +347,13 @@ defmodule Mobilizon.Service.ActivityPub do
"to" => [actor.url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"]
}
with {:ok, %Comment{} = comment} <- Events.delete_comment(comment),
with audience <-
Audience.calculate_to_and_cc_from_mentions(comment),
{:ok, %Comment{} = comment} <- Events.delete_comment(comment),
{:ok, %Tombstone{} = _tombstone} <-
Tombstone.create_tombstone(%{uri: comment.url, actor_id: actor.id}),
{:ok, activity} <- create_activity(data, local),
Share.delete_all_by_uri(comment.url),
{:ok, activity} <- create_activity(Map.merge(data, audience), local),
:ok <- maybe_federate(activity) do
{:ok, activity, comment}
end
@ -384,7 +368,7 @@ defmodule Mobilizon.Service.ActivityPub do
"to" => [url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"]
}
with {:ok, %Actor{} = actor} <- Actors.delete_actor(actor),
with {:ok, %Oban.Job{}} <- Actors.delete_actor(actor),
{:ok, activity} <- create_activity(data, local),
:ok <- maybe_federate(activity) do
{:ok, activity, actor}
@ -396,6 +380,8 @@ defmodule Mobilizon.Service.ActivityPub do
{:create_report, {:ok, %Report{} = report}} <-
{:create_report, Reports.create_report(args)},
report_as_data <- Convertible.model_to_as(report),
cc <- if(local, do: [report.reported.url], else: []),
report_as_data <- Map.merge(report_as_data, %{"to" => [], "cc" => cc}),
{:ok, activity} <- create_activity(report_as_data, local),
:ok <- maybe_federate(activity) do
Enum.each(Users.list_moderators(), fn moderator ->
@ -413,52 +399,56 @@ defmodule Mobilizon.Service.ActivityPub do
end
end
def join(object, actor, local \\ true)
def join(object, actor, local \\ true, additional \\ %{})
def join(%Event{options: options} = event, %Actor{} = actor, local) do
def join(%Event{} = event, %Actor{} = actor, local, additional) do
# TODO Refactor me for federation
with maximum_attendee_capacity <-
Map.get(options, :maximum_attendee_capacity) || 0,
{:maximum_attendee_capacity, true} <-
{:maximum_attendee_capacity,
maximum_attendee_capacity == 0 ||
Mobilizon.Events.count_participant_participants(event.id) <
maximum_attendee_capacity},
role <- Mobilizon.Events.get_default_participant_role(event),
with {:maximum_attendee_capacity, true} <-
{:maximum_attendee_capacity, check_attendee_capacity(event)},
{:ok, %Participant{} = participant} <-
Mobilizon.Events.create_participant(%{
role: role,
role: :not_approved,
event_id: event.id,
actor_id: actor.id
actor_id: actor.id,
url: Map.get(additional, :url)
}),
join_data <- Convertible.model_to_as(participant),
join_data <- Map.put(join_data, "to", [event.organizer_actor.url]),
join_data <- Map.put(join_data, "cc", []),
{:ok, activity} <- create_activity(join_data, local),
{:ok, _object} <- insert_full_object(join_data),
audience <-
Audience.calculate_to_and_cc_from_mentions(participant),
{:ok, activity} <- create_activity(Map.merge(join_data, audience), local),
:ok <- maybe_federate(activity) do
if role === :participant do
accept_join(
if event.local && Mobilizon.Events.get_default_participant_role(event) === :participant do
accept(
:join,
participant,
%{}
true,
%{"actor" => event.organizer_actor.url}
)
else
{:ok, activity, participant}
end
{:ok, activity, participant}
end
end
# TODO: Implement me
def join(%Actor{type: :Group} = _group, %Actor{} = _actor, _local) do
def join(%Actor{type: :Group} = _group, %Actor{} = _actor, _local, _additional) do
:error
end
defp check_attendee_capacity(%Event{options: options} = event) do
with maximum_attendee_capacity <-
Map.get(options, :maximum_attendee_capacity) || 0 do
maximum_attendee_capacity == 0 ||
Mobilizon.Events.count_participant_participants(event.id) < maximum_attendee_capacity
end
end
def leave(object, actor, local \\ true)
# TODO: If we want to use this for exclusion we need to have an extra field
# for the actor that excluded the participant
def leave(
%Event{id: event_id, url: event_url} = event,
%Event{id: event_id, url: event_url} = _event,
%Actor{id: actor_id, url: actor_url} = _actor,
local
) do
@ -473,11 +463,11 @@ defmodule Mobilizon.Service.ActivityPub do
# If it's an exclusion it should be something else
"actor" => actor_url,
"object" => event_url,
"to" => [event.organizer_actor.url],
"cc" => []
"id" => "#{MobilizonWeb.Endpoint.url()}/leave/event/#{participant.id}"
},
{:ok, activity} <- create_activity(leave_data, local),
{:ok, _object} <- insert_full_object(leave_data),
audience <-
Audience.calculate_to_and_cc_from_mentions(participant),
{:ok, activity} <- create_activity(Map.merge(leave_data, audience), local),
:ok <- maybe_federate(activity) do
{:ok, activity, participant}
end
@ -537,16 +527,22 @@ defmodule Mobilizon.Service.ActivityPub do
end
end
@spec is_create_activity?(Activity.t()) :: boolean
defp is_create_activity?(%Activity{data: %{"type" => "Create"}}), do: true
defp is_create_activity?(_), do: false
@doc """
Publish an activity to all appropriated audiences inboxes
"""
@spec publish(Actor.t(), Activity.t()) :: :ok
def publish(actor, activity) do
Logger.debug("Publishing an activity")
Logger.debug(inspect(activity))
public = is_public?(activity)
Logger.debug("is public ? #{public}")
if public && !is_delete_activity?(activity) && Config.get([:instance, :allow_relay]) do
if public && is_create_activity?(activity) && Config.get([:instance, :allow_relay]) do
Logger.info(fn -> "Relaying #{activity.data["id"]} out" end)
Relay.publish(activity)
@ -578,15 +574,12 @@ defmodule Mobilizon.Service.ActivityPub do
end)
end
defp is_delete_activity?(%Activity{data: %{"type" => "Delete"}}), do: true
defp is_delete_activity?(_), do: false
@doc """
Publish an activity to a specific inbox
"""
def publish_one(%{inbox: inbox, json: json, actor: actor, id: id}) do
Logger.info("Federating #{id} to #{inbox}")
%URI{host: host, path: _path} = URI.parse(inbox)
%URI{host: host, path: path} = URI.parse(inbox)
digest = Signature.build_digest(json)
date = Signature.generate_date_header()
@ -594,10 +587,9 @@ defmodule Mobilizon.Service.ActivityPub do
signature =
Signature.sign(actor, %{
"(request-target)": "post #{path}",
host: host,
"content-length": byte_size(json),
# TODO : Look me up in depth why Pleroma handles this inside lib/mobilizon_web/http_signature.ex
# "(request-target)": request_target,
digest: digest,
date: date
})
@ -627,7 +619,7 @@ defmodule Mobilizon.Service.ActivityPub do
:ok <- Logger.debug("response okay, now decoding json"),
{:ok, data} <- Jason.decode(body) do
Logger.debug("Got activity+json response at actor's endpoint, now converting data")
actor_data_from_actor_object(data)
Mobilizon.Service.ActivityPub.Converter.Actor.as_to_model_data(data)
else
# Actor is gone, probably deleted
{:ok, %HTTPoison.Response{status_code: 410}} ->
@ -642,49 +634,6 @@ defmodule Mobilizon.Service.ActivityPub do
res
end
@doc """
Creating proper actor data struct from AP data
Convert ActivityPub data to our internal format
"""
@spec actor_data_from_actor_object(map()) :: {:ok, map()}
def actor_data_from_actor_object(data) when is_map(data) do
avatar =
data["icon"]["url"] &&
%{
"name" => data["icon"]["name"] || "avatar",
"url" => data["icon"]["url"]
}
banner =
data["image"]["url"] &&
%{
"name" => data["image"]["name"] || "banner",
"url" => data["image"]["url"]
}
actor_data = %{
url: data["id"],
avatar: avatar,
banner: banner,
name: data["name"],
preferred_username: data["preferredUsername"],
summary: data["summary"],
keys: data["publicKey"]["publicKeyPem"],
inbox_url: data["inbox"],
outbox_url: data["outbox"],
following_url: data["following"],
followers_url: data["followers"],
shared_inbox_url: data["endpoints"]["sharedInbox"],
domain: URI.parse(data["id"]).host,
manually_approves_followers: data["manuallyApprovesFollowers"],
type: data["type"]
}
{:ok, actor_data}
end
@doc """
Return all public activities (events & comments) for an actor
"""
@ -736,12 +685,7 @@ defmodule Mobilizon.Service.ActivityPub do
{:ok, %Event{} = event} <- Events.create_event(args),
event_as_data <- Convertible.model_to_as(event),
audience <-
Audience.calculate_to_and_cc_from_mentions(
event.organizer_actor,
args.mentions,
nil,
event.visibility
),
Audience.calculate_to_and_cc_from_mentions(event),
create_data <-
make_create_data(event_as_data, Map.merge(audience, additional)) do
{:ok, event, create_data}
@ -754,12 +698,7 @@ defmodule Mobilizon.Service.ActivityPub do
{:ok, %Comment{} = comment} <- Events.create_comment(args),
comment_as_data <- Convertible.model_to_as(comment),
audience <-
Audience.calculate_to_and_cc_from_mentions(
comment.actor,
args.mentions,
args.in_reply_to_comment,
comment.visibility
),
Audience.calculate_to_and_cc_from_mentions(comment),
create_data <-
make_create_data(comment_as_data, Map.merge(audience, additional)) do
{:ok, comment, create_data}
@ -771,13 +710,7 @@ defmodule Mobilizon.Service.ActivityPub do
with args <- prepare_args_for_group(args),
{:ok, %Actor{type: :Group} = group} <- Actors.create_group(args),
group_as_data <- Convertible.model_to_as(group),
audience <-
Audience.calculate_to_and_cc_from_mentions(
args.creator_actor,
[],
nil,
:public
),
audience <- %{"to" => ["https://www.w3.org/ns/activitystreams#Public"], "cc" => []},
create_data <-
make_create_data(group_as_data, Map.merge(audience, additional)) do
{:ok, group, create_data}
@ -799,12 +732,7 @@ defmodule Mobilizon.Service.ActivityPub do
{:ok, %Event{} = new_event} <- Events.update_event(old_event, args),
event_as_data <- Convertible.model_to_as(new_event),
audience <-
Audience.calculate_to_and_cc_from_mentions(
new_event.organizer_actor,
Map.get(args, :mentions, []),
nil,
new_event.visibility
),
Audience.calculate_to_and_cc_from_mentions(new_event),
update_data <- make_update_data(event_as_data, Map.merge(audience, additional)) do
{:ok, new_event, update_data}
else
@ -821,34 +749,29 @@ defmodule Mobilizon.Service.ActivityPub do
with {:ok, %Actor{} = new_actor} <- Actors.update_actor(old_actor, args),
actor_as_data <- Convertible.model_to_as(new_actor),
audience <-
Audience.calculate_to_and_cc_from_mentions(
new_actor,
[],
nil,
:public
),
Audience.calculate_to_and_cc_from_mentions(new_actor),
additional <- Map.merge(additional, %{"actor" => old_actor.url}),
update_data <- make_update_data(actor_as_data, Map.merge(audience, additional)) do
{:ok, new_actor, update_data}
end
end
@spec accept_follow(Follower.t(), map(), map()) ::
@spec accept_follow(Follower.t(), map()) ::
{:ok, Follower.t(), Activity.t()} | any()
defp accept_follow(
%Follower{} = follower,
args,
additional
) do
with {:ok, %Follower{} = follower} <- Actors.update_follower(follower, args),
with {:ok, %Follower{} = follower} <- Actors.update_follower(follower, %{approved: true}),
follower_as_data <- Convertible.model_to_as(follower),
audience <-
Audience.calculate_to_and_cc_from_mentions(follower.target_actor),
update_data <-
make_update_data(
make_accept_join_data(
follower_as_data,
Map.merge(Map.merge(audience, additional), %{
"id" => "#{MobilizonWeb.Endpoint.url()}/accept/follow/#{follower.id}"
Map.merge(additional, %{
"id" => "#{MobilizonWeb.Endpoint.url()}/accept/follow/#{follower.id}",
"to" => [follower.actor.url],
"cc" => [],
"actor" => follower.target_actor.url
})
) do
{:ok, follower, update_data}
@ -860,17 +783,20 @@ defmodule Mobilizon.Service.ActivityPub do
end
end
@spec accept_join(Participant.t(), map(), map()) ::
@spec accept_join(Participant.t(), map()) ::
{:ok, Participant.t(), Activity.t()} | any()
defp accept_join(
%Participant{} = participant,
args,
additional \\ %{}
additional
) do
with {:ok, %Participant{} = participant} <- Events.update_participant(participant, args),
with {:ok, %Participant{} = participant} <-
Events.update_participant(participant, %{role: :participant}),
Absinthe.Subscription.publish(MobilizonWeb.Endpoint, participant.actor,
event_person_participation_changed: participant.actor.id
),
participant_as_data <- Convertible.model_to_as(participant),
audience <-
Audience.calculate_to_and_cc_from_mentions(participant.actor),
Audience.calculate_to_and_cc_from_mentions(participant),
update_data <-
make_accept_join_data(
participant_as_data,
@ -887,6 +813,66 @@ defmodule Mobilizon.Service.ActivityPub do
end
end
@spec reject_join(Participant.t(), map()) ::
{:ok, Participant.t(), Activity.t()} | any()
defp reject_join(%Participant{} = participant, additional) do
with {:ok, %Participant{} = participant} <-
Events.update_participant(participant, %{approved: false, role: :rejected}),
Absinthe.Subscription.publish(MobilizonWeb.Endpoint, participant.actor,
event_person_participation_changed: participant.actor.id
),
participant_as_data <- Convertible.model_to_as(participant),
audience <-
participant
|> Audience.calculate_to_and_cc_from_mentions()
|> Map.merge(additional),
reject_data <- %{
"type" => "Reject",
"object" => participant_as_data
},
update_data <-
reject_data
|> Map.merge(audience)
|> Map.merge(%{
"id" => "#{MobilizonWeb.Endpoint.url()}/reject/join/#{participant.id}"
}) do
{:ok, participant, update_data}
else
err ->
Logger.error("Something went wrong while creating an update activity")
Logger.debug(inspect(err))
err
end
end
@spec reject_follow(Follower.t(), map()) ::
{:ok, Follower.t(), Activity.t()} | any()
defp reject_follow(%Follower{} = follower, additional) do
with {:ok, %Follower{} = follower} <- Actors.delete_follower(follower),
follower_as_data <- Convertible.model_to_as(follower),
audience <-
follower.actor |> Audience.calculate_to_and_cc_from_mentions() |> Map.merge(additional),
reject_data <- %{
"to" => follower.actor.url,
"type" => "Reject",
"actor" => follower.actor.url,
"object" => follower_as_data
},
update_data <-
reject_data
|> Map.merge(audience)
|> Map.merge(%{
"id" => "#{MobilizonWeb.Endpoint.url()}/reject/follow/#{follower.id}"
}) do
{:ok, follower, update_data}
else
err ->
Logger.error("Something went wrong while creating an update activity")
Logger.debug(inspect(err))
err
end
end
# Prepare and sanitize arguments for events
defp prepare_args_for_event(args) do
# If title is not set: we are not updating it
@ -923,7 +909,8 @@ defmodule Mobilizon.Service.ActivityPub do
# Prepare and sanitize arguments for comments
defp prepare_args_for_comment(args) do
with in_reply_to_comment <-
args |> Map.get(:in_reply_to_comment_id) |> Events.get_comment(),
args |> Map.get(:in_reply_to_comment_id) |> Events.get_comment_with_preload(),
event <- args |> Map.get(:event_id) |> handle_event_for_comment(),
args <- Map.update(args, :visibility, :public, & &1),
{text, mentions, tags} <-
APIUtils.make_content_html(
@ -940,6 +927,7 @@ defmodule Mobilizon.Service.ActivityPub do
text: text,
mentions: mentions,
tags: tags,
event: event,
in_reply_to_comment: in_reply_to_comment,
in_reply_to_comment_id:
if(is_nil(in_reply_to_comment), do: nil, else: Map.get(in_reply_to_comment, :id)),
@ -953,6 +941,16 @@ defmodule Mobilizon.Service.ActivityPub do
end
end
@spec handle_event_for_comment(String.t() | integer() | nil) :: Event.t() | nil
defp handle_event_for_comment(event_id) when not is_nil(event_id) do
case Events.get_event_with_preload(event_id) do
{:ok, %Event{} = event} -> event
{:error, :event_not_found} -> nil
end
end
defp handle_event_for_comment(nil), do: nil
defp prepare_args_for_group(args) do
with preferred_username <-
args |> Map.get(:preferred_username) |> HtmlSanitizeEx.strip_tags() |> String.trim(),

View File

@ -2,7 +2,13 @@ defmodule Mobilizon.Service.ActivityPub.Audience do
@moduledoc """
Tools for calculating content audience
"""
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Events.Participant
alias Mobilizon.Share
require Logger
@ap_public "https://www.w3.org/ns/activitystreams#Public"
@ -13,35 +19,27 @@ defmodule Mobilizon.Service.ActivityPub.Audience do
* `to` : the mentioned actors, the eventual actor we're replying to and the public
* `cc` : the actor's followers
"""
@spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()}
def get_to_and_cc(%Actor{} = actor, mentions, in_reply_to, :public) do
@spec get_to_and_cc(Actor.t(), list(), String.t()) :: {list(), list()}
def get_to_and_cc(%Actor{} = actor, mentions, :public) do
to = [@ap_public | mentions]
cc = [actor.followers_url]
if in_reply_to do
{Enum.uniq([in_reply_to.actor | to]), cc}
else
{to, cc}
end
{to, cc}
end
@doc """
Determines the full audience based on mentions based on a unlisted audience
Audience is:
* `to` : the mentionned actors, actor's followers and the eventual actor we're replying to
* `to` : the mentioned actors, actor's followers and the eventual actor we're replying to
* `cc` : public
"""
@spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()}
def get_to_and_cc(%Actor{} = actor, mentions, in_reply_to, :unlisted) do
@spec get_to_and_cc(Actor.t(), list(), String.t()) :: {list(), list()}
def get_to_and_cc(%Actor{} = actor, mentions, :unlisted) do
to = [actor.followers_url | mentions]
cc = [@ap_public]
if in_reply_to do
{Enum.uniq([in_reply_to.actor | to]), cc}
else
{to, cc}
end
{to, cc}
end
@doc """
@ -51,9 +49,9 @@ defmodule Mobilizon.Service.ActivityPub.Audience do
* `to` : the mentioned actors, actor's followers and the eventual actor we're replying to
* `cc` : none
"""
@spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()}
def get_to_and_cc(%Actor{} = actor, mentions, in_reply_to, :private) do
{to, cc} = get_to_and_cc(actor, mentions, in_reply_to, :direct)
@spec get_to_and_cc(Actor.t(), list(), String.t()) :: {list(), list()}
def get_to_and_cc(%Actor{} = actor, mentions, :private) do
{to, cc} = get_to_and_cc(actor, mentions, :direct)
{[actor.followers_url | to], cc}
end
@ -64,16 +62,12 @@ defmodule Mobilizon.Service.ActivityPub.Audience do
* `to` : the mentioned actors and the eventual actor we're replying to
* `cc` : none
"""
@spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()}
def get_to_and_cc(_actor, mentions, in_reply_to, :direct) do
if in_reply_to do
{Enum.uniq([in_reply_to.actor | mentions]), []}
else
{mentions, []}
end
@spec get_to_and_cc(Actor.t(), list(), String.t()) :: {list(), list()}
def get_to_and_cc(_actor, mentions, :direct) do
{mentions, []}
end
def get_to_and_cc(_actor, mentions, _in_reply_to, {:list, _}) do
def get_to_and_cc(_actor, mentions, {:list, _}) do
{mentions, []}
end
@ -83,16 +77,109 @@ defmodule Mobilizon.Service.ActivityPub.Audience do
def get_addressed_actors(mentioned_users, _), do: mentioned_users
def calculate_to_and_cc_from_mentions(
actor,
mentions \\ [],
in_reply_to \\ nil,
visibility \\ :public
) do
with mentioned_actors <- for({_, mentioned_actor} <- mentions, do: mentioned_actor.url),
def calculate_to_and_cc_from_mentions(%Comment{} = comment) do
with mentioned_actors <- Enum.map(comment.mentions, &process_mention/1),
addressed_actors <- get_addressed_actors(mentioned_actors, nil),
{to, cc} <- get_to_and_cc(actor, addressed_actors, in_reply_to, visibility) do
{to, cc} <- get_to_and_cc(comment.actor, addressed_actors, comment.visibility),
{to, cc} <- {Enum.uniq(to ++ add_in_reply_to(comment.in_reply_to_comment)), cc},
{to, cc} <- {Enum.uniq(to ++ add_event_author(comment.event)), cc},
{to, cc} <-
{to,
Enum.uniq(
cc ++
add_comments_authors([comment.origin_comment]) ++
add_shares_actors_followers(comment.url)
)} do
%{"to" => to, "cc" => cc}
end
end
def calculate_to_and_cc_from_mentions(%Event{} = event) do
with mentioned_actors <- Enum.map(event.mentions, &process_mention/1),
addressed_actors <- get_addressed_actors(mentioned_actors, nil),
{to, cc} <- get_to_and_cc(event.organizer_actor, addressed_actors, event.visibility),
{to, cc} <-
{to,
Enum.uniq(
cc ++ add_comments_authors(event.comments) ++ add_shares_actors_followers(event.url)
)} do
%{"to" => to, "cc" => cc}
end
end
def calculate_to_and_cc_from_mentions(%Participant{} = participant) do
participant = Mobilizon.Storage.Repo.preload(participant, [:actor, :event])
actor_participants_urls =
participant.event.id
|> Mobilizon.Events.list_actors_participants_for_event()
|> Enum.map(& &1.url)
%{"to" => [participant.actor.url], "cc" => actor_participants_urls}
end
def calculate_to_and_cc_from_mentions(%Actor{} = actor) do
%{
"to" => [@ap_public],
"cc" => [actor.followers_url] ++ add_actors_that_had_our_content(actor.id)
}
end
defp add_in_reply_to(%Comment{actor: %Actor{url: url}} = _comment), do: [url]
defp add_in_reply_to(%Event{organizer_actor: %Actor{url: url}} = _event), do: [url]
defp add_in_reply_to(_), do: []
defp add_event_author(nil), do: []
defp add_event_author(%Event{} = event) do
[Mobilizon.Storage.Repo.preload(event, [:organizer_actor]).organizer_actor.url]
end
defp add_comment_author(nil), do: nil
defp add_comment_author(%Comment{} = comment) do
case Mobilizon.Storage.Repo.preload(comment, [:actor]) do
%Comment{actor: %Actor{url: url}} ->
url
_err ->
nil
end
end
defp add_comments_authors(comments) do
authors =
comments
|> Enum.map(&add_comment_author/1)
|> Enum.filter(& &1)
authors
end
@spec add_shares_actors_followers(String.t()) :: list(String.t())
defp add_shares_actors_followers(uri) do
uri
|> Share.get_actors_by_share_uri()
|> Enum.map(&Actors.list_followers_actors_for_actor/1)
|> List.flatten()
|> Enum.map(& &1.url)
|> Enum.uniq()
end
defp add_actors_that_had_our_content(actor_id) do
actor_id
|> Share.get_actors_by_owner_actor_id()
|> Enum.map(&Actors.list_followers_actors_for_actor/1)
|> List.flatten()
|> Enum.map(& &1.url)
|> Enum.uniq()
end
defp process_mention({_, mentioned_actor}), do: mentioned_actor.url
defp process_mention(%{actor_id: actor_id}) do
with %Actor{url: url} <- Actors.get_actor(actor_id) do
url
end
end
end

View File

@ -7,7 +7,7 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Actor do
"""
alias Mobilizon.Actors.Actor, as: ActorModel
alias Mobilizon.Service.ActivityPub.{Converter, Convertible}
alias Mobilizon.Service.ActivityPub.{Converter, Convertible, Utils}
@behaviour Converter
@ -22,33 +22,40 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Actor do
"""
@impl Converter
@spec as_to_model_data(map) :: map
def as_to_model_data(object) do
def as_to_model_data(data) do
avatar =
object["icon"]["url"] &&
data["icon"]["url"] &&
%{
"name" => object["icon"]["name"] || "avatar",
"url" => object["icon"]["url"]
"name" => data["icon"]["name"] || "avatar",
"url" => MobilizonWeb.MediaProxy.url(data["icon"]["url"])
}
banner =
object["image"]["url"] &&
data["image"]["url"] &&
%{
"name" => object["image"]["name"] || "banner",
"url" => object["image"]["url"]
"name" => data["image"]["name"] || "banner",
"url" => MobilizonWeb.MediaProxy.url(data["image"]["url"])
}
{:ok,
%{
"type" => String.to_existing_atom(object["type"]),
"preferred_username" => object["preferredUsername"],
"summary" => object["summary"],
"url" => object["id"],
"name" => object["name"],
"avatar" => avatar,
"banner" => banner,
"keys" => object["publicKey"]["publicKeyPem"],
"manually_approves_followers" => object["manuallyApprovesFollowers"]
}}
actor_data = %{
url: data["id"],
avatar: avatar,
banner: banner,
name: data["name"],
preferred_username: data["preferredUsername"],
summary: data["summary"],
keys: data["publicKey"]["publicKeyPem"],
inbox_url: data["inbox"],
outbox_url: data["outbox"],
following_url: data["following"],
followers_url: data["followers"],
shared_inbox_url: data["endpoints"]["sharedInbox"],
domain: URI.parse(data["id"]).host,
manually_approves_followers: data["manuallyApprovesFollowers"],
type: data["type"]
}
{:ok, actor_data}
end
@doc """
@ -57,18 +64,51 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Actor do
@impl Converter
@spec model_to_as(ActorModel.t()) :: map
def model_to_as(%ActorModel{} = actor) do
%{
"type" => Atom.to_string(actor.type),
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"preferred_username" => actor.preferred_username,
actor_data = %{
"id" => actor.url,
"type" => actor.type,
"preferredUsername" => actor.preferred_username,
"name" => actor.name,
"summary" => actor.summary,
"following" => ActorModel.build_url(actor.preferred_username, :following),
"followers" => ActorModel.build_url(actor.preferred_username, :followers),
"inbox" => ActorModel.build_url(actor.preferred_username, :inbox),
"outbox" => ActorModel.build_url(actor.preferred_username, :outbox),
"id" => ActorModel.build_url(actor.preferred_username, :page),
"url" => actor.url
"following" => actor.following_url,
"followers" => actor.followers_url,
"inbox" => actor.inbox_url,
"outbox" => actor.outbox_url,
"url" => actor.url,
"endpoints" => %{
"sharedInbox" => actor.shared_inbox_url
},
"manuallyApprovesFollowers" => actor.manually_approves_followers,
"publicKey" => %{
"id" => "#{actor.url}#main-key",
"owner" => actor.url,
"publicKeyPem" =>
if(is_nil(actor.domain) and not is_nil(actor.keys),
do: Utils.pem_to_public_key_pem(actor.keys),
else: actor.keys
)
}
}
actor_data =
if is_nil(actor.avatar) do
actor_data
else
Map.put(actor_data, "icon", %{
"type" => "Image",
"mediaType" => actor.avatar.content_type,
"url" => actor.avatar.url
})
end
if is_nil(actor.banner) do
actor_data
else
Map.put(actor_data, "image", %{
"type" => "Image",
"mediaType" => actor.banner.content_type,
"url" => actor.banner.url
})
end
end
end

View File

@ -12,6 +12,7 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Comment do
alias Mobilizon.Service.ActivityPub
alias Mobilizon.Service.ActivityPub.{Converter, Convertible}
alias Mobilizon.Service.ActivityPub.Converter.Utils, as: ConverterUtils
alias Mobilizon.Tombstone, as: TombstoneModel
require Logger
@ -32,9 +33,11 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Comment do
Logger.debug("We're converting raw ActivityStream data to a comment entity")
Logger.debug(inspect(object))
with {:ok, %Actor{id: actor_id}} <- ActivityPub.get_or_fetch_actor_by_url(object["actor"]),
{:tags, tags} <- {:tags, ConverterUtils.fetch_tags(object["tag"])},
{:mentions, mentions} <- {:mentions, ConverterUtils.fetch_mentions(object["tag"])} do
with author_url <- Map.get(object, "actor") || Map.get(object, "attributedTo"),
{:ok, %Actor{id: actor_id}} <- ActivityPub.get_or_fetch_actor_by_url(author_url),
{:tags, tags} <- {:tags, ConverterUtils.fetch_tags(Map.get(object, "tag", []))},
{:mentions, mentions} <-
{:mentions, ConverterUtils.fetch_mentions(Map.get(object, "tag", []))} do
Logger.debug("Inserting full comment")
Logger.debug(inspect(object))
@ -70,6 +73,7 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Comment do
data
|> Map.put(:in_reply_to_comment_id, id)
|> Map.put(:origin_comment_id, comment |> CommentModel.get_thread_id())
|> Map.put(:event_id, comment.event_id)
# Anything else is kind of a MP
{:error, parent} ->
@ -106,6 +110,7 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Comment do
"to" => to,
"cc" => [],
"content" => comment.text,
"mediaType" => "text/html",
"actor" => comment.actor.url,
"attributedTo" => comment.actor.url,
"uuid" => comment.uuid,
@ -114,23 +119,27 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Comment do
ConverterUtils.build_mentions(comment.mentions) ++ ConverterUtils.build_tags(comment.tags)
}
if comment.in_reply_to_comment do
object |> Map.put("inReplyTo", comment.in_reply_to_comment.url || comment.event.url)
else
object
cond do
comment.in_reply_to_comment ->
Map.put(object, "inReplyTo", comment.in_reply_to_comment.url)
comment.event ->
Map.put(object, "inReplyTo", comment.event.url)
true ->
object
end
end
@impl Converter
@spec model_to_as(CommentModel.t()) :: map
@doc """
A "soft-deleted" comment is a tombstone
"""
def model_to_as(%CommentModel{} = comment) do
%{
"type" => "Tombstone",
"uuid" => comment.uuid,
"id" => comment.url,
"published" => comment.inserted_at,
"updated" => comment.updated_at,
"deleted" => comment.deleted_at
}
Convertible.model_to_as(%TombstoneModel{
uri: comment.url,
inserted_at: comment.deleted_at
})
end
end

View File

@ -6,14 +6,13 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Event do
internal one, and back.
"""
alias Mobilizon.{Addresses, Media}
alias Mobilizon.Addresses
alias Mobilizon.Actors.Actor
alias Mobilizon.Addresses.Address
alias Mobilizon.Events.Event, as: EventModel
alias Mobilizon.Events.EventOptions
alias Mobilizon.Media.Picture
alias Mobilizon.Service.ActivityPub
alias Mobilizon.Service.ActivityPub.{Converter, Convertible, Utils}
alias Mobilizon.Service.ActivityPub.{Converter, Convertible}
alias Mobilizon.Service.ActivityPub.Converter.Address, as: AddressConverter
alias Mobilizon.Service.ActivityPub.Converter.Picture, as: PictureConverter
alias Mobilizon.Service.ActivityPub.Converter.Utils, as: ConverterUtils
@ -37,26 +36,25 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Event do
Logger.debug("event as_to_model_data")
Logger.debug(inspect(object))
with {:actor, {:ok, %Actor{id: actor_id}}} <-
{:actor, ActivityPub.get_or_fetch_actor_by_url(object["actor"])},
with author_url <- Map.get(object, "actor") || Map.get(object, "attributedTo"),
{:actor, {:ok, %Actor{id: actor_id, domain: actor_domain}}} <-
{:actor, ActivityPub.get_or_fetch_actor_by_url(author_url)},
{:address, address_id} <-
{:address, get_address(object["location"])},
{:tags, tags} <- {:tags, ConverterUtils.fetch_tags(object["tag"])},
{:mentions, mentions} <- {:mentions, ConverterUtils.fetch_mentions(object["tag"])},
{:visibility, visibility} <- {:visibility, get_visibility(object)},
{:options, options} <- {:options, get_options(object)} do
picture_id =
with true <- Map.has_key?(object, "attachment") && length(object["attachment"]) > 0,
%Picture{id: picture_id} <-
Media.get_picture_by_url(
object["attachment"]
|> hd
|> Map.get("url")
|> hd
|> Map.get("href")
) do
{:ok, %Picture{id: picture_id}} <-
object["attachment"]
|> hd
|> PictureConverter.find_or_create_picture(actor_id) do
picture_id
else
_ -> nil
_err ->
nil
end
entity = %{
@ -68,16 +66,20 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Event do
ends_on: object["endTime"],
category: object["category"],
visibility: visibility,
join_options: Map.get(object, "joinOptions", "free"),
join_options: Map.get(object, "joinMode", "free"),
local: is_nil(actor_domain),
options: options,
status: object["status"],
status: object |> Map.get("ical:status", "CONFIRMED") |> String.downcase(),
online_address: object["onlineAddress"],
phone_address: object["phoneAddress"],
draft: object["draft"] || false,
draft: false,
url: object["id"],
uuid: object["uuid"],
tags: tags,
physical_address_id: address_id
mentions: mentions,
physical_address_id: address_id,
updated_at: object["updated"],
publish_at: object["published"]
}
{:ok, entity}
@ -108,14 +110,17 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Event do
"uuid" => event.uuid,
"category" => event.category,
"content" => event.description,
"publish_at" => (event.publish_at || event.inserted_at) |> date_to_string(),
"updated_at" => event.updated_at |> date_to_string(),
"published" => (event.publish_at || event.inserted_at) |> date_to_string(),
"updated" => event.updated_at |> date_to_string(),
"mediaType" => "text/html",
"startTime" => event.begins_on |> date_to_string(),
"joinOptions" => to_string(event.join_options),
"joinMode" => to_string(event.join_options),
"endTime" => event.ends_on |> date_to_string(),
"tag" => event.tags |> ConverterUtils.build_tags(),
"draft" => event.draft,
"maximumAttendeeCapacity" => event.options.maximum_attendee_capacity,
"repliesModerationOption" => event.options.comment_moderation,
# "draft" => event.draft,
"ical:status" => event.status |> to_string |> String.upcase(),
"id" => event.url,
"url" => event.url
}
@ -133,17 +138,10 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Event do
# Get only elements that we have in EventOptions
@spec get_options(map) :: map
defp get_options(object) do
keys =
EventOptions
|> struct
|> Map.keys()
|> List.delete(:__struct__)
|> Enum.map(&Utils.camelize/1)
Enum.reduce(object, %{}, fn {key, value}, acc ->
(!is_nil(value) && key in keys && Map.put(acc, Utils.underscore(key), value)) ||
acc
end)
%{
maximum_attendee_capacity: object["maximumAttendeeCapacity"],
comment_moderation: object["repliesModerationOption"]
}
end
@spec get_address(map | binary | nil) :: integer | nil
@ -186,13 +184,7 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Event do
@ap_public "https://www.w3.org/ns/activitystreams#Public"
defp get_visibility(object) do
cond do
@ap_public in object["to"] -> :public
@ap_public in object["cc"] -> :unlisted
true -> :private
end
end
defp get_visibility(object), do: if(@ap_public in object["to"], do: :public, else: :unlisted)
@spec date_to_string(DateTime.t() | nil) :: String.t()
defp date_to_string(nil), do: nil

View File

@ -15,6 +15,7 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Flag do
alias Mobilizon.Reports.Report
alias Mobilizon.Service.ActivityPub.Converter
alias Mobilizon.Service.ActivityPub.Convertible
alias Mobilizon.Service.ActivityPub.Relay
@behaviour Converter
@ -42,8 +43,6 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Flag do
end
end
@audience %{"to" => ["https://www.w3.org/ns/activitystreams#Public"], "cc" => []}
@doc """
Convert an event struct to an ActivityStream representation
"""
@ -54,17 +53,13 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Flag do
object = if report.event, do: object ++ [report.event.url], else: object
audience =
if report.local, do: @audience, else: Map.put(@audience, "cc", [report.reported.url])
%{
"type" => "Flag",
"actor" => report.reporter.url,
"actor" => Relay.get_actor().url,
"id" => report.url,
"content" => report.content,
"object" => object
}
|> Map.merge(audience)
end
@spec as_to_model(map) :: map
@ -91,7 +86,7 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Flag do
end
end),
# Remove the reported user from the object list.
# Remove the reported actor and the event from the object list.
comments <-
Enum.filter(objects, fn url ->
!(url == reported.url || (!is_nil(event) && event.url == url))

View File

@ -15,14 +15,48 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Picture do
def model_to_as(%PictureModel{file: file}) do
%{
"type" => "Document",
"url" => [
%{
"type" => "Link",
"mediaType" => file.content_type,
"href" => file.url
}
],
"mediaType" => file.content_type,
"url" => file.url,
"name" => file.name
}
end
@doc """
Save picture data from raw data and return AS Link data.
"""
def find_or_create_picture(%{"type" => "Link", "href" => url}, actor_id),
do: find_or_create_picture(url, actor_id)
def find_or_create_picture(
%{"type" => "Document", "url" => picture_url, "name" => name},
actor_id
)
when is_bitstring(picture_url) do
with {:ok, %HTTPoison.Response{body: body}} <- HTTPoison.get(picture_url),
{:ok,
%{
name: name,
url: url,
content_type: content_type,
size: size
}} <-
MobilizonWeb.Upload.store(%{body: body, name: name}),
{:picture_exists, nil} <- {:picture_exists, Mobilizon.Media.get_picture_by_url(url)} do
Mobilizon.Media.create_picture(%{
"file" => %{
"url" => url,
"name" => name,
"content_type" => content_type,
"size" => size
},
"actor_id" => actor_id
})
else
{:picture_exists, %PictureModel{file: _file} = picture} ->
{:ok, picture}
err ->
err
end
end
end

View File

@ -0,0 +1,40 @@
defmodule Mobilizon.Service.ActivityPub.Converter.Tombstone do
@moduledoc """
Comment converter.
This module allows to convert Tombstone models to ActivityStreams data
"""
alias Mobilizon.Tombstone, as: TombstoneModel
alias Mobilizon.Service.ActivityPub.{Converter, Convertible}
require Logger
@behaviour Converter
defimpl Convertible, for: TombstoneModel do
alias Mobilizon.Service.ActivityPub.Converter.Tombstone, as: TombstoneConverter
defdelegate model_to_as(comment), to: TombstoneConverter
end
@doc """
Make an AS tombstone object from an existing `Tombstone` structure.
"""
@impl Converter
@spec model_to_as(TombstoneModel.t()) :: map
def model_to_as(%TombstoneModel{} = tombstone) do
%{
"type" => "Tombstone",
"id" => tombstone.uri,
"deleted" => tombstone.inserted_at
}
end
@doc """
Converting an Tombstone to an object makes no sense, nevertheless
"""
@impl Converter
@spec as_to_model_data(map) :: map
def as_to_model_data(object), do: object
end

View File

@ -14,6 +14,7 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Utils do
@spec fetch_tags([String.t()]) :: [Tag.t()]
def fetch_tags(tags) when is_list(tags) do
Logger.debug("fetching tags")
Logger.debug(inspect(tags))
tags |> Enum.flat_map(&fetch_tag/1) |> Enum.uniq() |> Enum.map(&existing_tag_or_data/1)
end
@ -64,6 +65,8 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Utils do
}
end
defp fetch_tag(%{title: title}), do: [title]
defp fetch_tag(tag) when is_map(tag) do
case tag["type"] do
"Hashtag" ->

View File

@ -9,27 +9,37 @@ defmodule Mobilizon.Service.ActivityPub.Relay do
"""
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Actors.{Actor, Follower}
alias Mobilizon.Service.ActivityPub
alias Mobilizon.Service.ActivityPub.{Activity, Transmogrifier}
alias Mobilizon.Service.WebFinger
alias MobilizonWeb.API.Follows
require Logger
def init() do
# Wait for everything to settle.
Process.sleep(1000 * 5)
get_actor()
end
@spec get_actor() :: Actor.t() | {:error, Ecto.Changeset.t()}
def get_actor do
with {:ok, %Actor{} = actor} <-
Actors.get_or_create_actor_by_url("#{MobilizonWeb.Endpoint.url()}/relay") do
Actors.get_or_create_instance_actor_by_url("#{MobilizonWeb.Endpoint.url()}/relay") do
actor
end
end
def follow(target_instance) do
with %Actor{} = local_actor <- get_actor(),
@spec follow(String.t()) :: {:ok, Activity.t(), Follower.t()}
def follow(address) do
with {:ok, target_instance} <- fetch_actor(address),
%Actor{} = local_actor <- get_actor(),
{:ok, %Actor{} = target_actor} <- ActivityPub.get_or_fetch_actor_by_url(target_instance),
{:ok, activity} <- Follows.follow(local_actor, target_actor) do
{:ok, activity, follow} <- Follows.follow(local_actor, target_actor) do
Logger.info("Relay: followed instance #{target_instance}; id=#{activity.data["id"]}")
{:ok, activity}
{:ok, activity, follow}
else
e ->
Logger.warn("Error while following remote instance: #{inspect(e)}")
@ -37,12 +47,14 @@ defmodule Mobilizon.Service.ActivityPub.Relay do
end
end
def unfollow(target_instance) do
with %Actor{} = local_actor <- get_actor(),
@spec unfollow(String.t()) :: {:ok, Activity.t(), Follower.t()}
def unfollow(address) do
with {:ok, target_instance} <- fetch_actor(address),
%Actor{} = local_actor <- get_actor(),
{:ok, %Actor{} = target_actor} <- ActivityPub.get_or_fetch_actor_by_url(target_instance),
{:ok, activity} <- Follows.unfollow(local_actor, target_actor) do
{:ok, activity, follow} <- Follows.unfollow(local_actor, target_actor) do
Logger.info("Relay: unfollowed instance #{target_instance}: id=#{activity.data["id"]}")
{:ok, activity}
{:ok, activity, follow}
else
e ->
Logger.warn("Error while unfollowing remote instance: #{inspect(e)}")
@ -50,30 +62,38 @@ defmodule Mobilizon.Service.ActivityPub.Relay do
end
end
def accept(target_instance) do
with %Actor{} = local_actor <- get_actor(),
@spec accept(String.t()) :: {:ok, Activity.t(), Follower.t()}
def accept(address) do
Logger.debug("We're trying to accept a relay subscription")
with {:ok, target_instance} <- fetch_actor(address),
%Actor{} = local_actor <- get_actor(),
{:ok, %Actor{} = target_actor} <- ActivityPub.get_or_fetch_actor_by_url(target_instance),
{:ok, activity} <- Follows.accept(target_actor, local_actor) do
{:ok, activity}
{:ok, activity, follow} <- Follows.accept(target_actor, local_actor) do
{:ok, activity, follow}
end
end
# def reject(target_instance) do
# with %Actor{} = local_actor <- get_actor(),
# {:ok, %Actor{} = target_actor} <- Activity.get_or_fetch_actor_by_url(target_instance),
# {:ok, activity} <- Follows.reject(target_actor, local_actor) do
# {:ok, activity}
# end
# end
def reject(address) do
Logger.debug("We're trying to reject a relay subscription")
with {:ok, target_instance} <- fetch_actor(address),
%Actor{} = local_actor <- get_actor(),
{:ok, %Actor{} = target_actor} <- ActivityPub.get_or_fetch_actor_by_url(target_instance),
{:ok, activity, follow} <- Follows.reject(target_actor, local_actor) do
{:ok, activity, follow}
end
end
@doc """
Publish an activity to all relays following this instance
"""
def publish(%Activity{data: %{"object" => object}} = _activity) do
with %Actor{id: actor_id} = actor <- get_actor(),
{:ok, object} <-
Transmogrifier.fetch_obj_helper_as_activity_streams(object) do
ActivityPub.announce(actor, object, "#{object["id"]}/announces/#{actor_id}", true, false)
{object, object_id} <- fetch_object(object),
id <- "#{object_id}/announces/#{actor_id}" do
Logger.info("Publishing activity #{id} to all relays")
ActivityPub.announce(actor, object, id, true, false)
else
e ->
Logger.error("Error while getting local instance actor: #{inspect(e)}")
@ -85,4 +105,51 @@ defmodule Mobilizon.Service.ActivityPub.Relay do
Logger.debug(inspect(err))
nil
end
defp fetch_object(object) when is_map(object) do
with {:ok, object} <- Transmogrifier.fetch_obj_helper_as_activity_streams(object) do
{object, object["id"]}
end
end
defp fetch_object(object) when is_bitstring(object), do: {object, object}
@spec fetch_actor(String.t()) :: {:ok, String.t()} | {:error, String.t()}
# Dirty hack
defp fetch_actor("https://" <> address), do: fetch_actor(address)
defp fetch_actor("http://" <> address), do: fetch_actor(address)
defp fetch_actor(address) do
%URI{host: host} = URI.parse("http://" <> address)
cond do
String.contains?(address, "@") ->
check_actor(address)
!is_nil(host) ->
check_actor("relay@#{host}")
true ->
{:error, "Bad URL"}
end
end
@spec check_actor(String.t()) :: {:ok, String.t()} | {:error, String.t()}
defp check_actor(username_and_domain) do
case Actors.get_actor_by_name(username_and_domain) do
%Actor{url: url} -> {:ok, url}
nil -> finger_actor(username_and_domain)
end
end
@spec finger_actor(String.t()) :: {:ok, String.t()} | {:error, String.t()}
defp finger_actor(nickname) do
case WebFinger.finger(nickname) do
{:ok, %{"url" => url}} when not is_nil(url) ->
{:ok, url}
_e ->
{:error, "No ActivityPub URL found in WebFinger"}
end
end
end

View File

@ -20,108 +20,6 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
require Logger
def get_actor(%{"actor" => actor}) when is_binary(actor) do
actor
end
def get_actor(%{"actor" => actor}) when is_list(actor) do
if is_binary(Enum.at(actor, 0)) do
Enum.at(actor, 0)
else
actor
|> Enum.find(fn %{"type" => type} -> type in ["Person", "Service", "Application"] end)
|> Map.get("id")
end
end
def get_actor(%{"actor" => %{"id" => id}}) when is_bitstring(id) do
id
end
def get_actor(%{"actor" => nil, "attributedTo" => actor}) when not is_nil(actor) do
get_actor(%{"actor" => actor})
end
@doc """
Modifies an incoming AP object (mastodon format) to our internal format.
"""
def fix_object(object) do
object
|> Map.put("actor", object["attributedTo"])
|> fix_attachments
# |> fix_in_reply_to
# |> fix_tag
end
def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object)
when not is_nil(in_reply_to) and is_bitstring(in_reply_to) do
in_reply_to |> do_fix_in_reply_to(object)
end
def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object)
when not is_nil(in_reply_to) and is_map(in_reply_to) do
if is_bitstring(in_reply_to["id"]) do
in_reply_to["id"] |> do_fix_in_reply_to(object)
end
end
def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object)
when not is_nil(in_reply_to) and is_list(in_reply_to) do
if is_bitstring(Enum.at(in_reply_to, 0)) do
in_reply_to |> Enum.at(0) |> do_fix_in_reply_to(object)
end
end
def fix_in_reply_to(%{"inReplyTo" => in_reply_to} = object)
when not is_nil(in_reply_to) do
Logger.warn("inReplyTo ID seem incorrect: #{inspect(in_reply_to)}")
do_fix_in_reply_to("", object)
end
def fix_in_reply_to(object), do: object
def do_fix_in_reply_to(in_reply_to_id, object) do
case fetch_obj_helper(in_reply_to_id) do
{:ok, replied_object} ->
object
|> Map.put("inReplyTo", replied_object.url)
{:error, {:error, :not_supported}} ->
Logger.info("Object reply origin has not a supported type")
object
e ->
Logger.warn("Couldn't fetch #{in_reply_to_id} #{inspect(e)}")
object
end
end
def fix_attachments(object) do
attachments =
(object["attachment"] || [])
|> Enum.map(fn data ->
url = [%{"type" => "Link", "mediaType" => data["mediaType"], "href" => data["url"]}]
Map.put(data, "url", url)
end)
object
|> Map.put("attachment", attachments)
end
def fix_tag(object) do
tags =
(object["tag"] || [])
|> Enum.filter(fn data -> data["type"] == "Hashtag" and data["name"] end)
|> Enum.map(fn data -> String.slice(data["name"], 1..-1) end)
combined = (object["tag"] || []) ++ tags
object
|> Map.put("tag", combined)
end
def handle_incoming(%{"id" => nil}), do: :error
def handle_incoming(%{"id" => ""}), do: :error
@ -135,6 +33,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
additional: %{
"cc" => [params["reported"].url]
},
event_id: if(is_nil(params["event"]), do: nil, else: params["event"].id || nil),
local: false
}
@ -158,7 +57,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
Logger.info("Handle incoming to create notes")
with {:ok, object_data} <-
object |> fix_object() |> Converter.Comment.as_to_model_data(),
object |> Converter.Comment.as_to_model_data(),
{:existing_comment, {:error, :comment_not_found}} <-
{:existing_comment, Events.get_comment_from_url_with_preload(object_data.url)},
{:ok, %Activity{} = activity, %Comment{} = comment} <-
@ -186,7 +85,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
Logger.info("Handle incoming to create event")
with {:ok, object_data} <-
object |> fix_object() |> Converter.Event.as_to_model_data(),
object |> Converter.Event.as_to_model_data(),
{:existing_event, nil} <- {:existing_event, Events.get_event_by_url(object_data.url)},
{:ok, %Activity{} = activity, %Event{} = event} <-
ActivityPub.create(:event, object_data, false) do
@ -273,36 +172,25 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
end
end
#
# def handle_incoming(
# %{"type" => "Like", "object" => object_id, "actor" => actor, "id" => id} = data
# ) do
# with %User{} = actor <- User.get_or_fetch_by_ap_id(actor),
# {:ok, object} <-
# fetch_obj_helper(object_id) || ActivityPub.fetch_object_from_id(object_id),
# {:ok, activity, object} <- ActivityPub.like(actor, object, id, false) do
# {:ok, activity}
# else
# _e -> :error
# end
# end
# #
def handle_incoming(
%{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => _id} = data
%{"type" => "Announce", "object" => object, "actor" => _actor, "id" => _id} = data
) do
with actor <- get_actor(data),
# TODO: Is the following line useful?
{:ok, %Actor{} = _actor} <- ActivityPub.get_or_fetch_actor_by_url(actor),
{:ok, %Actor{id: actor_id} = _actor} <- ActivityPub.get_or_fetch_actor_by_url(actor),
:ok <- Logger.debug("Fetching contained object"),
{:ok, object} <- fetch_obj_helper_as_activity_streams(object_id),
{:ok, object} <- fetch_obj_helper_as_activity_streams(object),
:ok <- Logger.debug("Handling contained object"),
create_data <-
make_create_data(object),
:ok <- Logger.debug(inspect(object)),
{:ok, _activity, object} <- handle_incoming(create_data),
{:ok, _activity, entity} <- handle_incoming(create_data),
:ok <- Logger.debug("Finished processing contained object"),
{:ok, activity} <- ActivityPub.create_activity(data, false) do
{:ok, activity, object}
{:ok, activity} <- ActivityPub.create_activity(data, false),
{:ok, %Actor{id: object_owner_actor_id}} <- Actors.get_actor_by_url(object["actor"]),
{:ok, %Mobilizon.Share{} = _share} <-
Mobilizon.Share.create(object["id"], actor_id, object_owner_actor_id) do
{:ok, activity, entity}
else
e ->
Logger.debug(inspect(e))
@ -318,7 +206,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
when object_type in ["Person", "Group", "Application", "Service", "Organization"] do
with {:ok, %Actor{} = old_actor} <- Actors.get_actor_by_url(object["id"]),
{:ok, object_data} <-
object |> fix_object() |> Converter.Actor.as_to_model_data(),
object |> Converter.Actor.as_to_model_data(),
{:ok, %Activity{} = activity, %Actor{} = new_actor} <-
ActivityPub.update(:actor, old_actor, object_data, false) do
{:ok, activity, new_actor}
@ -331,12 +219,15 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
def handle_incoming(
%{"type" => "Update", "object" => %{"type" => "Event"} = object, "actor" => _actor} =
_update
update_data
) do
with %Event{} = old_event <-
Events.get_event_by_url(object["id"]),
with actor <- get_actor(update_data),
{:ok, %Actor{url: actor_url}} <- Actors.get_actor_by_url(actor),
{:ok, %Event{} = old_event} <-
object |> Utils.get_url() |> ActivityPub.fetch_object_from_url(),
{:ok, object_data} <-
object |> fix_object() |> Converter.Event.as_to_model_data(),
object |> Converter.Event.as_to_model_data(),
{:origin_check, true} <- {:origin_check, origin_check?(actor_url, update_data)},
{:ok, %Activity{} = activity, %Event{} = new_event} <-
ActivityPub.update(:event, old_event, object_data, false) do
{:ok, activity, new_event}
@ -396,16 +287,18 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
def handle_incoming(
%{"type" => "Delete", "object" => object, "actor" => _actor, "id" => _id} = data
) do
object_id = Utils.get_url(object)
with actor <- get_actor(data),
{:ok, %Actor{url: _actor_url}} <- Actors.get_actor_by_url(actor),
{:ok, object} <- fetch_obj_helper(object_id),
# TODO : Validate that DELETE comes indeed form right domain (see above)
# :ok <- contain_origin(actor_url, object.data),
{:ok, %Actor{url: actor_url}} <- Actors.get_actor_by_url(actor),
object_id <- Utils.get_url(object),
{:origin_check, true} <- {:origin_check, origin_check_from_id?(actor_url, object_id)},
{:ok, object} <- ActivityPub.fetch_object_from_url(object_id),
{:ok, activity, object} <- ActivityPub.delete(object, false) do
{:ok, activity, object}
else
{:origin_check, false} ->
Logger.warn("Object origin check failed")
:error
e ->
Logger.debug(inspect(e))
:error
@ -413,12 +306,13 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
end
def handle_incoming(
%{"type" => "Join", "object" => object, "actor" => _actor, "id" => _id} = data
%{"type" => "Join", "object" => object, "actor" => _actor, "id" => id} = data
) do
with actor <- get_actor(data),
{:ok, %Actor{url: _actor_url} = actor} <- Actors.get_actor_by_url(actor),
{:ok, object} <- fetch_obj_helper(object),
{:ok, activity, object} <- ActivityPub.join(object, actor, false) do
object <- Utils.get_url(object),
{:ok, object} <- ActivityPub.fetch_object_from_url(object),
{:ok, activity, object} <- ActivityPub.join(object, actor, false, %{url: id}) do
{:ok, activity, object}
else
e ->
@ -432,7 +326,8 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
) do
with actor <- get_actor(data),
{:ok, %Actor{} = actor} <- Actors.get_actor_by_url(actor),
{:ok, object} <- fetch_obj_helper(object),
object <- Utils.get_url(object),
{:ok, object} <- ActivityPub.fetch_object_from_url(object),
{:ok, activity, object} <- ActivityPub.leave(object, actor, false) do
{:ok, activity, object}
else
@ -487,7 +382,6 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
ActivityPub.accept(
:follow,
follow,
%{approved: true},
false
) do
{:ok, activity, follow}
@ -511,23 +405,11 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
Handle incoming `Reject` activities wrapping a `Follow` activity
"""
def do_handle_incoming_reject_following(follow_object, %Actor{} = actor) do
with {:follow,
{:ok,
%Follower{approved: false, actor: follower, id: follow_id, target_actor: followed} =
follow}} <-
with {:follow, {:ok, %Follower{approved: false, target_actor: followed} = follow}} <-
{:follow, get_follow(follow_object)},
{:same_actor, true} <- {:same_actor, actor.id == followed.id},
{:ok, activity, _} <-
ActivityPub.reject(
%{
to: [follower.url],
actor: actor.url,
object: follow_object,
local: false
},
"#{MobilizonWeb.Endpoint.url()}/reject/follow/#{follow_id}"
),
{:ok, %Follower{}} <- Actors.delete_follower(follow) do
ActivityPub.reject(:follow, follow) do
{:ok, activity, follow}
else
{:follow, _} ->
@ -547,7 +429,8 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
# Handle incoming `Accept` activities wrapping a `Join` activity on an event
defp do_handle_incoming_accept_join(join_object, %Actor{} = actor_accepting) do
with {:join_event, {:ok, %Participant{role: :not_approved, event: event} = participant}} <-
with {:join_event, {:ok, %Participant{role: role, event: event} = participant}}
when role in [:not_approved, :rejected] <-
{:join_event, get_participant(join_object)},
# TODO: The actor that accepts the Join activity may another one that the event organizer ?
# Or maybe for groups it's the group that sends the Accept activity
@ -556,7 +439,6 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
ActivityPub.accept(
:join,
participant,
%{role: :participant},
false
),
:ok <-
@ -587,32 +469,20 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
# Handle incoming `Reject` activities wrapping a `Join` activity on an event
defp do_handle_incoming_reject_join(join_object, %Actor{} = actor_accepting) do
with {:join_event,
{:ok,
%Participant{role: :not_approved, actor: actor, id: join_id, event: event} =
participant}} <-
with {:join_event, {:ok, %Participant{event: event, role: role} = participant}}
when role != :rejected <-
{:join_event, get_participant(join_object)},
# TODO: The actor that accepts the Join activity may another one that the event organizer ?
# Or maybe for groups it's the group that sends the Accept activity
{:same_actor, true} <- {:same_actor, actor_accepting.id == event.organizer_actor_id},
{:ok, activity, _} <-
ActivityPub.reject(
%{
to: [actor.url],
actor: actor_accepting.url,
object: join_object,
local: false
},
"#{MobilizonWeb.Endpoint.url()}/reject/join/#{join_id}"
),
{:ok, %Participant{role: :rejected} = participant} <-
Events.update_participant(participant, %{"role" => :rejected}),
{:ok, activity, participant} <-
ActivityPub.reject(:join, participant, false),
:ok <- Participation.send_emails_to_local_user(participant) do
{:ok, activity, participant}
else
{:join_event, {:ok, %Participant{role: :participant}}} ->
Logger.debug(
"Tried to handle an Reject activity on a Join activity with a event object but the participant is already validated"
{:join_event, {:ok, %Participant{role: :rejected}}} ->
Logger.warn(
"Tried to handle an Reject activity on a Join activity with a event object but the participant is already rejected"
)
nil
@ -662,49 +532,6 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
end
end
def set_reply_to_uri(%{"inReplyTo" => in_reply_to} = object) do
with false <- String.starts_with?(in_reply_to, "http"),
{:ok, replied_to_object} <- fetch_obj_helper(in_reply_to) do
Map.put(object, "inReplyTo", replied_to_object["external_url"] || in_reply_to)
else
_e -> object
end
end
def set_reply_to_uri(obj), do: obj
#
# # Prepares the object of an outgoing create activity.
def prepare_object(object) do
object
# |> set_sensitive
# |> add_hashtags
|> add_mention_tags
# |> add_emoji_tags
|> add_attributed_to
# |> prepare_attachments
|> set_reply_to_uri
end
@doc """
internal -> Mastodon
"""
def prepare_outgoing(%{"type" => "Create", "object" => %{"type" => "Note"} = object} = data) do
Logger.debug("Prepare outgoing for a note creation")
object =
object
|> prepare_object
data =
data
|> Map.put("object", object)
|> Map.merge(Utils.make_json_ld_header())
Logger.debug("Finished prepare outgoing for a note creation")
{:ok, data}
end
def prepare_outgoing(%{"type" => _type} = data) do
data =
data
@ -713,145 +540,6 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
{:ok, data}
end
# def prepare_outgoing(%Event{} = event) do
# event =
# event
# |> Map.from_struct()
# |> Map.drop([:__meta__])
# |> Map.put(:"@context", "https://www.w3.org/ns/activitystreams")
# |> prepare_object
# {:ok, event}
# end
# def prepare_outgoing(%Comment{} = comment) do
# comment =
# comment
# |> Map.from_struct()
# |> Map.drop([:__meta__])
# |> Map.put(:"@context", "https://www.w3.org/ns/activitystreams")
# |> prepare_object
# {:ok, comment}
# end
#
# def maybe_fix_object_url(data) do
# if is_binary(data["object"]) and not String.starts_with?(data["object"], "http") do
# case ActivityPub.fetch_object_from_id(data["object"]) do
# {:ok, relative_object} ->
# if relative_object.data["external_url"] do
# data =
# data
# |> Map.put("object", relative_object.data["external_url"])
# else
# data
# end
#
# e ->
# Logger.error("Couldn't fetch #{data["object"]} #{inspect(e)}")
# data
# end
# else
# data
# end
# end
#
def add_hashtags(object) do
tags =
(object["tag"] || [])
|> Enum.map(fn tag ->
%{
"href" => MobilizonWeb.Endpoint.url() <> "/tags/#{tag}",
"name" => "##{tag}",
"type" => "Hashtag"
}
end)
object
|> Map.put("tag", tags)
end
def add_mention_tags(object) do
Logger.debug("add mention tags")
Logger.debug(inspect(object))
recipients =
(object["to"] ++ (object["cc"] || [])) -- ["https://www.w3.org/ns/activitystreams#Public"]
mentions =
recipients
|> Enum.filter(& &1)
|> Enum.map(fn url ->
case Actors.get_actor_by_url(url) do
{:ok, actor} -> actor
_ -> nil
end
end)
|> Enum.filter(& &1)
|> Enum.map(fn actor ->
%{
"type" => "Mention",
"href" => actor.url,
"name" => "@#{Actor.preferred_username_and_domain(actor)}"
}
end)
tags = object["tag"] || []
object
|> Map.put("tag", tags ++ mentions)
end
#
# # TODO: we should probably send mtime instead of unix epoch time for updated
# def add_emoji_tags(object) do
# tags = object["tag"] || []
# emoji = object["emoji"] || []
#
# out =
# emoji
# |> Enum.map(fn {name, url} ->
# %{
# "icon" => %{"url" => url, "type" => "Image"},
# "name" => ":" <> name <> ":",
# "type" => "Emoji",
# "updated" => "1970-01-01T00:00:00Z",
# "id" => url
# }
# end)
#
# object
# |> Map.put("tag", tags ++ out)
# end
#
#
# def set_sensitive(object) do
# tags = object["tag"] || []
# Map.put(object, "sensitive", "nsfw" in tags)
# end
#
def add_attributed_to(object) do
attributed_to = object["attributedTo"] || object["actor"]
object |> Map.put("attributedTo", attributed_to)
end
#
# def prepare_attachments(object) do
# attachments =
# (object["attachment"] || [])
# |> Enum.map(fn data ->
# [%{"mediaType" => media_type, "href" => href} | _] = data["url"]
# %{"url" => href, "mediaType" => media_type, "name" => data["name"], "type" => "Document"}
# end)
#
# object
# |> Map.put("attachment", attachments)
# end
@spec fetch_obj_helper(map() | String.t()) :: Event.t() | Comment.t() | Actor.t() | any()
def fetch_obj_helper(object) do
Logger.debug("fetch_obj_helper")
@ -862,7 +550,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
{:ok, object}
err ->
Logger.info("Error while fetching #{inspect(object)}")
Logger.warn("Error while fetching #{inspect(object)}")
{:error, err}
end
end

View File

@ -8,20 +8,11 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
# Various ActivityPub related utils.
"""
alias Ecto.Changeset
alias Mobilizon.{Actors, Addresses, Events, Reports, Users}
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Addresses.Address
alias Mobilizon.Events.{Comment, Event}
alias Mobilizon.Media.Picture
alias Mobilizon.Reports.Report
alias Mobilizon.Service.ActivityPub.{Activity, Converter}
alias Mobilizon.Service.Federator
alias Mobilizon.Storage.Repo
alias MobilizonWeb.{Email, Endpoint}
alias MobilizonWeb.Router.Helpers, as: Routes
require Logger
@ -37,12 +28,31 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
%{
"@context" => [
"https://www.w3.org/ns/activitystreams",
"https://litepub.github.io/litepub/context.jsonld",
"https://litepub.social/litepub/context.jsonld",
%{
"sc" => "http://schema.org#",
"ical" => "http://www.w3.org/2002/12/cal/ical#",
"Hashtag" => "as:Hashtag",
"category" => "sc:category",
"uuid" => "sc:identifier"
"uuid" => "sc:identifier",
"maximumAttendeeCapacity" => "sc:maximumAttendeeCapacity",
"mz" => "https://joinmobilizon.org/ns#",
"repliesModerationOptionType" => %{
"@id" => "mz:repliesModerationOptionType",
"@type" => "rdfs:Class"
},
"repliesModerationOption" => %{
"@id" => "mz:repliesModerationOption",
"@type" => "mz:repliesModerationOptionType"
},
"joinModeType" => %{
"@id" => "mz:joinModeType",
"@type" => "rdfs:Class"
},
"joinMode" => %{
"@id" => "mz:joinMode",
"@type" => "mz:joinModeType"
}
}
]
}
@ -112,128 +122,56 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
Map.put_new_lazy(map, "published", &make_date/0)
end
@doc """
Inserts a full object if it is contained in an activity.
"""
def insert_full_object(object_data)
@doc """
Inserts a full object if it is contained in an activity.
"""
def insert_full_object(%{"object" => %{"type" => "Event"} = object_data, "type" => "Create"})
when is_map(object_data) do
with {:ok, object_data} <-
Converter.Event.as_to_model_data(object_data),
{:ok, %Event{} = event} <- Events.create_event(object_data) do
{:ok, event}
end
def get_actor(%{"actor" => actor}) when is_binary(actor) do
actor
end
def insert_full_object(%{"object" => %{"type" => "Group"} = object_data, "type" => "Create"})
when is_map(object_data) do
with object_data <-
Map.put(object_data, "preferred_username", object_data["preferredUsername"]),
{:ok, %Actor{} = group} <- Actors.create_group(object_data) do
{:ok, group}
end
end
@doc """
Inserts a full object if it is contained in an activity.
"""
def insert_full_object(%{"object" => %{"type" => "Note"} = object_data, "type" => "Create"})
when is_map(object_data) do
with data <- Converter.Comment.as_to_model_data(object_data),
{:ok, %Comment{} = comment} <- Events.create_comment(data) do
{:ok, comment}
def get_actor(%{"actor" => actor}) when is_list(actor) do
if is_binary(Enum.at(actor, 0)) do
Enum.at(actor, 0)
else
err ->
Logger.error("Error while inserting a remote comment inside database")
Logger.debug(inspect(err))
{:error, err}
actor
|> Enum.find(fn %{"type" => type} -> type in ["Person", "Service", "Application"] end)
|> Map.get("id")
end
end
def get_actor(%{"actor" => %{"id" => id}}) when is_bitstring(id) do
id
end
def get_actor(%{"actor" => nil, "attributedTo" => actor}) when not is_nil(actor) do
get_actor(%{"actor" => actor})
end
@doc """
Inserts a full object if it is contained in an activity.
Checks that an incoming AP object's actor matches the domain it came from.
"""
def insert_full_object(%{"type" => "Flag"} = object_data)
when is_map(object_data) do
with data <- Converter.Flag.as_to_model_data(object_data),
{:ok, %Report{} = report} <- Reports.create_report(data) do
Enum.each(Users.list_moderators(), fn moderator ->
moderator
|> Email.Admin.report(report)
|> Email.Mailer.deliver_later()
end)
def origin_check?(id, %{"actor" => actor} = params) when not is_nil(actor) do
id_uri = URI.parse(id)
actor_uri = URI.parse(get_actor(params))
{:ok, report}
else
err ->
Logger.error("Error while inserting report inside database")
Logger.debug(inspect(err))
{:error, err}
end
compare_uris?(actor_uri, id_uri)
end
def insert_full_object(_), do: {:ok, nil}
def origin_check?(_id, %{"actor" => nil}), do: false
@doc """
Update an object
"""
@spec update_object(struct(), map()) :: {:ok, struct()} | any()
def update_object(object, object_data)
def origin_check?(id, %{"attributedTo" => actor} = params),
do: origin_check?(id, Map.put(params, "actor", actor))
def update_object(event_url, %{
"object" => %{"type" => "Event"} = object_data,
"type" => "Update"
})
when is_map(object_data) do
with {:event_not_found, %Event{} = event} <-
{:event_not_found, Events.get_event_by_url(event_url)},
{:ok, object_data} <- Converter.Event.as_to_model_data(object_data),
{:ok, %Event{} = event} <- Events.update_event(event, object_data) do
{:ok, event}
end
def origin_check?(_id, _data), do: false
defp compare_uris?(%URI{} = id_uri, %URI{} = other_uri), do: id_uri.host == other_uri.host
def origin_check_from_id?(id, other_id) when is_binary(other_id) do
id_uri = URI.parse(id)
other_uri = URI.parse(other_id)
compare_uris?(id_uri, other_uri)
end
def update_object(actor_url, %{
"object" => %{"type" => type_actor} = object_data,
"type" => "Update"
})
when is_map(object_data) and type_actor in @actor_types do
with {:ok, %Actor{} = actor} <- Actors.get_actor_by_url(actor_url),
object_data <- Converter.Actor.as_to_model_data(object_data),
{:ok, %Actor{} = actor} <- Actors.update_actor(actor, object_data) do
{:ok, actor}
end
end
def update_object(_, _), do: {:ok, nil}
#### Like-related helpers
# @doc """
# Returns an existing like if a user already liked an object
# """
# def get_existing_like(actor, %{data: %{"id" => id}}) do
# query =
# from(
# activity in Activity,
# where: fragment("(?)->>'actor' = ?", activity.data, ^actor),
# # this is to use the index
# where:
# fragment(
# "coalesce((?)->'object'->>'id', (?)->>'object') = ?",
# activity.data,
# activity.data,
# ^id
# ),
# where: fragment("(?)->>'type' = 'Like'", activity.data)
# )
#
# Repo.one(query)
# end
def origin_check_from_id?(id, %{"id" => other_id} = _params) when is_binary(other_id),
do: origin_check_from_id?(id, other_id)
@doc """
Save picture data from %Plug.Upload{} and return AS Link data.
@ -284,255 +222,6 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
def make_picture_data(nil), do: nil
@doc """
Make an AP event object from an set of values
"""
@spec make_event_data(
String.t(),
map(),
String.t(),
String.t(),
map(),
list(),
map(),
String.t()
) :: map()
def make_event_data(
actor,
%{to: to, cc: cc} = _audience,
title,
content_html,
picture \\ nil,
tags \\ [],
metadata \\ %{},
uuid \\ nil,
url \\ nil
) do
Logger.debug("Making event data")
uuid = uuid || Ecto.UUID.generate()
res = %{
"type" => "Event",
"to" => to,
"cc" => cc || [],
"content" => content_html,
"name" => title,
"startTime" => metadata.begins_on,
"endTime" => metadata.ends_on,
"category" => metadata.category,
"actor" => actor,
"id" => url || Routes.page_url(Endpoint, :event, uuid),
"joinOptions" => metadata.join_options,
"status" => metadata.status,
"onlineAddress" => metadata.online_address,
"phoneAddress" => metadata.phone_address,
"draft" => metadata.draft,
"uuid" => uuid,
"tag" =>
tags |> Enum.uniq() |> Enum.map(fn tag -> %{"type" => "Hashtag", "name" => "##{tag}"} end)
}
res =
if is_nil(metadata.physical_address),
do: res,
else: Map.put(res, "location", make_address_data(metadata.physical_address))
res =
if is_nil(picture), do: res, else: Map.put(res, "attachment", [make_picture_data(picture)])
if is_nil(metadata.options) do
res
else
options = Events.EventOptions |> struct(metadata.options) |> Map.from_struct()
Enum.reduce(options, res, fn {key, value}, acc ->
(!is_nil(value) && Map.put(acc, camelize(key), value)) ||
acc
end)
end
end
def make_address_data(%Address{} = address) do
# res = %{
# "type" => "Place",
# "name" => address.description,
# "id" => address.url,
# "address" => %{
# "type" => "PostalAddress",
# "streetAddress" => address.street,
# "postalCode" => address.postal_code,
# "addressLocality" => address.locality,
# "addressRegion" => address.region,
# "addressCountry" => address.country
# }
# }
#
# if is_nil(address.geom) do
# res
# else
# Map.put(res, "geo", %{
# "type" => "GeoCoordinates",
# "latitude" => address.geom.coordinates |> elem(0),
# "longitude" => address.geom.coordinates |> elem(1)
# })
# end
address.url
end
def make_address_data(address) when is_map(address) do
Address
|> struct(address)
|> make_address_data()
end
def make_address_data(address_url) when is_bitstring(address_url) do
with %Address{} = address <- Addresses.get_address_by_url(address_url) do
address.url
end
end
@doc """
Make an AP comment object from an set of values
"""
def make_comment_data(
actor,
to,
content_html,
# attachments,
inReplyTo \\ nil,
tags \\ [],
# _cw \\ nil,
cc \\ []
) do
Logger.debug("Making comment data")
uuid = Ecto.UUID.generate()
object = %{
"type" => "Note",
"to" => to,
"cc" => cc,
"content" => content_html,
# "summary" => cw,
# "attachment" => attachments,
"actor" => actor,
"id" => Routes.page_url(Endpoint, :comment, uuid),
"uuid" => uuid,
"tag" => tags |> Enum.uniq()
}
if inReplyTo do
object
|> Map.put("inReplyTo", inReplyTo)
else
object
end
end
def make_group_data(
actor,
to,
preferred_username,
content_html,
# attachments,
tags \\ [],
# _cw \\ nil,
cc \\ []
) do
uuid = Ecto.UUID.generate()
%{
"type" => "Group",
"to" => to,
"cc" => cc,
"summary" => content_html,
"attributedTo" => actor,
"preferredUsername" => preferred_username,
"id" => Actor.build_url(preferred_username, :page),
"uuid" => uuid,
"tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq()
}
end
#### Like-related helpers
@doc """
Returns an existing like if a user already liked an object
"""
# @spec get_existing_like(Actor.t, map()) :: nil
# def get_existing_like(%Actor{url: url} = actor, %{data: %{"id" => id}}) do
# nil
# end
# def make_like_data(%Actor{url: url} = actor, %{data: %{"id" => id}} = object, activity_id) do
# data = %{
# "type" => "Like",
# "actor" => url,
# "object" => id,
# "to" => [actor.followers_url, object.data["actor"]],
# "cc" => ["https://www.w3.org/ns/activitystreams#Public"],
# "context" => object.data["context"]
# }
# if activity_id, do: Map.put(data, "id", activity_id), else: data
# end
def update_element_in_object(property, element, object) do
with new_data <-
object.data
|> Map.put("#{property}_count", length(element))
|> Map.put("#{property}s", element),
changeset <- Changeset.change(object, data: new_data),
{:ok, object} <- Repo.update(changeset) do
{:ok, object}
end
end
# def update_likes_in_object(likes, object) do
# update_element_in_object("like", likes, object)
# end
#
# def add_like_to_object(%Activity{data: %{"actor" => actor}}, object) do
# with likes <- [actor | object.data["likes"] || []] |> Enum.uniq() do
# update_likes_in_object(likes, object)
# end
# end
#
# def remove_like_from_object(%Activity{data: %{"actor" => actor}}, object) do
# with likes <- (object.data["likes"] || []) |> List.delete(actor) do
# update_likes_in_object(likes, object)
# end
# end
#### Follow-related helpers
@doc """
Makes a follow activity data for the given followed and follower
"""
def make_follow_data(%Actor{url: followed_id}, %Actor{url: follower_id}, activity_id) do
Logger.debug("Make follow data")
data = %{
"type" => "Follow",
"actor" => follower_id,
"to" => [followed_id],
"cc" => ["https://www.w3.org/ns/activitystreams#Public"],
"object" => followed_id
}
data =
if activity_id,
do: Map.put(data, "id", activity_id),
else: data
Logger.debug(inspect(data))
data
end
#### Announce-related helpers
require Logger
@doc """
Make announce activity data for the given actor and object
"""
@ -673,42 +362,6 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
|> Map.merge(additional)
end
#### Flag-related helpers
@spec make_flag_data(map(), map()) :: map()
def make_flag_data(params, additional) do
object = [params.reported_actor_url] ++ params.comments_url
object = if params[:event_url], do: object ++ [params.event_url], else: object
%{
"type" => "Flag",
"id" => "#{MobilizonWeb.Endpoint.url()}/report/#{Ecto.UUID.generate()}",
"actor" => params.reporter_url,
"content" => params.content,
"object" => object,
"state" => "open"
}
|> Map.merge(additional)
end
def make_join_data(%Event{} = event, %Actor{} = actor) do
%{
"type" => "Join",
"id" => "#{actor.url}/join/event/id",
"actor" => actor.url,
"object" => event.url
}
end
def make_join_data(%Actor{type: :Group} = event, %Actor{} = actor) do
%{
"type" => "Join",
"id" => "#{actor.url}/join/group/id",
"actor" => actor.url,
"object" => event.url
}
end
@doc """
Make accept join activity data
"""
@ -718,7 +371,6 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
"type" => "Accept",
"to" => object["to"],
"cc" => object["cc"],
"actor" => object["actor"],
"object" => object,
"id" => object["id"] <> "/activity"
}
@ -741,37 +393,39 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
end
end
@doc """
Converts PEM encoded keys to a private key representation
"""
def pem_to_private_key(pem) do
[private_key_code] = :public_key.pem_decode(pem)
:public_key.pem_entry_decode(private_key_code)
end
@doc """
Converts PEM encoded keys to a PEM public key representation
"""
def pem_to_public_key_pem(pem) do
public_key = pem_to_public_key(pem)
public_key = :public_key.pem_entry_encode(:RSAPublicKey, public_key)
:public_key.pem_encode([public_key])
end
def camelize(word) when is_atom(word) do
camelize(to_string(word))
defp make_signature(id, date) do
uri = URI.parse(id)
signature =
Mobilizon.Service.ActivityPub.Relay.get_actor()
|> Mobilizon.Service.HTTPSignatures.Signature.sign(%{
"(request-target)": "get #{uri.path}",
host: uri.host,
date: date
})
[{:Signature, signature}]
end
def camelize(word) when is_bitstring(word) do
{first, rest} = String.split_at(Macro.camelize(word), 1)
String.downcase(first) <> rest
def sign_fetch(headers, id, date) do
if Mobilizon.Config.get([:activitypub, :sign_object_fetches]) do
headers ++ make_signature(id, date)
else
headers
end
end
def underscore(word) when is_atom(word) do
underscore(to_string(word))
end
def underscore(word) when is_bitstring(word) do
Macro.underscore(word)
def maybe_date_fetch(headers, date) do
if Mobilizon.Config.get([:activitypub, :sign_object_fetches]) do
headers ++ [{:Date, date}]
else
headers
end
end
end

View File

@ -17,7 +17,10 @@ defmodule Mobilizon.Service.ActivityPub.Visibility do
def is_public?(%{data: %{"type" => "Tombstone"}}), do: false
def is_public?(%{data: data}), do: is_public?(data)
def is_public?(%Activity{data: data}), do: is_public?(data)
def is_public?(data) when is_map(data), do: @public in (data["to"] ++ (data["cc"] || []))
def is_public?(data) when is_map(data),
do: @public in (Map.get(data, "to", []) ++ Map.get(data, "cc", []))
def is_public?(%Comment{deleted_at: deleted_at}), do: !is_nil(deleted_at)
def is_public?(err), do: raise(ArgumentError, message: "Invalid argument #{inspect(err)}")
end

View File

@ -34,16 +34,14 @@ defmodule Mobilizon.Service.Formatter do
def mention_handler("@" <> nickname, buffer, _opts, acc) do
case Actors.get_actor_by_name(nickname) do
%Actor{preferred_username: preferred_username} = actor ->
link = "<span class='h-card mention'>@<span>#{preferred_username}</span></span>"
# %Actor{preferred_username: preferred_username} = actor ->
# link = "<span class='h-card mention'>@<span>#{preferred_username}</span></span>"
#
# {link, %{acc | mentions: MapSet.put(acc.mentions, {"@" <> nickname, actor})}}
{link, %{acc | mentions: MapSet.put(acc.mentions, {"@" <> nickname, actor})}}
%Actor{type: :Person, id: id, url: url, preferred_username: preferred_username} = actor ->
%Actor{type: :Person, id: id, preferred_username: preferred_username} = actor ->
link =
"<span class='h-card'><a data-user='#{id}' class='u-url mention' href='#{url}'>@<span>#{
preferred_username
}</span></a></span>"
"<span class='h-card mention' data-user='#{id}'>@<span>#{preferred_username}</span></span>"
{link, %{acc | mentions: MapSet.put(acc.mentions, {"@" <> nickname, actor})}}

Some files were not shown because too many files have changed in this diff Show More