Merge branch 'feature/admin-instance-follow' into 'master'

Add admin interface to manage instances subscriptions

See merge request framasoft/mobilizon!337
This commit is contained in:
Thomas Citharel 2019-12-15 22:12:40 +01:00
commit 4470bf5c9a
141 changed files with 4784 additions and 2405 deletions

1
.gitignore vendored
View file

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

View file

@ -22,7 +22,7 @@ config :mobilizon, :instance,
repository: Mix.Project.config()[:source_url], repository: Mix.Project.config()[:source_url],
allow_relay: true, allow_relay: true,
# Federation is to be activated with Mobilizon 1.0.0-beta.2 # Federation is to be activated with Mobilizon 1.0.0-beta.2
federating: false, federating: true,
remote_limit: 100_000, remote_limit: 100_000,
upload_limit: 10_000_000, upload_limit: 10_000_000,
avatar_upload_limit: 2_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, MobilizonWeb.Uploaders.Local, uploads: "uploads"
config :mobilizon, :media_proxy, config :mobilizon, :media_proxy,
enabled: false, enabled: true,
proxy_opts: [ proxy_opts: [
redirect_on_failure: false, redirect_on_failure: false,
max_body_length: 25 * 1_048_576, max_body_length: 25 * 1_048_576,
@ -107,7 +107,9 @@ config :auto_linker,
# TODO: Set to :no_scheme when it works properly # TODO: Set to :no_scheme when it works properly
validate_tld: true, validate_tld: true,
class: false, class: false,
strip_prefix: false strip_prefix: false,
new_window: true,
rel: "noopener noreferrer ugc"
] ]
config :phoenix, :format_encoders, json: Jason, "activity-json": Jason config :phoenix, :format_encoders, json: Jason, "activity-json": Jason
@ -120,6 +122,8 @@ config :ex_cldr,
config :http_signatures, config :http_signatures,
adapter: Mobilizon.Service.HTTPSignatures.Signature adapter: Mobilizon.Service.HTTPSignatures.Signature
config :mobilizon, :activitypub, sign_object_fetches: true
config :mobilizon, Mobilizon.Service.Geospatial.Nominatim, config :mobilizon, Mobilizon.Service.Geospatial.Nominatim,
endpoint: endpoint:
System.get_env("GEOSPATIAL_NOMINATIM_ENDPOINT") || "https://nominatim.openstreetmap.org", System.get_env("GEOSPATIAL_NOMINATIM_ENDPOINT") || "https://nominatim.openstreetmap.org",
@ -155,7 +159,7 @@ config :mobilizon, :maps,
config :mobilizon, Oban, config :mobilizon, Oban,
repo: Mobilizon.Storage.Repo, repo: Mobilizon.Storage.Repo,
prune: {:maxlen, 10_000}, 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 # Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above. # 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", "dev": "vue-cli-service build --watch",
"styleguide": "vue-cli-service styleguidist", "styleguide": "vue-cli-service styleguidist",
"styleguide:build": "vue-cli-service styleguidist:build", "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": { "dependencies": {
"@absinthe/socket": "^0.2.1",
"@absinthe/socket-apollo-link": "^0.2.1",
"@mdi/font": "^4.5.95", "@mdi/font": "^4.5.95",
"apollo-absinthe-upload-link": "^1.5.0", "apollo-absinthe-upload-link": "^1.5.0",
"apollo-cache-inmemory": "^1.5.1", "apollo-cache-inmemory": "^1.5.1",
"apollo-client": "^2.5.1", "apollo-client": "^2.5.1",
"apollo-link": "^1.2.11", "apollo-link": "^1.2.11",
"apollo-link-http": "^1.5.16", "apollo-link-http": "^1.5.16",
"apollo-link-ws": "^1.0.19",
"apollo-utilities": "^1.3.2",
"buefy": "^0.8.2", "buefy": "^0.8.2",
"graphql": "^14.5.8", "graphql": "^14.5.8",
"graphql-tag": "^2.10.1", "graphql-tag": "^2.10.1",
@ -30,6 +36,7 @@
"leaflet.locatecontrol": "^0.68.0", "leaflet.locatecontrol": "^0.68.0",
"lodash": "^4.17.11", "lodash": "^4.17.11",
"ngeohash": "^0.6.3", "ngeohash": "^0.6.3",
"phoenix": "^1.4.11",
"register-service-worker": "^1.6.2", "register-service-worker": "^1.6.2",
"tippy.js": "4.3.5", "tippy.js": "4.3.5",
"tiptap": "^1.26.0", "tiptap": "^1.26.0",
@ -58,7 +65,7 @@
"@vue/cli-plugin-unit-mocha": "^4.0.3", "@vue/cli-plugin-unit-mocha": "^4.0.3",
"@vue/cli-service": "^4.0.3", "@vue/cli-service": "^4.0.3",
"@vue/eslint-config-typescript": "^5.0.0", "@vue/eslint-config-typescript": "^5.0.0",
"@vue/test-utils": "^1.0.0-beta.29", "@vue/test-utils": "^1.0.0-beta.30",
"apollo-link-error": "^1.1.12", "apollo-link-error": "^1.1.12",
"chai": "^4.2.0", "chai": "^4.2.0",
"dotenv-webpack": "^1.7.0", "dotenv-webpack": "^1.7.0",

View file

@ -26,7 +26,8 @@
</div> </div>
<div class="media-content"> <div class="media-content">
<span class="title" ref="title">{{ actorDisplayName }}</span><br> <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> </div>
</div> </div>
@ -41,7 +42,7 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'; 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'; import { IParticipant, ParticipantRole } from '@/types/event.model';
@Component @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"> <div class="content">
<span class="first-line" v-if="!comment.deletedAt"> <span class="first-line" v-if="!comment.deletedAt">
<strong>{{ comment.actor.name }}</strong> <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"> <a class="comment-link has-text-grey" :href="commentId">
<small>{{ timeago(new Date(comment.updatedAt)) }}</small> <small>{{ timeago(new Date(comment.updatedAt)) }}</small>
</a> </a>
@ -213,7 +214,7 @@ export default class Comment extends Vue {
} }
get commentFromOrganizer(): boolean { 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 { get commentId(): String {
@ -230,6 +231,7 @@ export default class Comment extends Vue {
title: this.$t('Report this comment'), title: this.$t('Report this comment'),
comment: this.comment, comment: this.comment,
onConfirm: this.reportComment, onConfirm: this.reportComment,
outsideDomain: this.comment.actor.domain,
}, },
}); });
} }
@ -244,6 +246,7 @@ export default class Comment extends Vue {
reportedId: this.comment.actor.id, reportedId: this.comment.actor.id,
commentsIds: [this.comment.id], commentsIds: [this.comment.id],
content, content,
forward,
}, },
}); });
this.$buefy.notification.open({ this.$buefy.notification.open({

View file

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

View file

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

View file

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

View file

@ -20,7 +20,14 @@
</div> </div>
<div class="content columns"> <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 class="column" v-if="report.content">{{ report.content }}</div>
</div> </div>
</div> </div>
@ -29,10 +36,13 @@
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component, Prop, Vue } from 'vue-property-decorator';
import { IReport } from '@/types/report.model'; import { IReport } from '@/types/report.model';
import { ActorType } from '@/types/actor';
@Component @Component
export default class ReportCard extends Vue { export default class ReportCard extends Vue {
@Prop({ required: true }) report!: IReport; @Prop({ required: true }) report!: IReport;
ActorType = ActorType;
} }
</script> </script>
<style lang="scss"> <style lang="scss">

View file

@ -44,11 +44,8 @@
/> />
</div> </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"> <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> <b-switch v-model="forward">{{ $t('Transfer to {outsideDomain}', { outsideDomain }) }}</b-switch>
</div> </div>
</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 url
}, },
id, id,
domain,
preferredUsername, preferredUsername,
name name
}, },

View file

@ -10,7 +10,8 @@ const participantQuery = `
url url
}, },
name, name,
id id,
domain
}, },
event { event {
id 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, name,
avatar { avatar {
url url
} },
domain,
type
}, },
event { event {
id, id,
@ -52,7 +54,9 @@ const REPORT_FRAGMENT = gql`
name, name,
avatar { avatar {
url url
} },
domain,
type
}, },
event { event {
id, id,
@ -111,9 +115,10 @@ export const CREATE_REPORT = gql`
$reporterId: ID!, $reporterId: ID!,
$reportedId: ID!, $reportedId: ID!,
$content: String, $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 id
} }
} }

View file

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

View file

@ -333,11 +333,59 @@
"resend confirmation email": "resend confirmation email", "resend confirmation email": "resend confirmation email",
"respect of the fundamental freedoms": "respect of the fundamental freedoms", "respect of the fundamental freedoms": "respect of the fundamental freedoms",
"with another identity…": "with another identity…", "with another identity…": "with another identity…",
"with {identity}": "with {identity}", "as {identity}": "as {identity}",
"{approved} / {total} seats": "{approved} / {total} seats", "{approved} / {total} seats": "{approved} / {total} seats",
"{count} participants": "No participants yet | One participant | {count} participants", "{count} participants": "No participants yet | One participant | {count} participants",
"{count} requests waiting": "{count} requests waiting", "{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.", "{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 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", "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.", "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", "Username": "Pseudo",
"Users": "Utilisateurs", "Users": "Utilisateur⋅ice⋅s",
"View a reply": "Aucune réponse | Voir une réponse | Voir {totalReplies} réponses", "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 event page": "Voir la page de l'événement",
"View everything": "Voir tout", "View everything": "Voir tout",
@ -337,11 +337,57 @@
"resend confirmation email": "réenvoyer l'email de confirmation", "resend confirmation email": "réenvoyer l'email de confirmation",
"respect of the fundamental freedoms": "le respect des libertés fondamentales", "respect of the fundamental freedoms": "le respect des libertés fondamentales",
"with another identity…": "avec une autre identité…", "with another identity…": "avec une autre identité…",
"with {identity}": "avec {identity}", "as {identity}": "en tant que {identity}",
"{approved} / {total} seats": "{approved} / {total} places", "{approved} / {total} seats": "{approved} / {total} places",
"{count} participants": "Aucun⋅e participant⋅e | Un⋅e participant⋅e | {count} participant⋅e⋅s", "{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", "{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.", "{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 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", "resend confirmation email": "bevestigingsemail opnieuw versturen",
"respect of the fundamental freedoms": "respect voor de fundamentele vrijheden", "respect of the fundamental freedoms": "respect voor de fundamentele vrijheden",
"with another identity…": "met een andere identiteit…", "with another identity…": "met een andere identiteit…",
"with {identity}": "met {identity}",
"{approved} / {total} seats": "{approved} / {total} plaatsen", "{approved} / {total} seats": "{approved} / {total} plaatsen",
"{count} participants": "Nog geen deelnemers | Eén deelnemer | {count} deelnemers", "{count} participants": "Nog geen deelnemers | Eén deelnemer | {count} deelnemers",
"{count} requests waiting": "{count} aanvragen in afwachting", "{count} requests waiting": "{count} aanvragen in afwachting",

View file

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

View file

@ -324,7 +324,6 @@
"resend confirmation email": "skicka bekräftelsemail igen", "resend confirmation email": "skicka bekräftelsemail igen",
"respect of the fundamental freedoms": "respektera våra grundläggande friheter", "respect of the fundamental freedoms": "respektera våra grundläggande friheter",
"with another identity…": "med en annan identitet…", "with another identity…": "med en annan identitet…",
"with {identity}": "med {identity}",
"{approved} / {total} seats": "{approved} / {total} platser", "{approved} / {total} seats": "{approved} / {total} platser",
"{count} participants": "Inga deltagande ännu|En deltagande|{count} deltagande", "{count} participants": "Inga deltagande ännu|En deltagande|{count} deltagande",
"{count} requests waiting": "{count} förfrågningar väntar", "{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 Vue from 'vue';
import { ColorModifiers } from 'buefy/types/helpers';
declare module 'vue/types/vue' { declare module 'vue/types/vue' {
interface Vue { interface Vue {
$notifier: { $notifier: {
success: (message: string) => void; success: (message: string) => void;
error: (message: string) => void; error: (message: string) => void;
info: (message: string) => void;
}; };
} }
} }
@ -17,21 +19,23 @@ export class Notifier {
} }
success(message: string) { success(message: string) {
this.vue.prototype.$buefy.notification.open({ this.notification(message, 'is-success');
message,
duration: 5000,
position: 'is-bottom-right',
type: 'is-success',
hasIcon: true,
});
} }
error(message: string) { 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({ this.vue.prototype.$buefy.notification.open({
message, message,
duration: 5000, duration: 5000,
position: 'is-bottom-right', position: 'is-bottom-right',
type: 'is-danger', type,
hasIcon: true, hasIcon: true,
}); });
} }

View file

@ -1,8 +1,14 @@
import { RouteConfig } from 'vue-router'; import { RouteConfig } from 'vue-router';
import Dashboard from '@/views/Admin/Dashboard.vue'; 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 { export enum AdminRouteName {
DASHBOARD = 'Dashboard', DASHBOARD = 'Dashboard',
RELAYS = 'Relays',
RELAY_FOLLOWINGS = 'Followings',
RELAY_FOLLOWERS = 'Followers',
} }
export const adminRoutes: RouteConfig[] = [ export const adminRoutes: RouteConfig[] = [
@ -13,4 +19,24 @@ export const adminRoutes: RouteConfig[] = [
props: true, props: true,
meta: { requiredAuth: 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'; import { IPicture } from '@/types/picture.model';
export enum ActorType {
PERSON = 'PERSON',
APPLICATION = 'APPLICATION',
GROUP = 'GROUP',
ORGANISATION = 'ORGANISATION',
SERVICE = 'SERVICE',
}
export interface IActor { export interface IActor {
id?: number; id?: number;
url: string; url: string;
@ -10,6 +18,7 @@ export interface IActor {
suspended: boolean; suspended: boolean;
avatar: IPicture | null; avatar: IPicture | null;
banner: IPicture | null; banner: IPicture | null;
type: ActorType;
} }
export class Actor implements IActor { export class Actor implements IActor {
@ -22,6 +31,7 @@ export class Actor implements IActor {
summary: string = ''; summary: string = '';
suspended: boolean = false; suspended: boolean = false;
url: string = ''; url: string = '';
type: ActorType = ActorType.PERSON;
constructor (hash: IActor | {} = {}) { constructor (hash: IActor | {} = {}) {
Object.assign(this, hash); 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.onlineAddress = hash.onlineAddress;
this.phoneAddress = hash.phoneAddress; this.phoneAddress = hash.phoneAddress;
this.physicalAddress = new Address(hash.physicalAddress); this.physicalAddress = hash.physicalAddress ? new Address(hash.physicalAddress) : undefined;
this.participantStats = hash.participantStats; this.participantStats = hash.participantStats;
this.tags = hash.tags; 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"> <div class="list is-hoverable">
<a class="list-item" v-for="identity in identities" :class="{ 'is-active': identity.id === currentIdentity.id }" @click="changeCurrentIdentity(identity)"> <a class="list-item" v-for="identity in identities" :class="{ 'is-active': identity.id === currentIdentity.id }" @click="changeCurrentIdentity(identity)">
<div class="media"> <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" /> <b-icon class="media-left" v-else size="is-large" icon="account-circle" />
<div class="media-content"> <div class="media-content">
<h3>@{{ identity.preferredUsername }}</h3> <h3>@{{ identity.preferredUsername }}</h3>
@ -17,7 +17,7 @@
</a> </a>
</div> </div>
</section> </section>
<slot name="footer"></slot> <slot name="footer" />
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">

View file

@ -1,7 +1,9 @@
<template> <template>
<div class="identity-picker"> <div class="identity-picker">
<span v-if="inline" class="inline"> <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"> <b-button type="is-text" @click="isComponentModalActive = true">
{{ $t('Change') }} {{ $t('Change') }}
</b-button> </b-button>

View file

@ -38,6 +38,13 @@
</article> </article>
</router-link> </router-link>
</div> </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>
<div class="tile is-parent"> <div class="tile is-parent">
<article class="tile is-child box"> <article class="tile is-child box">
@ -67,6 +74,12 @@ import { RouteName } from '@/router';
query: DASHBOARD, query: DASHBOARD,
}, },
}, },
metaInfo() {
return {
title: this.$t('Administration') as string,
titleTemplate: '%s | Mobilizon',
};
},
}) })
export default class Dashboard extends Vue { export default class Dashboard extends Vue {
dashboard!: IDashboard; 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" /> <address-auto-complete v-model="event.physicalAddress" />
<b-field :label="$t('Organizer')"> <b-field :label="$t('Organizer')">
<identity-picker-wrapper v-model="event.organizerActor"></identity-picker-wrapper> <identity-picker-wrapper v-model="event.organizerActor" />
</b-field> </b-field>
<div class="field"> <div class="field">
@ -92,7 +92,7 @@
<div class="box" v-if="limitedPlaces"> <div class="box" v-if="limitedPlaces">
<b-field :label="$t('Number of places')"> <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>
<!-- <!--
<b-field> <b-field>
@ -145,21 +145,21 @@
name="status" name="status"
type="is-warning" type="is-warning"
:native-value="EventStatus.TENTATIVE"> :native-value="EventStatus.TENTATIVE">
<b-icon icon="calendar-question"></b-icon> <b-icon icon="calendar-question" />
{{ $t('Tentative: Will be confirmed later') }} {{ $t('Tentative: Will be confirmed later') }}
</b-radio-button> </b-radio-button>
<b-radio-button v-model="event.status" <b-radio-button v-model="event.status"
name="status" name="status"
type="is-success" type="is-success"
:native-value="EventStatus.CONFIRMED"> :native-value="EventStatus.CONFIRMED">
<b-icon icon="calendar-check"></b-icon> <b-icon icon="calendar-check" />
{{ $t('Confirmed: Will happen') }} {{ $t('Confirmed: Will happen') }}
</b-radio-button> </b-radio-button>
<b-radio-button v-model="event.status" <b-radio-button v-model="event.status"
name="status" name="status"
type="is-danger" type="is-danger"
:native-value="EventStatus.CANCELLED"> :native-value="EventStatus.CANCELLED">
<b-icon icon="calendar-remove"></b-icon> <b-icon icon="calendar-remove" />
{{ $t("Cancelled: Won't happen") }} {{ $t("Cancelled: Won't happen") }}
</b-radio-button> </b-radio-button>
</b-field> </b-field>
@ -191,7 +191,7 @@
</div> </div>
</form> </form>
</b-modal> </b-modal>
<span ref="bottomObserver"></span> <span ref="bottomObserver" />
<nav role="navigation" aria-label="main navigation" class="navbar" :class="{'is-fixed-bottom': showFixedNavbar }"> <nav role="navigation" aria-label="main navigation" class="navbar" :class="{'is-fixed-bottom': showFixedNavbar }">
<div class="container"> <div class="container">
<div class="navbar-menu"> <div class="navbar-menu">
@ -395,6 +395,11 @@ export default class EditEvent extends Vue {
} }
} }
@Watch('currentActor')
setCurrentActor() {
this.event.organizerActor = this.currentActor;
}
private validateForm() { private validateForm() {
const form = this.$refs.form as HTMLFormElement; const form = this.$refs.form as HTMLFormElement;
if (form.checkValidity()) { if (form.checkValidity()) {

View file

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

View file

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

View file

@ -27,7 +27,10 @@
</tr> </tr>
<tr> <tr>
<td>{{ $t('Reported by') }}</td> <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 } }"> <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 }} <img v-if="report.reporter.avatar" class="image" :src="report.reporter.avatar.url" /> @{{ report.reporter.preferredUsername }}
</router-link> </router-link>
@ -55,15 +58,15 @@
<td> <td>
<router-link :to="{ name: RouteName.EVENT, params: { uuid: report.event.uuid }}">{{ report.event.title }}</router-link> <router-link :to="{ name: RouteName.EVENT, params: { uuid: report.event.uuid }}">{{ report.event.title }}</router-link>
<span class="is-pulled-right"> <span class="is-pulled-right">
<b-button <!-- <b-button-->
tag="router-link" <!-- tag="router-link"-->
type="is-primary" <!-- type="is-primary"-->
:to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }" <!-- :to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"-->
icon-left="pencil" <!-- icon-left="pencil"-->
size="is-small">{{ $t('Edit') }}</b-button> <!-- size="is-small">{{ $t('Edit') }}</b-button>-->
<b-button <b-button
type="is-danger" type="is-danger"
@click="confirmDelete()" @click="confirmEventDelete()"
icon-left="delete" icon-left="delete"
size="is-small">{{ $t('Delete') }}</b-button> size="is-small">{{ $t('Delete') }}</b-button>
</span> </span>
@ -74,24 +77,24 @@
</div> </div>
<div class="box report-content"> <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> <p v-else>{{ $t('No comment') }}</p>
</div> </div>
<div class="box" v-if="report.event && report.comments.length === 0"> <div class="box" v-if="report.event && report.comments.length === 0">
<router-link :to="{ name: RouteName.EVENT, params: { uuid: report.event.uuid }}"> <router-link :to="{ name: RouteName.EVENT, params: { uuid: report.event.uuid }}">
<h3 class="title">{{ report.event.title }}</h3> <h3 class="title">{{ report.event.title }}</h3>
<p v-html="report.event.description"></p> <p v-html="report.event.description" />
</router-link> </router-link>
<b-button <!-- <b-button-->
tag="router-link" <!-- tag="router-link"-->
type="is-primary" <!-- type="is-primary"-->
:to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }" <!-- :to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"-->
icon-left="pencil" <!-- icon-left="pencil"-->
size="is-small">{{ $t('Edit') }}</b-button> <!-- size="is-small">{{ $t('Edit') }}</b-button>-->
<b-button <b-button
type="is-danger" type="is-danger"
@click="confirmDelete()" @click="confirmEventDelete()"
icon-left="delete" icon-left="delete"
size="is-small">{{ $t('Delete') }}</b-button> size="is-small">{{ $t('Delete') }}</b-button>
</div> </div>
@ -101,17 +104,25 @@
<div class="box" v-if="comment"> <div class="box" v-if="comment">
<article class="media"> <article class="media">
<div class="media-left"> <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"> <img :src="comment.actor.avatar.url" alt="Image">
</figure> </figure>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" /> <b-icon class="media-left" v-else size="is-large" icon="account-circle" />
</div> </div>
<div class="media-content"> <div class="media-content">
<div class="content"> <div class="content">
<span v-if="comment.actor">
<strong>{{ comment.actor.name }}</strong> <small>@{{ comment.actor.preferredUsername }}</small> <strong>{{ comment.actor.name }}</strong> <small>@{{ comment.actor.preferredUsername }}</small>
</span>
<span v-else>{{ $t('Unknown actor') }}</span>
<br> <br>
<p v-html="comment.text"></p> <p v-html="comment.text" />
</div> </div>
<b-button
type="is-danger"
@click="confirmCommentDelete(comment)"
icon-left="delete"
size="is-small">{{ $t('Delete') }}</b-button>
</div> </div>
</article> </article>
</div> </div>
@ -131,21 +142,23 @@
<b-field :label="$t('New note')"> <b-field :label="$t('New note')">
<b-input type="textarea" v-model="noteContent"></b-input> <b-input type="textarea" v-model="noteContent"></b-input>
</b-field> </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> </form>
</div> </div>
</section> </section>
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'; 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 { IReport, IReportNote, ReportStatusEnum } from '@/types/report.model';
import { RouteName } from '@/router'; import { RouteName } from '@/router';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor'; 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 { DELETE_EVENT } from '@/graphql/event';
import { uniq } from 'lodash'; import { uniq } from 'lodash';
import { nl2br } from '@/utils/html'; import { nl2br } from '@/utils/html';
import { DELETE_COMMENT } from '@/graphql/comment';
import { IComment } from '@/types/comment.model';
@Component({ @Component({
apollo: { apollo: {
@ -164,6 +177,12 @@ import { nl2br } from '@/utils/html';
query: CURRENT_ACTOR_CLIENT, query: CURRENT_ACTOR_CLIENT,
}, },
}, },
metaInfo() {
return {
title: this.$t('Report') as string,
titleTemplate: '%s | Mobilizon',
};
},
}) })
export default class Report extends Vue { export default class Report extends Vue {
@Prop({ required: true }) reportId!: number; @Prop({ required: true }) reportId!: number;
@ -173,6 +192,7 @@ export default class Report extends Vue {
ReportStatusEnum = ReportStatusEnum; ReportStatusEnum = ReportStatusEnum;
RouteName = RouteName; RouteName = RouteName;
ActorType = ActorType;
nl2br = nl2br; nl2br = nl2br;
noteContent: string = ''; noteContent: string = '';
@ -210,7 +230,7 @@ export default class Report extends Vue {
} }
} }
confirmDelete() { confirmEventDelete() {
this.$buefy.dialog.confirm({ this.$buefy.dialog.confirm({
title: this.$t('Deleting event') as string, 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, 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() { async deleteEvent() {
if (!this.report.event || !this.report.event.id) return; if (!this.report.event || !this.report.event.id) return;
const eventTitle = this.report.event.title; 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) { async updateReport(status: ReportStatusEnum) {
try { try {
await this.$apollo.mutate({ await this.$apollo.mutate({
@ -289,10 +335,6 @@ export default class Report extends Vue {
<style lang="scss" scoped> <style lang="scss" scoped>
@import "@/variables.scss"; @import "@/variables.scss";
.container li {
margin: 10px auto;
}
tbody td img.image, .note img.image { tbody td img.image, .note img.image {
display: inline; display: inline;
height: 1.5em; height: 1.5em;

View file

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

View file

@ -1,10 +1,10 @@
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; 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 { defaultDataIdFromObject, InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import { onError } from 'apollo-link-error'; import { onError } from 'apollo-link-error';
import { createLink } from 'apollo-absinthe-upload-link'; 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 { ApolloClient } from 'apollo-client';
import { buildCurrentUserResolver } from '@/apollo/user'; import { buildCurrentUserResolver } from '@/apollo/user';
import { isServerError } from '@/types/apollo'; 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 { logout, saveTokenData } from '@/utils/auth';
import { SnackbarProgrammatic as Snackbar } from 'buefy'; import { SnackbarProgrammatic as Snackbar } from 'buefy';
import { defaultError, errors, IError, refreshSuggestion } from '@/utils/errors'; 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 // Install the vue plugin
Vue.use(VueApollo); Vue.use(VueApollo);
// Http endpoint // Endpoints
const httpServer = GRAPHQL_API_ENDPOINT || 'http://localhost:4000'; const httpServer = GRAPHQL_API_ENDPOINT || 'http://localhost:4000';
const httpEndpoint = GRAPHQL_API_FULL_PATH || `${httpServer}/api`; const httpEndpoint = GRAPHQL_API_FULL_PATH || `${httpServer}/api`;
const wsEndpoint = `ws${httpServer.substring(httpServer.indexOf(':'))}/graphql_socket`;
const fragmentMatcher = new IntrospectionFragmentMatcher({ const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData: { introspectionQueryResultData: {
@ -60,10 +65,6 @@ const authMiddleware = new ApolloLink((operation, forward) => {
return null; return null;
}); });
const uploadLink = createLink({
uri: httpEndpoint,
});
let refreshingTokenPromise: Promise<boolean> | undefined; let refreshingTokenPromise: Promise<boolean> | undefined;
let alreadyRefreshedToken = false; let alreadyRefreshedToken = false;
const errorLink = onError(({ graphQLErrors, networkError, forward, operation }) => { const errorLink = onError(({ graphQLErrors, networkError, forward, operation }) => {
@ -126,9 +127,38 @@ const computeErrorMessage = (message) => {
return error.suggestRefresh === false ? error.value : `${error.value}<br>${refreshSuggestion}`; 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(errorLink)
.concat(uploadLink); .concat(link);
const cache = new InMemoryCache({ const cache = new InMemoryCache({
fragmentMatcher, fragmentMatcher,
@ -143,7 +173,7 @@ const cache = new InMemoryCache({
const apolloClient = new ApolloClient({ const apolloClient = new ApolloClient({
cache, cache,
link, link: fullLink,
connectToDevTools: true, connectToDevTools: true,
resolvers: buildCurrentUserResolver(cache), resolvers: buildCurrentUserResolver(cache),
}); });

File diff suppressed because it is too large Load diff

View file

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

View file

@ -37,6 +37,7 @@ defmodule Mobilizon do
# supervisors # supervisors
Mobilizon.Storage.Repo, Mobilizon.Storage.Repo,
MobilizonWeb.Endpoint, MobilizonWeb.Endpoint,
{Absinthe.Subscription, [MobilizonWeb.Endpoint]},
{Oban, Application.get_env(:mobilizon, Oban)}, {Oban, Application.get_env(:mobilizon, Oban)},
# workers # workers
Guardian.DB.Token.SweeperServer, Guardian.DB.Token.SweeperServer,
@ -44,7 +45,8 @@ defmodule Mobilizon do
cachex_spec(:feed, 2500, 60, 60, &Feed.create_cache/1), cachex_spec(:feed, 2500, 60, 60, &Feed.create_cache/1),
cachex_spec(:ics, 2500, 60, 60, &ICalendar.create_cache/1), cachex_spec(:ics, 2500, 60, 60, &ICalendar.create_cache/1),
cachex_spec(:statistics, 10, 60, 60), 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) Supervisor.start_link(children, strategy: :one_for_one, name: Mobilizon.Supervisor)
@ -88,4 +90,12 @@ defmodule Mobilizon do
@spec fallback_options(function | nil) :: keyword @spec fallback_options(function | nil) :: keyword
defp fallback_options(nil), do: [] defp fallback_options(nil), do: []
defp fallback_options(fallback), do: [fallback: fallback(default: fallback)] 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 end

View file

@ -7,9 +7,9 @@ defmodule Mobilizon.Actors.Actor do
import Ecto.Changeset import Ecto.Changeset
alias Mobilizon.{Actors, Config, Crypto} alias Mobilizon.{Actors, Config, Crypto, Share}
alias Mobilizon.Actors.{ActorOpenness, ActorType, ActorVisibility, Follower, Member} 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.Media.File
alias Mobilizon.Reports.{Note, Report} alias Mobilizon.Reports.{Note, Report}
alias Mobilizon.Users.User alias Mobilizon.Users.User
@ -43,11 +43,14 @@ defmodule Mobilizon.Actors.Actor do
followers: [Follower.t()], followers: [Follower.t()],
followings: [Follower.t()], followings: [Follower.t()],
organized_events: [Event.t()], organized_events: [Event.t()],
comments: [Comment.t()],
feed_tokens: [FeedToken.t()], feed_tokens: [FeedToken.t()],
created_reports: [Report.t()], created_reports: [Report.t()],
subject_reports: [Report.t()], subject_reports: [Report.t()],
report_notes: [Note.t()], report_notes: [Note.t()],
mentions: [Mention.t()], mentions: [Mention.t()],
shares: [Share.t()],
owner_shares: [Share.t()],
memberships: [t] memberships: [t]
} }
@ -137,11 +140,14 @@ defmodule Mobilizon.Actors.Actor do
has_many(:followers, Follower, foreign_key: :target_actor_id) has_many(:followers, Follower, foreign_key: :target_actor_id)
has_many(:followings, Follower, foreign_key: :actor_id) has_many(:followings, Follower, foreign_key: :actor_id)
has_many(:organized_events, Event, foreign_key: :organizer_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(:feed_tokens, FeedToken, foreign_key: :actor_id)
has_many(:created_reports, Report, foreign_key: :reporter_id) has_many(:created_reports, Report, foreign_key: :reporter_id)
has_many(:subject_reports, Report, foreign_key: :reported_id) has_many(:subject_reports, Report, foreign_key: :reported_id)
has_many(:report_notes, Note, foreign_key: :moderator_id) has_many(:report_notes, Note, foreign_key: :moderator_id)
has_many(:mentions, Mention) 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) many_to_many(:memberships, __MODULE__, join_through: Member)
timestamps() timestamps()
@ -217,6 +223,19 @@ defmodule Mobilizon.Actors.Actor do
|> validate_required(@update_required_attrs) |> validate_required(@update_required_attrs)
end 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 """ @doc """
Changeset for person registration. Changeset for person registration.
""" """

View file

@ -12,6 +12,8 @@ defmodule Mobilizon.Actors do
alias Mobilizon.{Crypto, Events} alias Mobilizon.{Crypto, Events}
alias Mobilizon.Media.File alias Mobilizon.Media.File
alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Service.Workers.BackgroundWorker
alias Mobilizon.Service.ActivityPub
require Logger require Logger
@ -47,6 +49,7 @@ defmodule Mobilizon.Actors do
@public_visibility [:public, :unlisted] @public_visibility [:public, :unlisted]
@administrator_roles [:creator, :administrator] @administrator_roles [:creator, :administrator]
@actor_preloads [:user, :organized_events, :comments]
@doc """ @doc """
Gets a single actor. Gets a single actor.
@ -224,16 +227,24 @@ defmodule Mobilizon.Actors do
end end
end end
def delete_actor(%Actor{} = actor) do
BackgroundWorker.enqueue("delete_actor", %{"actor_id" => actor.id})
end
@doc """ @doc """
Deletes an actor. Deletes an actor.
""" """
@spec delete_actor(Actor.t()) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} @spec perform(atom(), Actor.t()) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()}
def delete_actor(%Actor{domain: nil} = actor) do def perform(:delete_actor, %Actor{} = actor) do
actor = Repo.preload(actor, @actor_preloads)
transaction = transaction =
Multi.new() Multi.new()
|> Multi.delete(:actor, actor) |> Multi.run(:delete_organized_events, fn _, _ -> delete_actor_organized_events(actor) end)
|> Multi.run(:remove_banner, fn _, %{actor: %Actor{}} -> remove_banner(actor) end) |> Multi.run(:empty_comments, fn _, _ -> delete_actor_empty_comments(actor) end)
|> Multi.run(:remove_avatar, fn _, %{actor: %Actor{}} -> remove_avatar(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() |> Repo.transaction()
case transaction do case transaction do
@ -245,8 +256,6 @@ defmodule Mobilizon.Actors do
end end
end end
def delete_actor(%Actor{} = actor), do: Repo.delete(actor)
@doc """ @doc """
Returns the list of actors. Returns the list of actors.
""" """
@ -486,9 +495,9 @@ defmodule Mobilizon.Actors do
|> Repo.insert() |> Repo.insert()
end 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()} {: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 case get_actor_by_url(url) do
{:ok, %Actor{} = actor} -> {:ok, %Actor{} = actor} ->
{:ok, actor} {:ok, actor}
@ -571,9 +580,12 @@ defmodule Mobilizon.Actors do
""" """
@spec update_follower(Follower.t(), map) :: {:ok, Follower.t()} | {:error, Ecto.Changeset.t()} @spec update_follower(Follower.t(), map) :: {:ok, Follower.t()} | {:error, Ecto.Changeset.t()}
def update_follower(%Follower{} = follower, attrs) do def update_follower(%Follower{} = follower, attrs) do
with {:ok, %Follower{} = follower} <-
follower follower
|> Follower.changeset(attrs) |> Follower.changeset(attrs)
|> Repo.update() |> Repo.update() do
{:ok, Repo.preload(follower, [:actor, :target_actor])}
end
end end
@doc """ @doc """
@ -597,10 +609,10 @@ defmodule Mobilizon.Actors do
Returns the list of followers for an actor. 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. 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()] @spec list_followers_actors_for_actor(Actor.t()) :: [Actor.t()]
def list_followers_for_actor(%Actor{id: actor_id}) do def list_followers_actors_for_actor(%Actor{id: actor_id}) do
actor_id actor_id
|> followers_for_actor_query() |> follower_actors_for_actor_query()
|> Repo.all() |> Repo.all()
end end
@ -610,18 +622,28 @@ defmodule Mobilizon.Actors do
@spec list_external_followers_for_actor(Actor.t()) :: [Follower.t()] @spec list_external_followers_for_actor(Actor.t()) :: [Follower.t()]
def list_external_followers_for_actor(%Actor{id: actor_id}) do def list_external_followers_for_actor(%Actor{id: actor_id}) do
actor_id actor_id
|> followers_for_actor_query() |> list_external_follower_actors_for_actor_query()
|> filter_external()
|> Repo.all() |> Repo.all()
end 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 """ @doc """
Build a page struct for followers of an actor. Build a page struct for followers of an actor.
""" """
@spec build_followers_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t() @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 def build_followers_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
actor_id actor_id
|> followers_for_actor_query() |> follower_actors_for_actor_query()
|> Page.build_page(page, limit) |> Page.build_page(page, limit)
end end
@ -632,17 +654,32 @@ defmodule Mobilizon.Actors do
@spec list_followings_for_actor(Actor.t()) :: [Follower.t()] @spec list_followings_for_actor(Actor.t()) :: [Follower.t()]
def list_followings_for_actor(%Actor{id: actor_id}) do def list_followings_for_actor(%Actor{id: actor_id}) do
actor_id actor_id
|> followings_for_actor_query() |> followings_actors_for_actor_query()
|> Repo.all() |> Repo.all()
end 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 """ @doc """
Build a page struct for followings of an actor. Build a page struct for followings of an actor.
""" """
@spec build_followings_for_actor(Actor.t(), integer | nil, integer | nil) :: Page.t() @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 def build_followings_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
actor_id actor_id
|> followings_for_actor_query() |> followings_actors_for_actor_query()
|> Page.build_page(page, limit) |> Page.build_page(page, limit)
end end
@ -747,7 +784,7 @@ defmodule Mobilizon.Actors do
defp actor_with_preload_query(actor_id) do defp actor_with_preload_query(actor_id) do
from( from(
a in Actor, a in Actor,
where: a.id == ^actor_id, where: a.id == ^actor_id and not a.suspended,
preload: [:organized_events, :followers, :followings] preload: [:organized_events, :followers, :followings]
) )
end end
@ -885,12 +922,13 @@ defmodule Mobilizon.Actors do
defp follower_by_followed_and_following_query(followed_id, follower_id) do defp follower_by_followed_and_following_query(followed_id, follower_id) do
from( from(
f in Follower, 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 end
@spec followers_for_actor_query(integer | String.t()) :: Ecto.Query.t() @spec follower_actors_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp followers_for_actor_query(actor_id) do defp follower_actors_for_actor_query(actor_id) do
from( from(
a in Actor, a in Actor,
join: f in Follower, join: f in Follower,
@ -899,8 +937,18 @@ defmodule Mobilizon.Actors do
) )
end end
@spec followings_for_actor_query(integer | String.t()) :: Ecto.Query.t() @spec follower_for_actor_query(integer | String.t()) :: Ecto.Query.t()
defp followings_for_actor_query(actor_id) do 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( from(
a in Actor, a in Actor,
join: f in Follower, join: f in Follower,
@ -909,6 +957,38 @@ defmodule Mobilizon.Actors do
) )
end 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() @spec filter_local(Ecto.Query.t()) :: Ecto.Query.t()
defp filter_local(query) do defp filter_local(query) do
from(a in query, where: is_nil(a.domain)) 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)) from(a in query, where: not is_nil(a.domain))
end 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() @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) from(a in query, where: a.type == ^type)
end end
@ -943,4 +1031,36 @@ defmodule Mobilizon.Actors do
@spec preload_followers(Actor.t(), boolean) :: Actor.t() @spec preload_followers(Actor.t(), boolean) :: Actor.t()
defp preload_followers(actor, true), do: Repo.preload(actor, [:followers]) defp preload_followers(actor, true), do: Repo.preload(actor, [:followers])
defp preload_followers(actor, false), do: actor 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 end

View file

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

View file

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

View file

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

View file

@ -39,6 +39,21 @@ defmodule Mobilizon.Events.EventParticipantStats do
@doc false @doc false
@spec changeset(t, map) :: Ecto.Changeset.t() @spec changeset(t, map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = event_options, attrs) do 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
end end

View file

@ -97,6 +97,7 @@ defmodule Mobilizon.Events do
@comment_preloads [ @comment_preloads [
:actor, :actor,
:event,
:attributed_to, :attributed_to,
:in_reply_to_comment, :in_reply_to_comment,
:origin_comment, :origin_comment,
@ -722,6 +723,13 @@ defmodule Mobilizon.Events do
|> Repo.all() |> Repo.all()
end 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 """ @doc """
Returns the list of participations for an actor. Returns the list of participations for an actor.
@ -864,30 +872,15 @@ defmodule Mobilizon.Events do
|> Multi.run(:update_event_participation_stats, fn _repo, |> Multi.run(:update_event_participation_stats, fn _repo,
%{ %{
participant: participant:
%Participant{ %Participant{role: new_role} =
role: role, participant
event_id: event_id
} = _participant
} -> } ->
with {:update_event_participation_stats, true} <- update_participant_stats(
{:update_event_participation_stats, update_event_participation_stats}, participant,
{:ok, %Event{} = event} <- get_event(event_id), nil,
%EventParticipantStats{} = participant_stats <- new_role,
Map.get(event, :participant_stats), update_event_participation_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
end) end)
|> Repo.transaction() do |> Repo.transaction() do
{:ok, Repo.preload(participant, [:event, :actor])} {:ok, Repo.preload(participant, [:event, :actor])}
@ -899,10 +892,21 @@ defmodule Mobilizon.Events do
""" """
@spec update_participant(Participant.t(), map) :: @spec update_participant(Participant.t(), map) ::
{:ok, Participant.t()} | {:error, Changeset.t()} {:ok, Participant.t()} | {:error, Changeset.t()}
def update_participant(%Participant{} = participant, attrs) do 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 participant
|> Participant.changeset(attrs) } ->
|> Repo.update() update_participant_stats(participant, old_role, new_role)
end)
|> Repo.transaction() do
{:ok, Repo.preload(participant, [:event, :actor])}
end
end end
@doc """ @doc """
@ -910,7 +914,71 @@ defmodule Mobilizon.Events do
""" """
@spec delete_participant(Participant.t()) :: @spec delete_participant(Participant.t()) ::
{:ok, Participant.t()} | {:error, Changeset.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 """ @doc """
Gets a single session. Gets a single session.
@ -1170,11 +1238,7 @@ defmodule Mobilizon.Events do
@spec delete_comment(Comment.t()) :: {:ok, Comment.t()} | {:error, Changeset.t()} @spec delete_comment(Comment.t()) :: {:ok, Comment.t()} | {:error, Changeset.t()}
def delete_comment(%Comment{} = comment) do def delete_comment(%Comment{} = comment) do
comment comment
|> Comment.delete_changeset(%{ |> Comment.delete_changeset()
text: nil,
actor_id: nil,
deleted_at: DateTime.utc_now()
})
|> Repo.update() |> Repo.update()
end end
@ -1561,14 +1625,22 @@ defmodule Mobilizon.Events do
defp list_participants_for_event_query(event_id) do defp list_participants_for_event_query(event_id) do
from( from(
p in Participant, p in Participant,
join: e in Event, where: p.event_id == ^event_id,
on: p.event_id == e.id,
where: e.id == ^event_id,
preload: [:actor] preload: [:actor]
) )
end 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 def list_local_emails_user_participants_for_event_query(event_id) do
Participant Participant
|> join(:inner, [p], a in Actor, on: p.actor_id == a.id and is_nil(a.domain)) |> 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) |> cast(attrs, @attrs)
|> ensure_url() |> ensure_url()
|> validate_required(@required_attrs) |> validate_required(@required_attrs)
|> unique_constraint(:actor_id, name: :participants_event_id_actor_id_index)
end end
# If there's a blank URL that's because we're doing the first insert # 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 def changeset(%__MODULE__{} = tombstone, attrs) do
tombstone tombstone
|> cast(attrs, @attrs) |> cast(attrs, @attrs)
|> validate_required(@attrs) |> validate_required(@required_attrs)
end end
@spec create_tombstone(map()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()} @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 def follow(%Actor{} = follower, %Actor{} = followed) do
case ActivityPub.follow(follower, followed) do case ActivityPub.follow(follower, followed) do
{:ok, activity, _} -> {:ok, activity, follow} ->
{:ok, activity} {:ok, activity, follow}
e -> e ->
Logger.warn("Error while following actor: #{inspect(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 def unfollow(%Actor{} = follower, %Actor{} = followed) do
case ActivityPub.unfollow(follower, followed) do case ActivityPub.unfollow(follower, followed) do
{:ok, activity, _} -> {:ok, activity, follow} ->
{:ok, activity} {:ok, activity, follow}
e -> e ->
Logger.warn("Error while unfollowing actor: #{inspect(e)}") Logger.warn("Error while unfollowing actor: #{inspect(e)}")
@ -33,15 +33,35 @@ defmodule MobilizonWeb.API.Follows do
end end
def accept(%Actor{} = follower, %Actor{} = followed) do def accept(%Actor{} = follower, %Actor{} = followed) do
Logger.debug("We're trying to accept a follow")
with %Follower{approved: false} = follow <- with %Follower{approved: false} = follow <-
Actors.is_following(follower, followed), Actors.is_following(follower, followed),
{:ok, %Activity{} = activity, %Follower{approved: true}} <- {:ok, %Activity{} = activity, %Follower{approved: true} = follow} <-
ActivityPub.accept( ActivityPub.accept(
:follow, :follow,
follow, follow,
%{approved: true} true
) do ) 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 else
%Follower{approved: true} -> %Follower{approved: true} ->
{:error, "Follow already accepted"} {: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(), args |> Map.get(:preferred_username) |> HtmlSanitizeEx.strip_tags() |> String.trim(),
{:existing_group, nil} <- {:existing_group, nil} <-
{:existing_group, Actors.get_local_group_by_title(preferred_username)}, {: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} {:ok, activity, group}
else else
{:existing_group, _} -> {:existing_group, _} ->

View file

@ -4,7 +4,6 @@ defmodule MobilizonWeb.API.Participations do
""" """
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Events
alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub
alias MobilizonWeb.Email.Participation alias MobilizonWeb.Email.Participation
@ -36,16 +35,13 @@ defmodule MobilizonWeb.API.Participations do
%Participant{} = participation, %Participant{} = participation,
%Actor{} = moderator %Actor{} = moderator
) do ) do
with {:ok, activity, _} <- with {:ok, activity, %Participant{role: :participant} = participation} <-
ActivityPub.accept( ActivityPub.accept(
:join, :join,
participation, participation,
%{role: :participant},
true, 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 <- Participation.send_emails_to_local_user(participation) do
{:ok, activity, participation} {:ok, activity, participation}
end end
@ -55,17 +51,12 @@ defmodule MobilizonWeb.API.Participations do
%Participant{} = participation, %Participant{} = participation,
%Actor{} = moderator %Actor{} = moderator
) do ) do
with {:ok, activity, _} <- with {:ok, activity, %Participant{role: :rejected} = participation} <-
ActivityPub.reject( ActivityPub.reject(
%{ :join,
to: [participation.actor.url], participation,
actor: moderator.url, %{"actor" => moderator.url}
object: participation.url
},
"#{MobilizonWeb.Endpoint.url()}/reject/join/#{participation.id}"
), ),
{:ok, %Participant{role: :rejected} = participation} <-
Events.update_participant(participation, %{"role" => :rejected}),
:ok <- Participation.send_emails_to_local_user(participation) do :ok <- Participation.send_emails_to_local_user(participation) do
{:ok, activity, participation} {:ok, activity, participation}
end 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. Create a report/flag on an actor, and optionally on an event or on comments.
""" """
def report(args) do 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}} -> {:make_activity, {:ok, %Activity{} = activity, %Report{} = report}} ->
{:ok, activity, report} {:ok, activity, report}

View file

@ -3,10 +3,12 @@ defmodule MobilizonWeb.Cache.ActivityPub do
The ActivityPub related functions. The ActivityPub related functions.
""" """
alias Mobilizon.{Actors, Events, Service} alias Mobilizon.{Actors, Events, Service, Tombstone}
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Events.{Comment, Event} alias Mobilizon.Events.{Comment, Event}
alias Service.ActivityPub alias Service.ActivityPub
alias MobilizonWeb.Router.Helpers, as: Routes
alias MobilizonWeb.Endpoint
@cache :activity_pub @cache :activity_pub
@ -39,7 +41,12 @@ defmodule MobilizonWeb.Cache.ActivityPub do
{:commit, event} {:commit, event}
nil -> 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) 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) action_fallback(:errors)
plug(MobilizonWeb.Plugs.Federating when action in [:inbox, :relay])
plug(:relay_active? when action in [:relay]) plug(:relay_active? when action in [:relay])
def relay_active?(conn, _) do def relay_active?(conn, _) do
@ -114,7 +115,7 @@ defmodule MobilizonWeb.ActivityPubController do
end end
def relay(conn, _params) do 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 conn
|> put_resp_header("content-type", "application/activity+json") |> put_resp_header("content-type", "application/activity+json")
|> json(ActorView.render("actor.json", %{actor: actor})) |> 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 defp render_or_error(conn, check_fn, status, object_type, object) do
if check_fn.(status, object) do if check_fn.(status, object) do
case object do
%Mobilizon.Tombstone{} ->
conn
|> put_status(:gone)
|> render(object_type, object: object)
_ ->
render(conn, object_type, object: object) render(conn, object_type, object: object)
end
else else
{:error, :not_found} {:error, :not_found}
end end
end end
defp is_visible?(%{visibility: v}), do: v in [:public, :unlisted] 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: status in [:ok, :commit]
defp ok_status?(status, _), do: ok_status?(status) defp ok_status?(status, _), do: ok_status?(status)

View file

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

View file

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

View file

@ -22,8 +22,8 @@ defmodule MobilizonWeb.HTTPSignaturePlug do
end end
def call(conn, _opts) do def call(conn, _opts) do
[signature | _] = get_req_header(conn, "signature") case get_req_header(conn, "signature") do
[signature | _] ->
if signature do if signature do
# set (request-target) header to the appropriate value # set (request-target) header to the appropriate value
# we also replace the digest header with the one we computed # we also replace the digest header with the one we computed
@ -42,10 +42,16 @@ defmodule MobilizonWeb.HTTPSignaturePlug do
conn conn
end end
assign(conn, :valid_signature, HTTPSignatures.validate_conn(conn)) signature_valid = HTTPSignatures.validate_conn(conn)
Logger.debug("Is signature valid ? #{inspect(signature_valid)}")
assign(conn, :valid_signature, signature_valid)
else else
Logger.debug("No signature header!") Logger.debug("No signature header!")
conn conn
end end
_ ->
conn
end
end 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 import Mobilizon.Users.Guards
alias Mobilizon.Admin.ActionLog alias Mobilizon.Admin.ActionLog
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Events alias Mobilizon.Events
alias Mobilizon.Events.{Event, Comment} alias Mobilizon.Events.{Event, Comment}
alias Mobilizon.Reports.{Note, Report} alias Mobilizon.Reports.{Note, Report}
alias Mobilizon.Service.Statistics alias Mobilizon.Service.Statistics
alias Mobilizon.Users.User alias Mobilizon.Users.User
alias Mobilizon.Storage.Page
alias Mobilizon.Service.ActivityPub.Relay
def list_action_logs( def list_action_logs(
_parent, _parent,
@ -136,4 +140,76 @@ defmodule MobilizonWeb.Resolvers.Admin do
def get_dashboard(_parent, _args, _resolution) do def get_dashboard(_parent, _args, _resolution) do
{:error, "You need to be logged-in and an administrator to access dashboard statistics"} {:error, "You need to be logged-in and an administrator to access dashboard statistics"}
end 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 end

View file

@ -245,6 +245,9 @@ defmodule MobilizonWeb.Resolvers.Event do
{:error, {:error,
"Participant #{id} can't be approved since it's already a participant (with role #{role})"} "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, _} -> {:actor_approve_permission, _} ->
{:error, "Provided moderator actor ID doesn't have permission on this event"} {: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}} %{context: %{current_user: user}}
) do ) do
with creator_actor_id <- Map.get(args, :creator_actor_id), with creator_actor_id <- Map.get(args, :creator_actor_id),
{:is_owned, %Actor{} = actor} <- User.owns_actor(user, creator_actor_id), {:is_owned, %Actor{} = creator_actor} <- User.owns_actor(user, creator_actor_id),
args <- Map.put(args, :creator_actor, actor), args <- Map.put(args, :creator_actor, creator_actor),
{:ok, _activity, %Actor{type: :Group} = group} <- {:ok, _activity, %Actor{type: :Group} = group} <-
API.Groups.create_group(args) do API.Groups.create_group(args) do
{:ok, group} {:ok, group}

View file

@ -97,7 +97,7 @@ defmodule MobilizonWeb.Resolvers.Person do
{:find_actor, Actors.get_actor(id)}, {:find_actor, Actors.get_actor(id)},
{:is_owned, %Actor{}} <- User.owns_actor(user, actor.id), {:is_owned, %Actor{}} <- User.owns_actor(user, actor.id),
args <- save_attached_pictures(args), 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} {:ok, actor}
else else
{:find_actor, nil} -> {:find_actor, nil} ->

View file

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

View file

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

View file

@ -3,13 +3,10 @@ defmodule MobilizonWeb.Schema.ActorInterface do
Schema representation for Actor Schema representation for Actor
""" """
use Absinthe.Schema.Notation use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.{Events}
import_types(MobilizonWeb.Schema.Actors.FollowerType) import_types(MobilizonWeb.Schema.Actors.FollowerType)
import_types(MobilizonWeb.Schema.EventType) import_types(MobilizonWeb.Schema.EventType)
# import_types(MobilizonWeb.Schema.PictureType)
@desc "An ActivityPub actor" @desc "An ActivityPub actor"
interface :actor do interface :actor do
@ -21,7 +18,6 @@ defmodule MobilizonWeb.Schema.ActorInterface do
field(:local, :boolean, description: "If the actor is from this instance") field(:local, :boolean, description: "If the actor is from this instance")
field(:summary, :string, description: "The actor's summary") field(:summary, :string, description: "The actor's summary")
field(:preferred_username, :string, description: "The actor's preferred username") field(:preferred_username, :string, description: "The actor's preferred username")
field(:keys, :string, description: "The actors RSA Keys")
field(:manually_approves_followers, :boolean, field(:manually_approves_followers, :boolean,
description: "Whether the actors manually approves followers" 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(:followersCount, :integer, description: "Number of followers for this actor")
field(:followingCount, :integer, description: "Number of actors following 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 resolve_type(fn
%Actor{type: :Person}, _ -> %Actor{type: :Person}, _ ->
:person :person
@ -56,6 +41,9 @@ defmodule MobilizonWeb.Schema.ActorInterface do
%Actor{type: :Group}, _ -> %Actor{type: :Group}, _ ->
:group :group
%Actor{type: :Application}, _ ->
:application
_, _ -> _, _ ->
nil nil
end) 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, field(:approved, :boolean,
description: "Whether the follow has been approved by the target actor" 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
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(:local, :boolean, description: "If the actor is from this instance")
field(:summary, :string, description: "The actor's summary") field(:summary, :string, description: "The actor's summary")
field(:preferred_username, :string, description: "The actor's preferred username") field(:preferred_username, :string, description: "The actor's preferred username")
field(:keys, :string, description: "The actors RSA Keys")
field(:manually_approves_followers, :boolean, field(:manually_approves_followers, :boolean,
description: "Whether the actors manually approves followers" 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(:local, :boolean, description: "If the actor is from this instance")
field(:summary, :string, description: "The actor's summary") field(:summary, :string, description: "The actor's summary")
field(:preferred_username, :string, description: "The actor's preferred username") field(:preferred_username, :string, description: "The actor's preferred username")
field(:keys, :string, description: "The actors RSA Keys")
field(:manually_approves_followers, :boolean, field(:manually_approves_followers, :boolean,
description: "Whether the actors manually approves followers" description: "Whether the actors manually approves followers"
@ -160,4 +159,14 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do
resolve(handle_errors(&Person.register_person/3)) resolve(handle_errors(&Person.register_person/3))
end end
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 end

View file

@ -71,5 +71,49 @@ defmodule MobilizonWeb.Schema.AdminType do
field :dashboard, type: :dashboard do field :dashboard, type: :dashboard do
resolve(&Admin.get_dashboard/3) resolve(&Admin.get_dashboard/3)
end 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
end end

View file

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

View file

@ -35,7 +35,11 @@
<tr> <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;" > <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;"> <p style="margin: 0;">
<%= 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) %> <%= 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> </p>
</td> </td>
</tr> </tr>
@ -59,10 +63,10 @@
<%= if Map.has_key?(@report, :comments) && length(@report.comments) > 0 do %> <%= if Map.has_key?(@report, :comments) && length(@report.comments) > 0 do %>
<tr> <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;" > <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 %> <%= for comment <- @report.comments do %>
<p style="margin: 0;"> <p style="margin: 0;">
<%= comment.text %> <%= HtmlSanitizeEx.strip_tags(comment.text) %>
</p> </p>
<% end %> <% end %>
<table cellspacing="0" cellpadding="0" border="0" width="100%" style="width: 100% !important;"> <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 %> <%= if Map.has_key?(@report, :event) do %>
<%= gettext "Event" %> <%= gettext "Event" %>
<%= @report.event.title %> <%= @report.event.title %>
<% end %> <% end %>
<%= if Map.has_key?(@report, :comments) && length(@report.comments) > 0 do %> <%= if Map.has_key?(@report, :comments) && length(@report.comments) > 0 do %>
<%= gettext "Comments" %> <%= gettext "Comments" %>
<%= for comment <- @report.comments do %> <%= for comment <- @report.comments do %>
<%= comment.text %> <%= comment.text %>
<% end %> <% end %>
<% end %> <% end %>
<%= if @report.content do %> <%= if @report.content do %>
<%= gettext "Reason" %> <%= gettext "Reason" %>
<%= @report.content %> <%= @report.content %>
<% end %> <% end %>
View the report: <%= moderation_report_url(MobilizonWeb.Endpoint, :index, @report.id) %> View the report: <%= moderation_report_url(MobilizonWeb.Endpoint, :index, @report.id) %>

View file

@ -148,6 +148,21 @@ defmodule MobilizonWeb.Upload do
end end
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 defp check_file_size(path, size_limit) when is_integer(size_limit) and size_limit > 0 do
with {:ok, %{size: size}} <- File.stat(path), with {:ok, %{size: size}} <- File.stat(path),
true <- size <= size_limit do true <- size <= size_limit do
@ -160,6 +175,23 @@ defmodule MobilizonWeb.Upload do
defp check_file_size(_, _), do: :ok 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 defp url_from_spec(%__MODULE__{name: name}, base_url, {:file, path}) do
path = path =
URI.encode(path, &char_unescaped?/1) <> URI.encode(path, &char_unescaped?/1) <>

View file

@ -4,44 +4,13 @@ defmodule MobilizonWeb.ActivityPub.ActorView do
alias Mobilizon.Actors alias Mobilizon.Actors
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub
alias Mobilizon.Service.ActivityPub.{Activity, Utils} alias Mobilizon.Service.ActivityPub.{Activity, Utils, Convertible}
@private_visibility_empty_collection %{elements: [], total: 0} @private_visibility_empty_collection %{elements: [], total: 0}
def render("actor.json", %{actor: actor}) do def render("actor.json", %{actor: actor}) do
public_key = Utils.pem_to_public_key_pem(actor.keys) actor
|> Convertible.model_to_as()
%{
"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)
# }
}
|> Map.merge(Utils.make_json_ld_header()) |> Map.merge(Utils.make_json_ld_header())
end end

View file

@ -4,69 +4,34 @@ defmodule MobilizonWeb.PageView do
""" """
use MobilizonWeb, :view use MobilizonWeb, :view
alias Mobilizon.Actors.Actor 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.Metadata
alias Mobilizon.Service.MetadataUtils alias Mobilizon.Service.MetadataUtils
alias Mobilizon.Service.Metadata.Instance alias Mobilizon.Service.Metadata.Instance
alias Mobilizon.Events.{Comment, Event}
def render("actor.activity-json", %{conn: %{assigns: %{object: actor}}}) do def render("actor.activity-json", %{conn: %{assigns: %{object: %Actor{} = actor}}}) do
public_key = Utils.pem_to_public_key_pem(actor.keys) actor
|> Convertible.model_to_as()
%{
"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)
# }
}
|> Map.merge(Utils.make_json_ld_header()) |> Map.merge(Utils.make_json_ld_header())
end end
def render("event.activity-json", %{conn: %{assigns: %{object: event}}}) do def render("event.activity-json", %{conn: %{assigns: %{object: %Event{} = event}}}) do
event event
|> Converter.Event.model_to_as() |> Convertible.model_to_as()
|> Map.merge(Utils.make_json_ld_header()) |> Map.merge(Utils.make_json_ld_header())
end end
def render("comment.activity-json", %{conn: %{assigns: %{object: comment}}}) do def render("event.activity-json", %{conn: %{assigns: %{object: %Tombstone{} = event}}}) do
comment = Converter.Comment.model_to_as(comment) event
|> Convertible.model_to_as()
|> Map.merge(Utils.make_json_ld_header())
end
%{ def render("comment.activity-json", %{conn: %{assigns: %{object: %Comment{} = comment}}}) do
"actor" => comment["actor"], comment
"uuid" => comment["uuid"], |> Convertible.model_to_as()
# 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}")
}
|> Map.merge(Utils.make_json_ld_header()) |> Map.merge(Utils.make_json_ld_header())
end end
@ -74,7 +39,9 @@ defmodule MobilizonWeb.PageView do
when page in ["actor.html", "event.html", "comment.html"] do when page in ["actor.html", "event.html", "comment.html"] do
with {:ok, index_content} <- File.read(index_file_path()) do with {:ok, index_content} <- File.read(index_file_path()) do
tags = object |> Metadata.build_tags() |> MetadataUtils.stringify_tags() 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} {:safe, index_content}
end end
end end
@ -82,7 +49,9 @@ defmodule MobilizonWeb.PageView do
def render("index.html", _assigns) do def render("index.html", _assigns) do
with {:ok, index_content} <- File.read(index_file_path()) do with {:ok, index_content} <- File.read(index_file_path()) do
tags = Instance.build_tags() |> MetadataUtils.stringify_tags() 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} {:safe, index_content}
end end
end end
@ -90,4 +59,11 @@ defmodule MobilizonWeb.PageView do
defp index_file_path do defp index_file_path do
Path.join(Application.app_dir(:mobilizon, "priv/static"), "index.html") Path.join(Application.app_dir(:mobilizon, "priv/static"), "index.html")
end 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 end

View file

@ -11,7 +11,7 @@ defmodule Mobilizon.Service.ActivityPub do
import Mobilizon.Service.ActivityPub.Utils import Mobilizon.Service.ActivityPub.Utils
import Mobilizon.Service.ActivityPub.Visibility 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.Actors.{Actor, Follower}
alias Mobilizon.Events.{Comment, Event, Participant} alias Mobilizon.Events.{Comment, Event, Participant}
alias Mobilizon.Reports.Report alias Mobilizon.Reports.Report
@ -50,6 +50,15 @@ defmodule Mobilizon.Service.ActivityPub do
def fetch_object_from_url(url) do def fetch_object_from_url(url) do
Logger.info("Fetching object from url #{url}") 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")}, with {:not_http, true} <- {:not_http, String.starts_with?(url, "http")},
{:existing_event, nil} <- {:existing_event, Events.get_event_by_url(url)}, {:existing_event, nil} <- {:existing_event, Events.get_event_by_url(url)},
{:existing_comment, nil} <- {:existing_comment, Events.get_comment_from_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 <- {:ok, %{body: body, status_code: code}} when code in 200..299 <-
HTTPoison.get( HTTPoison.get(
url, url,
[Accept: "application/activity+json"], headers,
follow_redirect: true, follow_redirect: true,
timeout: 10_000, timeout: 10_000,
recv_timeout: 20_000 recv_timeout: 20_000
), ),
{:ok, data} <- Jason.decode(body), {:ok, data} <- Jason.decode(body),
{:origin_check, true} <- {:origin_check, origin_check?(url, data)},
params <- %{ params <- %{
"type" => "Create", "type" => "Create",
"to" => data["to"], "to" => data["to"],
@ -95,6 +105,10 @@ defmodule Mobilizon.Service.ActivityPub do
{:existing_actor, {:ok, %Actor{url: actor_url}}} -> {:existing_actor, {:ok, %Actor{url: actor_url}}} ->
{:ok, Actors.get_actor_by_url!(actor_url, true)} {:ok, Actors.get_actor_by_url!(actor_url, true)}
{:origin_check, false} ->
Logger.warn("Object origin check failed")
{:error, "Object origin check failed"}
e -> e ->
{:error, e} {:error, e}
end end
@ -114,9 +128,9 @@ defmodule Mobilizon.Service.ActivityPub do
{:ok, %Actor{} = actor} -> {:ok, %Actor{} = actor} ->
{:ok, actor} {:ok, actor}
_ -> err ->
Logger.warn("Could not fetch by AP id") Logger.warn("Could not fetch by AP id")
Logger.debug(inspect(err))
{:error, "Could not fetch by AP id"} {:error, "Could not fetch by AP id"}
end end
end end
@ -184,11 +198,13 @@ defmodule Mobilizon.Service.ActivityPub do
end end
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} = {:ok, entity, update_data} =
case type do case type do
:join -> accept_join(entity, args, additional) :join -> accept_join(entity, additional)
:follow -> accept_follow(entity, args, additional) :follow -> accept_follow(entity, additional)
end end
with {:ok, activity} <- create_activity(update_data, local), with {:ok, activity} <- create_activity(update_data, local),
@ -202,63 +218,24 @@ defmodule Mobilizon.Service.ActivityPub do
end end
end end
def reject(%{to: to, actor: actor, object: object} = params, activity_wrapper_id \\ nil) do def reject(type, entity, local \\ true, additional \\ %{}) do
# only accept false as false value {:ok, entity, update_data} =
local = !(params[:local] == false) case type do
:join -> reject_join(entity, additional)
:follow -> reject_follow(entity, additional)
end
with data <- %{ with {:ok, activity} <- create_activity(update_data, local),
"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),
:ok <- maybe_federate(activity) do :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
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( def announce(
%Actor{} = actor, %Actor{} = actor,
object, object,
@ -267,9 +244,10 @@ defmodule Mobilizon.Service.ActivityPub do
public \\ true public \\ true
) do ) do
with true <- is_public?(object), 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), announce_data <- make_announce_data(actor, object, activity_id, public),
{:ok, activity} <- create_activity(announce_data, local), {:ok, activity} <- create_activity(announce_data, local),
{:ok, object} <- insert_full_object(announce_data),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity, object} {:ok, activity, object}
else else
@ -288,7 +266,6 @@ defmodule Mobilizon.Service.ActivityPub do
with announce_activity <- make_announce_data(actor, object, cancelled_activity_id), with announce_activity <- make_announce_data(actor, object, cancelled_activity_id),
unannounce_data <- make_unannounce_data(actor, announce_activity, activity_id), unannounce_data <- make_unannounce_data(actor, announce_activity, activity_id),
{:ok, unannounce_activity} <- create_activity(unannounce_data, local), {:ok, unannounce_activity} <- create_activity(unannounce_data, local),
{:ok, object} <- insert_full_object(unannounce_data),
:ok <- maybe_federate(unannounce_activity) do :ok <- maybe_federate(unannounce_activity) do
{:ok, unannounce_activity, object} {:ok, unannounce_activity, object}
else else
@ -327,9 +304,8 @@ defmodule Mobilizon.Service.ActivityPub do
unfollow_data <- unfollow_data <-
make_unfollow_data(follower, followed, follow_activity, activity_unfollow_id), make_unfollow_data(follower, followed, follow_activity, activity_unfollow_id),
{:ok, activity} <- create_activity(unfollow_data, local), {:ok, activity} <- create_activity(unfollow_data, local),
{:ok, object} <- insert_full_object(unfollow_data),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity, object} {:ok, activity, follow}
else else
err -> err ->
Logger.debug("Error while unfollowing an actor #{inspect(err)}") Logger.debug("Error while unfollowing an actor #{inspect(err)}")
@ -339,6 +315,7 @@ defmodule Mobilizon.Service.ActivityPub do
def delete(object, local \\ true) 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 def delete(%Event{url: url, organizer_actor: actor} = event, local) do
data = %{ data = %{
"type" => "Delete", "type" => "Delete",
@ -348,15 +325,19 @@ defmodule Mobilizon.Service.ActivityPub do
"id" => url <> "/delete" "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} <- {:ok, %Tombstone{} = _tombstone} <-
Tombstone.create_tombstone(%{uri: event.url, actor_id: actor.id}), 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 <- maybe_federate(activity) do
{:ok, activity, event} {:ok, activity, event}
end end
end end
@spec delete(Comment.t(), boolean) :: {:ok, Activity.t(), Comment.t()}
def delete(%Comment{url: url, actor: actor} = comment, local) do def delete(%Comment{url: url, actor: actor} = comment, local) do
data = %{ data = %{
"type" => "Delete", "type" => "Delete",
@ -366,10 +347,13 @@ defmodule Mobilizon.Service.ActivityPub do
"to" => [actor.url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"] "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} <- {:ok, %Tombstone{} = _tombstone} <-
Tombstone.create_tombstone(%{uri: comment.url, actor_id: actor.id}), 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 <- maybe_federate(activity) do
{:ok, activity, comment} {:ok, activity, comment}
end end
@ -384,7 +368,7 @@ defmodule Mobilizon.Service.ActivityPub do
"to" => [url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"] "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, activity} <- create_activity(data, local),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity, actor} {:ok, activity, actor}
@ -396,6 +380,8 @@ defmodule Mobilizon.Service.ActivityPub do
{:create_report, {:ok, %Report{} = report}} <- {:create_report, {:ok, %Report{} = report}} <-
{:create_report, Reports.create_report(args)}, {:create_report, Reports.create_report(args)},
report_as_data <- Convertible.model_to_as(report), 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, activity} <- create_activity(report_as_data, local),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
Enum.each(Users.list_moderators(), fn moderator -> Enum.each(Users.list_moderators(), fn moderator ->
@ -413,52 +399,56 @@ defmodule Mobilizon.Service.ActivityPub do
end end
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 # TODO Refactor me for federation
with maximum_attendee_capacity <- with {:maximum_attendee_capacity, true} <-
Map.get(options, :maximum_attendee_capacity) || 0, {:maximum_attendee_capacity, check_attendee_capacity(event)},
{: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),
{:ok, %Participant{} = participant} <- {:ok, %Participant{} = participant} <-
Mobilizon.Events.create_participant(%{ Mobilizon.Events.create_participant(%{
role: role, role: :not_approved,
event_id: event.id, 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 <- Convertible.model_to_as(participant),
join_data <- Map.put(join_data, "to", [event.organizer_actor.url]), audience <-
join_data <- Map.put(join_data, "cc", []), Audience.calculate_to_and_cc_from_mentions(participant),
{:ok, activity} <- create_activity(join_data, local), {:ok, activity} <- create_activity(Map.merge(join_data, audience), local),
{:ok, _object} <- insert_full_object(join_data),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
if role === :participant do if event.local && Mobilizon.Events.get_default_participant_role(event) === :participant do
accept_join( accept(
:join,
participant, participant,
%{} true,
%{"actor" => event.organizer_actor.url}
) )
end else
{:ok, activity, participant} {:ok, activity, participant}
end end
end end
end
# TODO: Implement me # 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 :error
end 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) def leave(object, actor, local \\ true)
# TODO: If we want to use this for exclusion we need to have an extra field # TODO: If we want to use this for exclusion we need to have an extra field
# for the actor that excluded the participant # for the actor that excluded the participant
def leave( 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, %Actor{id: actor_id, url: actor_url} = _actor,
local local
) do ) do
@ -473,11 +463,11 @@ defmodule Mobilizon.Service.ActivityPub do
# If it's an exclusion it should be something else # If it's an exclusion it should be something else
"actor" => actor_url, "actor" => actor_url,
"object" => event_url, "object" => event_url,
"to" => [event.organizer_actor.url], "id" => "#{MobilizonWeb.Endpoint.url()}/leave/event/#{participant.id}"
"cc" => []
}, },
{:ok, activity} <- create_activity(leave_data, local), audience <-
{:ok, _object} <- insert_full_object(leave_data), Audience.calculate_to_and_cc_from_mentions(participant),
{:ok, activity} <- create_activity(Map.merge(leave_data, audience), local),
:ok <- maybe_federate(activity) do :ok <- maybe_federate(activity) do
{:ok, activity, participant} {:ok, activity, participant}
end end
@ -537,16 +527,22 @@ defmodule Mobilizon.Service.ActivityPub do
end end
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 """ @doc """
Publish an activity to all appropriated audiences inboxes Publish an activity to all appropriated audiences inboxes
""" """
@spec publish(Actor.t(), Activity.t()) :: :ok
def publish(actor, activity) do def publish(actor, activity) do
Logger.debug("Publishing an activity") Logger.debug("Publishing an activity")
Logger.debug(inspect(activity)) Logger.debug(inspect(activity))
public = is_public?(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) Logger.info(fn -> "Relaying #{activity.data["id"]} out" end)
Relay.publish(activity) Relay.publish(activity)
@ -578,15 +574,12 @@ defmodule Mobilizon.Service.ActivityPub do
end) end)
end end
defp is_delete_activity?(%Activity{data: %{"type" => "Delete"}}), do: true
defp is_delete_activity?(_), do: false
@doc """ @doc """
Publish an activity to a specific inbox Publish an activity to a specific inbox
""" """
def publish_one(%{inbox: inbox, json: json, actor: actor, id: id}) do def publish_one(%{inbox: inbox, json: json, actor: actor, id: id}) do
Logger.info("Federating #{id} to #{inbox}") 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) digest = Signature.build_digest(json)
date = Signature.generate_date_header() date = Signature.generate_date_header()
@ -594,10 +587,9 @@ defmodule Mobilizon.Service.ActivityPub do
signature = signature =
Signature.sign(actor, %{ Signature.sign(actor, %{
"(request-target)": "post #{path}",
host: host, host: host,
"content-length": byte_size(json), "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, digest: digest,
date: date date: date
}) })
@ -627,7 +619,7 @@ defmodule Mobilizon.Service.ActivityPub do
:ok <- Logger.debug("response okay, now decoding json"), :ok <- Logger.debug("response okay, now decoding json"),
{:ok, data} <- Jason.decode(body) do {:ok, data} <- Jason.decode(body) do
Logger.debug("Got activity+json response at actor's endpoint, now converting data") 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 else
# Actor is gone, probably deleted # Actor is gone, probably deleted
{:ok, %HTTPoison.Response{status_code: 410}} -> {:ok, %HTTPoison.Response{status_code: 410}} ->
@ -642,49 +634,6 @@ defmodule Mobilizon.Service.ActivityPub do
res res
end 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 """ @doc """
Return all public activities (events & comments) for an actor 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), {:ok, %Event{} = event} <- Events.create_event(args),
event_as_data <- Convertible.model_to_as(event), event_as_data <- Convertible.model_to_as(event),
audience <- audience <-
Audience.calculate_to_and_cc_from_mentions( Audience.calculate_to_and_cc_from_mentions(event),
event.organizer_actor,
args.mentions,
nil,
event.visibility
),
create_data <- create_data <-
make_create_data(event_as_data, Map.merge(audience, additional)) do make_create_data(event_as_data, Map.merge(audience, additional)) do
{:ok, event, create_data} {:ok, event, create_data}
@ -754,12 +698,7 @@ defmodule Mobilizon.Service.ActivityPub do
{:ok, %Comment{} = comment} <- Events.create_comment(args), {:ok, %Comment{} = comment} <- Events.create_comment(args),
comment_as_data <- Convertible.model_to_as(comment), comment_as_data <- Convertible.model_to_as(comment),
audience <- audience <-
Audience.calculate_to_and_cc_from_mentions( Audience.calculate_to_and_cc_from_mentions(comment),
comment.actor,
args.mentions,
args.in_reply_to_comment,
comment.visibility
),
create_data <- create_data <-
make_create_data(comment_as_data, Map.merge(audience, additional)) do make_create_data(comment_as_data, Map.merge(audience, additional)) do
{:ok, comment, create_data} {:ok, comment, create_data}
@ -771,13 +710,7 @@ defmodule Mobilizon.Service.ActivityPub do
with args <- prepare_args_for_group(args), with args <- prepare_args_for_group(args),
{:ok, %Actor{type: :Group} = group} <- Actors.create_group(args), {:ok, %Actor{type: :Group} = group} <- Actors.create_group(args),
group_as_data <- Convertible.model_to_as(group), group_as_data <- Convertible.model_to_as(group),
audience <- audience <- %{"to" => ["https://www.w3.org/ns/activitystreams#Public"], "cc" => []},
Audience.calculate_to_and_cc_from_mentions(
args.creator_actor,
[],
nil,
:public
),
create_data <- create_data <-
make_create_data(group_as_data, Map.merge(audience, additional)) do make_create_data(group_as_data, Map.merge(audience, additional)) do
{:ok, group, create_data} {:ok, group, create_data}
@ -799,12 +732,7 @@ defmodule Mobilizon.Service.ActivityPub do
{:ok, %Event{} = new_event} <- Events.update_event(old_event, args), {:ok, %Event{} = new_event} <- Events.update_event(old_event, args),
event_as_data <- Convertible.model_to_as(new_event), event_as_data <- Convertible.model_to_as(new_event),
audience <- audience <-
Audience.calculate_to_and_cc_from_mentions( Audience.calculate_to_and_cc_from_mentions(new_event),
new_event.organizer_actor,
Map.get(args, :mentions, []),
nil,
new_event.visibility
),
update_data <- make_update_data(event_as_data, Map.merge(audience, additional)) do update_data <- make_update_data(event_as_data, Map.merge(audience, additional)) do
{:ok, new_event, update_data} {:ok, new_event, update_data}
else else
@ -821,34 +749,29 @@ defmodule Mobilizon.Service.ActivityPub do
with {:ok, %Actor{} = new_actor} <- Actors.update_actor(old_actor, args), with {:ok, %Actor{} = new_actor} <- Actors.update_actor(old_actor, args),
actor_as_data <- Convertible.model_to_as(new_actor), actor_as_data <- Convertible.model_to_as(new_actor),
audience <- audience <-
Audience.calculate_to_and_cc_from_mentions( Audience.calculate_to_and_cc_from_mentions(new_actor),
new_actor,
[],
nil,
:public
),
additional <- Map.merge(additional, %{"actor" => old_actor.url}), additional <- Map.merge(additional, %{"actor" => old_actor.url}),
update_data <- make_update_data(actor_as_data, Map.merge(audience, additional)) do update_data <- make_update_data(actor_as_data, Map.merge(audience, additional)) do
{:ok, new_actor, update_data} {:ok, new_actor, update_data}
end end
end end
@spec accept_follow(Follower.t(), map(), map()) :: @spec accept_follow(Follower.t(), map()) ::
{:ok, Follower.t(), Activity.t()} | any() {:ok, Follower.t(), Activity.t()} | any()
defp accept_follow( defp accept_follow(
%Follower{} = follower, %Follower{} = follower,
args,
additional additional
) do ) 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), follower_as_data <- Convertible.model_to_as(follower),
audience <-
Audience.calculate_to_and_cc_from_mentions(follower.target_actor),
update_data <- update_data <-
make_update_data( make_accept_join_data(
follower_as_data, follower_as_data,
Map.merge(Map.merge(audience, additional), %{ Map.merge(additional, %{
"id" => "#{MobilizonWeb.Endpoint.url()}/accept/follow/#{follower.id}" "id" => "#{MobilizonWeb.Endpoint.url()}/accept/follow/#{follower.id}",
"to" => [follower.actor.url],
"cc" => [],
"actor" => follower.target_actor.url
}) })
) do ) do
{:ok, follower, update_data} {:ok, follower, update_data}
@ -860,17 +783,20 @@ defmodule Mobilizon.Service.ActivityPub do
end end
end end
@spec accept_join(Participant.t(), map(), map()) :: @spec accept_join(Participant.t(), map()) ::
{:ok, Participant.t(), Activity.t()} | any() {:ok, Participant.t(), Activity.t()} | any()
defp accept_join( defp accept_join(
%Participant{} = participant, %Participant{} = participant,
args, additional
additional \\ %{}
) do ) 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), participant_as_data <- Convertible.model_to_as(participant),
audience <- audience <-
Audience.calculate_to_and_cc_from_mentions(participant.actor), Audience.calculate_to_and_cc_from_mentions(participant),
update_data <- update_data <-
make_accept_join_data( make_accept_join_data(
participant_as_data, participant_as_data,
@ -887,6 +813,66 @@ defmodule Mobilizon.Service.ActivityPub do
end end
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 # Prepare and sanitize arguments for events
defp prepare_args_for_event(args) do defp prepare_args_for_event(args) do
# If title is not set: we are not updating it # 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 # Prepare and sanitize arguments for comments
defp prepare_args_for_comment(args) do defp prepare_args_for_comment(args) do
with in_reply_to_comment <- 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), args <- Map.update(args, :visibility, :public, & &1),
{text, mentions, tags} <- {text, mentions, tags} <-
APIUtils.make_content_html( APIUtils.make_content_html(
@ -940,6 +927,7 @@ defmodule Mobilizon.Service.ActivityPub do
text: text, text: text,
mentions: mentions, mentions: mentions,
tags: tags, tags: tags,
event: event,
in_reply_to_comment: in_reply_to_comment, in_reply_to_comment: in_reply_to_comment,
in_reply_to_comment_id: in_reply_to_comment_id:
if(is_nil(in_reply_to_comment), do: nil, else: Map.get(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
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 defp prepare_args_for_group(args) do
with preferred_username <- with preferred_username <-
args |> Map.get(:preferred_username) |> HtmlSanitizeEx.strip_tags() |> String.trim(), args |> Map.get(:preferred_username) |> HtmlSanitizeEx.strip_tags() |> String.trim(),

View file

@ -2,7 +2,13 @@ defmodule Mobilizon.Service.ActivityPub.Audience do
@moduledoc """ @moduledoc """
Tools for calculating content audience Tools for calculating content audience
""" """
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor 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" @ap_public "https://www.w3.org/ns/activitystreams#Public"
@ -13,36 +19,28 @@ defmodule Mobilizon.Service.ActivityPub.Audience do
* `to` : the mentioned actors, the eventual actor we're replying to and the public * `to` : the mentioned actors, the eventual actor we're replying to and the public
* `cc` : the actor's followers * `cc` : the actor's followers
""" """
@spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()} @spec get_to_and_cc(Actor.t(), list(), String.t()) :: {list(), list()}
def get_to_and_cc(%Actor{} = actor, mentions, in_reply_to, :public) do def get_to_and_cc(%Actor{} = actor, mentions, :public) do
to = [@ap_public | mentions] to = [@ap_public | mentions]
cc = [actor.followers_url] cc = [actor.followers_url]
if in_reply_to do
{Enum.uniq([in_reply_to.actor | to]), cc}
else
{to, cc} {to, cc}
end end
end
@doc """ @doc """
Determines the full audience based on mentions based on a unlisted audience Determines the full audience based on mentions based on a unlisted audience
Audience is: 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 * `cc` : public
""" """
@spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()} @spec get_to_and_cc(Actor.t(), list(), String.t()) :: {list(), list()}
def get_to_and_cc(%Actor{} = actor, mentions, in_reply_to, :unlisted) do def get_to_and_cc(%Actor{} = actor, mentions, :unlisted) do
to = [actor.followers_url | mentions] to = [actor.followers_url | mentions]
cc = [@ap_public] cc = [@ap_public]
if in_reply_to do
{Enum.uniq([in_reply_to.actor | to]), cc}
else
{to, cc} {to, cc}
end end
end
@doc """ @doc """
Determines the full audience based on mentions based on a private audience Determines the full audience based on mentions based on a private audience
@ -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 * `to` : the mentioned actors, actor's followers and the eventual actor we're replying to
* `cc` : none * `cc` : none
""" """
@spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()} @spec get_to_and_cc(Actor.t(), list(), String.t()) :: {list(), list()}
def get_to_and_cc(%Actor{} = actor, mentions, in_reply_to, :private) do def get_to_and_cc(%Actor{} = actor, mentions, :private) do
{to, cc} = get_to_and_cc(actor, mentions, in_reply_to, :direct) {to, cc} = get_to_and_cc(actor, mentions, :direct)
{[actor.followers_url | to], cc} {[actor.followers_url | to], cc}
end end
@ -64,16 +62,12 @@ defmodule Mobilizon.Service.ActivityPub.Audience do
* `to` : the mentioned actors and the eventual actor we're replying to * `to` : the mentioned actors and the eventual actor we're replying to
* `cc` : none * `cc` : none
""" """
@spec get_to_and_cc(Actor.t(), list(), map(), String.t()) :: {list(), list()} @spec get_to_and_cc(Actor.t(), list(), String.t()) :: {list(), list()}
def get_to_and_cc(_actor, mentions, in_reply_to, :direct) do def get_to_and_cc(_actor, mentions, :direct) do
if in_reply_to do
{Enum.uniq([in_reply_to.actor | mentions]), []}
else
{mentions, []} {mentions, []}
end end
end
def get_to_and_cc(_actor, mentions, _in_reply_to, {:list, _}) do def get_to_and_cc(_actor, mentions, {:list, _}) do
{mentions, []} {mentions, []}
end end
@ -83,16 +77,109 @@ defmodule Mobilizon.Service.ActivityPub.Audience do
def get_addressed_actors(mentioned_users, _), do: mentioned_users def get_addressed_actors(mentioned_users, _), do: mentioned_users
def calculate_to_and_cc_from_mentions( def calculate_to_and_cc_from_mentions(%Comment{} = comment) do
actor, with mentioned_actors <- Enum.map(comment.mentions, &process_mention/1),
mentions \\ [],
in_reply_to \\ nil,
visibility \\ :public
) do
with mentioned_actors <- for({_, mentioned_actor} <- mentions, do: mentioned_actor.url),
addressed_actors <- get_addressed_actors(mentioned_actors, nil), 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} %{"to" => to, "cc" => cc}
end end
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 end

View file

@ -7,7 +7,7 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Actor do
""" """
alias Mobilizon.Actors.Actor, as: ActorModel alias Mobilizon.Actors.Actor, as: ActorModel
alias Mobilizon.Service.ActivityPub.{Converter, Convertible} alias Mobilizon.Service.ActivityPub.{Converter, Convertible, Utils}
@behaviour Converter @behaviour Converter
@ -22,33 +22,40 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Actor do
""" """
@impl Converter @impl Converter
@spec as_to_model_data(map) :: map @spec as_to_model_data(map) :: map
def as_to_model_data(object) do def as_to_model_data(data) do
avatar = avatar =
object["icon"]["url"] && data["icon"]["url"] &&
%{ %{
"name" => object["icon"]["name"] || "avatar", "name" => data["icon"]["name"] || "avatar",
"url" => object["icon"]["url"] "url" => MobilizonWeb.MediaProxy.url(data["icon"]["url"])
} }
banner = banner =
object["image"]["url"] && data["image"]["url"] &&
%{ %{
"name" => object["image"]["name"] || "banner", "name" => data["image"]["name"] || "banner",
"url" => object["image"]["url"] "url" => MobilizonWeb.MediaProxy.url(data["image"]["url"])
} }
{:ok, actor_data = %{
%{ url: data["id"],
"type" => String.to_existing_atom(object["type"]), avatar: avatar,
"preferred_username" => object["preferredUsername"], banner: banner,
"summary" => object["summary"], name: data["name"],
"url" => object["id"], preferred_username: data["preferredUsername"],
"name" => object["name"], summary: data["summary"],
"avatar" => avatar, keys: data["publicKey"]["publicKeyPem"],
"banner" => banner, inbox_url: data["inbox"],
"keys" => object["publicKey"]["publicKeyPem"], outbox_url: data["outbox"],
"manually_approves_followers" => object["manuallyApprovesFollowers"] 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 end
@doc """ @doc """
@ -57,18 +64,51 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Actor do
@impl Converter @impl Converter
@spec model_to_as(ActorModel.t()) :: map @spec model_to_as(ActorModel.t()) :: map
def model_to_as(%ActorModel{} = actor) do def model_to_as(%ActorModel{} = actor) do
%{ actor_data = %{
"type" => Atom.to_string(actor.type), "id" => actor.url,
"to" => ["https://www.w3.org/ns/activitystreams#Public"], "type" => actor.type,
"preferred_username" => actor.preferred_username, "preferredUsername" => actor.preferred_username,
"name" => actor.name, "name" => actor.name,
"summary" => actor.summary, "summary" => actor.summary,
"following" => ActorModel.build_url(actor.preferred_username, :following), "following" => actor.following_url,
"followers" => ActorModel.build_url(actor.preferred_username, :followers), "followers" => actor.followers_url,
"inbox" => ActorModel.build_url(actor.preferred_username, :inbox), "inbox" => actor.inbox_url,
"outbox" => ActorModel.build_url(actor.preferred_username, :outbox), "outbox" => actor.outbox_url,
"id" => ActorModel.build_url(actor.preferred_username, :page), "url" => actor.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
end end

View file

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

View file

@ -6,14 +6,13 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Event do
internal one, and back. internal one, and back.
""" """
alias Mobilizon.{Addresses, Media} alias Mobilizon.Addresses
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Addresses.Address alias Mobilizon.Addresses.Address
alias Mobilizon.Events.Event, as: EventModel alias Mobilizon.Events.Event, as: EventModel
alias Mobilizon.Events.EventOptions
alias Mobilizon.Media.Picture alias Mobilizon.Media.Picture
alias Mobilizon.Service.ActivityPub 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.Address, as: AddressConverter
alias Mobilizon.Service.ActivityPub.Converter.Picture, as: PictureConverter alias Mobilizon.Service.ActivityPub.Converter.Picture, as: PictureConverter
alias Mobilizon.Service.ActivityPub.Converter.Utils, as: ConverterUtils 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("event as_to_model_data")
Logger.debug(inspect(object)) Logger.debug(inspect(object))
with {:actor, {:ok, %Actor{id: actor_id}}} <- with author_url <- Map.get(object, "actor") || Map.get(object, "attributedTo"),
{:actor, ActivityPub.get_or_fetch_actor_by_url(object["actor"])}, {:actor, {:ok, %Actor{id: actor_id, domain: actor_domain}}} <-
{:actor, ActivityPub.get_or_fetch_actor_by_url(author_url)},
{:address, address_id} <- {:address, address_id} <-
{:address, get_address(object["location"])}, {:address, get_address(object["location"])},
{:tags, tags} <- {:tags, ConverterUtils.fetch_tags(object["tag"])}, {:tags, tags} <- {:tags, ConverterUtils.fetch_tags(object["tag"])},
{:mentions, mentions} <- {:mentions, ConverterUtils.fetch_mentions(object["tag"])},
{:visibility, visibility} <- {:visibility, get_visibility(object)}, {:visibility, visibility} <- {:visibility, get_visibility(object)},
{:options, options} <- {:options, get_options(object)} do {:options, options} <- {:options, get_options(object)} do
picture_id = picture_id =
with true <- Map.has_key?(object, "attachment") && length(object["attachment"]) > 0, with true <- Map.has_key?(object, "attachment") && length(object["attachment"]) > 0,
%Picture{id: picture_id} <- {:ok, %Picture{id: picture_id}} <-
Media.get_picture_by_url(
object["attachment"] object["attachment"]
|> hd |> hd
|> Map.get("url") |> PictureConverter.find_or_create_picture(actor_id) do
|> hd
|> Map.get("href")
) do
picture_id picture_id
else else
_ -> nil _err ->
nil
end end
entity = %{ entity = %{
@ -68,16 +66,20 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Event do
ends_on: object["endTime"], ends_on: object["endTime"],
category: object["category"], category: object["category"],
visibility: visibility, visibility: visibility,
join_options: Map.get(object, "joinOptions", "free"), join_options: Map.get(object, "joinMode", "free"),
local: is_nil(actor_domain),
options: options, options: options,
status: object["status"], status: object |> Map.get("ical:status", "CONFIRMED") |> String.downcase(),
online_address: object["onlineAddress"], online_address: object["onlineAddress"],
phone_address: object["phoneAddress"], phone_address: object["phoneAddress"],
draft: object["draft"] || false, draft: false,
url: object["id"], url: object["id"],
uuid: object["uuid"], uuid: object["uuid"],
tags: tags, tags: tags,
physical_address_id: address_id mentions: mentions,
physical_address_id: address_id,
updated_at: object["updated"],
publish_at: object["published"]
} }
{:ok, entity} {:ok, entity}
@ -108,14 +110,17 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Event do
"uuid" => event.uuid, "uuid" => event.uuid,
"category" => event.category, "category" => event.category,
"content" => event.description, "content" => event.description,
"publish_at" => (event.publish_at || event.inserted_at) |> date_to_string(), "published" => (event.publish_at || event.inserted_at) |> date_to_string(),
"updated_at" => event.updated_at |> date_to_string(), "updated" => event.updated_at |> date_to_string(),
"mediaType" => "text/html", "mediaType" => "text/html",
"startTime" => event.begins_on |> date_to_string(), "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(), "endTime" => event.ends_on |> date_to_string(),
"tag" => event.tags |> ConverterUtils.build_tags(), "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, "id" => event.url,
"url" => event.url "url" => event.url
} }
@ -133,17 +138,10 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Event do
# Get only elements that we have in EventOptions # Get only elements that we have in EventOptions
@spec get_options(map) :: map @spec get_options(map) :: map
defp get_options(object) do defp get_options(object) do
keys = %{
EventOptions maximum_attendee_capacity: object["maximumAttendeeCapacity"],
|> struct comment_moderation: object["repliesModerationOption"]
|> 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)
end end
@spec get_address(map | binary | nil) :: integer | nil @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" @ap_public "https://www.w3.org/ns/activitystreams#Public"
defp get_visibility(object) do defp get_visibility(object), do: if(@ap_public in object["to"], do: :public, else: :unlisted)
cond do
@ap_public in object["to"] -> :public
@ap_public in object["cc"] -> :unlisted
true -> :private
end
end
@spec date_to_string(DateTime.t() | nil) :: String.t() @spec date_to_string(DateTime.t() | nil) :: String.t()
defp date_to_string(nil), do: nil 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.Reports.Report
alias Mobilizon.Service.ActivityPub.Converter alias Mobilizon.Service.ActivityPub.Converter
alias Mobilizon.Service.ActivityPub.Convertible alias Mobilizon.Service.ActivityPub.Convertible
alias Mobilizon.Service.ActivityPub.Relay
@behaviour Converter @behaviour Converter
@ -42,8 +43,6 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Flag do
end end
end end
@audience %{"to" => ["https://www.w3.org/ns/activitystreams#Public"], "cc" => []}
@doc """ @doc """
Convert an event struct to an ActivityStream representation 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 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", "type" => "Flag",
"actor" => report.reporter.url, "actor" => Relay.get_actor().url,
"id" => report.url, "id" => report.url,
"content" => report.content, "content" => report.content,
"object" => object "object" => object
} }
|> Map.merge(audience)
end end
@spec as_to_model(map) :: map @spec as_to_model(map) :: map
@ -91,7 +86,7 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Flag do
end end
end), end),
# Remove the reported user from the object list. # Remove the reported actor and the event from the object list.
comments <- comments <-
Enum.filter(objects, fn url -> Enum.filter(objects, fn url ->
!(url == reported.url || (!is_nil(event) && event.url == 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 def model_to_as(%PictureModel{file: file}) do
%{ %{
"type" => "Document", "type" => "Document",
"url" => [
%{
"type" => "Link",
"mediaType" => file.content_type, "mediaType" => file.content_type,
"href" => file.url "url" => file.url,
}
],
"name" => file.name "name" => file.name
} }
end 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 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()] @spec fetch_tags([String.t()]) :: [Tag.t()]
def fetch_tags(tags) when is_list(tags) do def fetch_tags(tags) when is_list(tags) do
Logger.debug("fetching tags") Logger.debug("fetching tags")
Logger.debug(inspect(tags))
tags |> Enum.flat_map(&fetch_tag/1) |> Enum.uniq() |> Enum.map(&existing_tag_or_data/1) tags |> Enum.flat_map(&fetch_tag/1) |> Enum.uniq() |> Enum.map(&existing_tag_or_data/1)
end end
@ -64,6 +65,8 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Utils do
} }
end end
defp fetch_tag(%{title: title}), do: [title]
defp fetch_tag(tag) when is_map(tag) do defp fetch_tag(tag) when is_map(tag) do
case tag["type"] do case tag["type"] do
"Hashtag" -> "Hashtag" ->

View file

@ -9,27 +9,37 @@ defmodule Mobilizon.Service.ActivityPub.Relay do
""" """
alias Mobilizon.Actors alias Mobilizon.Actors
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.{Actor, Follower}
alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub
alias Mobilizon.Service.ActivityPub.{Activity, Transmogrifier} alias Mobilizon.Service.ActivityPub.{Activity, Transmogrifier}
alias Mobilizon.Service.WebFinger
alias MobilizonWeb.API.Follows alias MobilizonWeb.API.Follows
require Logger 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 def get_actor do
with {:ok, %Actor{} = actor} <- 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 actor
end end
end end
def follow(target_instance) do @spec follow(String.t()) :: {:ok, Activity.t(), Follower.t()}
with %Actor{} = local_actor <- get_actor(), 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, %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"]}") Logger.info("Relay: followed instance #{target_instance}; id=#{activity.data["id"]}")
{:ok, activity} {:ok, activity, follow}
else else
e -> e ->
Logger.warn("Error while following remote instance: #{inspect(e)}") Logger.warn("Error while following remote instance: #{inspect(e)}")
@ -37,12 +47,14 @@ defmodule Mobilizon.Service.ActivityPub.Relay do
end end
end end
def unfollow(target_instance) do @spec unfollow(String.t()) :: {:ok, Activity.t(), Follower.t()}
with %Actor{} = local_actor <- get_actor(), 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, %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"]}") Logger.info("Relay: unfollowed instance #{target_instance}: id=#{activity.data["id"]}")
{:ok, activity} {:ok, activity, follow}
else else
e -> e ->
Logger.warn("Error while unfollowing remote instance: #{inspect(e)}") Logger.warn("Error while unfollowing remote instance: #{inspect(e)}")
@ -50,30 +62,38 @@ defmodule Mobilizon.Service.ActivityPub.Relay do
end end
end end
def accept(target_instance) do @spec accept(String.t()) :: {:ok, Activity.t(), Follower.t()}
with %Actor{} = local_actor <- get_actor(), 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, %Actor{} = target_actor} <- ActivityPub.get_or_fetch_actor_by_url(target_instance),
{:ok, activity} <- Follows.accept(target_actor, local_actor) do {:ok, activity, follow} <- Follows.accept(target_actor, local_actor) do
{:ok, activity} {:ok, activity, follow}
end end
end end
# def reject(target_instance) do def reject(address) do
# with %Actor{} = local_actor <- get_actor(), Logger.debug("We're trying to reject a relay subscription")
# {:ok, %Actor{} = target_actor} <- Activity.get_or_fetch_actor_by_url(target_instance),
# {:ok, activity} <- Follows.reject(target_actor, local_actor) do with {:ok, target_instance} <- fetch_actor(address),
# {:ok, activity} %Actor{} = local_actor <- get_actor(),
# end {:ok, %Actor{} = target_actor} <- ActivityPub.get_or_fetch_actor_by_url(target_instance),
# end {:ok, activity, follow} <- Follows.reject(target_actor, local_actor) do
{:ok, activity, follow}
end
end
@doc """ @doc """
Publish an activity to all relays following this instance Publish an activity to all relays following this instance
""" """
def publish(%Activity{data: %{"object" => object}} = _activity) do def publish(%Activity{data: %{"object" => object}} = _activity) do
with %Actor{id: actor_id} = actor <- get_actor(), with %Actor{id: actor_id} = actor <- get_actor(),
{:ok, object} <- {object, object_id} <- fetch_object(object),
Transmogrifier.fetch_obj_helper_as_activity_streams(object) do id <- "#{object_id}/announces/#{actor_id}" do
ActivityPub.announce(actor, object, "#{object["id"]}/announces/#{actor_id}", true, false) Logger.info("Publishing activity #{id} to all relays")
ActivityPub.announce(actor, object, id, true, false)
else else
e -> e ->
Logger.error("Error while getting local instance actor: #{inspect(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)) Logger.debug(inspect(err))
nil nil
end 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 end

View file

@ -20,108 +20,6 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
require Logger 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" => nil}), do: :error
def handle_incoming(%{"id" => ""}), do: :error def handle_incoming(%{"id" => ""}), do: :error
@ -135,6 +33,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
additional: %{ additional: %{
"cc" => [params["reported"].url] "cc" => [params["reported"].url]
}, },
event_id: if(is_nil(params["event"]), do: nil, else: params["event"].id || nil),
local: false local: false
} }
@ -158,7 +57,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
Logger.info("Handle incoming to create notes") Logger.info("Handle incoming to create notes")
with {:ok, object_data} <- 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, {:error, :comment_not_found}} <-
{:existing_comment, Events.get_comment_from_url_with_preload(object_data.url)}, {:existing_comment, Events.get_comment_from_url_with_preload(object_data.url)},
{:ok, %Activity{} = activity, %Comment{} = comment} <- {:ok, %Activity{} = activity, %Comment{} = comment} <-
@ -186,7 +85,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
Logger.info("Handle incoming to create event") Logger.info("Handle incoming to create event")
with {:ok, object_data} <- 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)}, {:existing_event, nil} <- {:existing_event, Events.get_event_by_url(object_data.url)},
{:ok, %Activity{} = activity, %Event{} = event} <- {:ok, %Activity{} = activity, %Event{} = event} <-
ActivityPub.create(:event, object_data, false) do ActivityPub.create(:event, object_data, false) do
@ -273,36 +172,25 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
end end
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( def handle_incoming(
%{"type" => "Announce", "object" => object_id, "actor" => _actor, "id" => _id} = data %{"type" => "Announce", "object" => object, "actor" => _actor, "id" => _id} = data
) do ) do
with actor <- get_actor(data), with actor <- get_actor(data),
# TODO: Is the following line useful? # 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 <- 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"), :ok <- Logger.debug("Handling contained object"),
create_data <- create_data <-
make_create_data(object), make_create_data(object),
:ok <- Logger.debug(inspect(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 <- Logger.debug("Finished processing contained object"),
{:ok, activity} <- ActivityPub.create_activity(data, false) do {:ok, activity} <- ActivityPub.create_activity(data, false),
{:ok, activity, object} {: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 else
e -> e ->
Logger.debug(inspect(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 when object_type in ["Person", "Group", "Application", "Service", "Organization"] do
with {:ok, %Actor{} = old_actor} <- Actors.get_actor_by_url(object["id"]), with {:ok, %Actor{} = old_actor} <- Actors.get_actor_by_url(object["id"]),
{:ok, object_data} <- {: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} <- {:ok, %Activity{} = activity, %Actor{} = new_actor} <-
ActivityPub.update(:actor, old_actor, object_data, false) do ActivityPub.update(:actor, old_actor, object_data, false) do
{:ok, activity, new_actor} {:ok, activity, new_actor}
@ -331,12 +219,15 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
def handle_incoming( def handle_incoming(
%{"type" => "Update", "object" => %{"type" => "Event"} = object, "actor" => _actor} = %{"type" => "Update", "object" => %{"type" => "Event"} = object, "actor" => _actor} =
_update update_data
) do ) do
with %Event{} = old_event <- with actor <- get_actor(update_data),
Events.get_event_by_url(object["id"]), {: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} <- {: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} <- {:ok, %Activity{} = activity, %Event{} = new_event} <-
ActivityPub.update(:event, old_event, object_data, false) do ActivityPub.update(:event, old_event, object_data, false) do
{:ok, activity, new_event} {:ok, activity, new_event}
@ -396,16 +287,18 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
def handle_incoming( def handle_incoming(
%{"type" => "Delete", "object" => object, "actor" => _actor, "id" => _id} = data %{"type" => "Delete", "object" => object, "actor" => _actor, "id" => _id} = data
) do ) do
object_id = Utils.get_url(object)
with actor <- get_actor(data), with actor <- get_actor(data),
{:ok, %Actor{url: _actor_url}} <- Actors.get_actor_by_url(actor), {:ok, %Actor{url: actor_url}} <- Actors.get_actor_by_url(actor),
{:ok, object} <- fetch_obj_helper(object_id), object_id <- Utils.get_url(object),
# TODO : Validate that DELETE comes indeed form right domain (see above) {:origin_check, true} <- {:origin_check, origin_check_from_id?(actor_url, object_id)},
# :ok <- contain_origin(actor_url, object.data), {:ok, object} <- ActivityPub.fetch_object_from_url(object_id),
{:ok, activity, object} <- ActivityPub.delete(object, false) do {:ok, activity, object} <- ActivityPub.delete(object, false) do
{:ok, activity, object} {:ok, activity, object}
else else
{:origin_check, false} ->
Logger.warn("Object origin check failed")
:error
e -> e ->
Logger.debug(inspect(e)) Logger.debug(inspect(e))
:error :error
@ -413,12 +306,13 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
end end
def handle_incoming( def handle_incoming(
%{"type" => "Join", "object" => object, "actor" => _actor, "id" => _id} = data %{"type" => "Join", "object" => object, "actor" => _actor, "id" => id} = data
) do ) do
with actor <- get_actor(data), with actor <- get_actor(data),
{:ok, %Actor{url: _actor_url} = actor} <- Actors.get_actor_by_url(actor), {:ok, %Actor{url: _actor_url} = actor} <- Actors.get_actor_by_url(actor),
{:ok, object} <- fetch_obj_helper(object), object <- Utils.get_url(object),
{:ok, activity, object} <- ActivityPub.join(object, actor, false) do {:ok, object} <- ActivityPub.fetch_object_from_url(object),
{:ok, activity, object} <- ActivityPub.join(object, actor, false, %{url: id}) do
{:ok, activity, object} {:ok, activity, object}
else else
e -> e ->
@ -432,7 +326,8 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
) do ) do
with actor <- get_actor(data), with actor <- get_actor(data),
{:ok, %Actor{} = actor} <- Actors.get_actor_by_url(actor), {: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} <- ActivityPub.leave(object, actor, false) do
{:ok, activity, object} {:ok, activity, object}
else else
@ -487,7 +382,6 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
ActivityPub.accept( ActivityPub.accept(
:follow, :follow,
follow, follow,
%{approved: true},
false false
) do ) do
{:ok, activity, follow} {:ok, activity, follow}
@ -511,23 +405,11 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
Handle incoming `Reject` activities wrapping a `Follow` activity Handle incoming `Reject` activities wrapping a `Follow` activity
""" """
def do_handle_incoming_reject_following(follow_object, %Actor{} = actor) do def do_handle_incoming_reject_following(follow_object, %Actor{} = actor) do
with {:follow, with {:follow, {:ok, %Follower{approved: false, target_actor: followed} = follow}} <-
{:ok,
%Follower{approved: false, actor: follower, id: follow_id, target_actor: followed} =
follow}} <-
{:follow, get_follow(follow_object)}, {:follow, get_follow(follow_object)},
{:same_actor, true} <- {:same_actor, actor.id == followed.id}, {:same_actor, true} <- {:same_actor, actor.id == followed.id},
{:ok, activity, _} <- {:ok, activity, _} <-
ActivityPub.reject( ActivityPub.reject(:follow, follow) do
%{
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
{:ok, activity, follow} {:ok, activity, follow}
else else
{:follow, _} -> {:follow, _} ->
@ -547,7 +429,8 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
# Handle incoming `Accept` activities wrapping a `Join` activity on an event # Handle incoming `Accept` activities wrapping a `Join` activity on an event
defp do_handle_incoming_accept_join(join_object, %Actor{} = actor_accepting) do 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)}, {:join_event, get_participant(join_object)},
# TODO: The actor that accepts the Join activity may another one that the event organizer ? # 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 # 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( ActivityPub.accept(
:join, :join,
participant, participant,
%{role: :participant},
false false
), ),
:ok <- :ok <-
@ -587,32 +469,20 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
# Handle incoming `Reject` activities wrapping a `Join` activity on an event # Handle incoming `Reject` activities wrapping a `Join` activity on an event
defp do_handle_incoming_reject_join(join_object, %Actor{} = actor_accepting) do defp do_handle_incoming_reject_join(join_object, %Actor{} = actor_accepting) do
with {:join_event, with {:join_event, {:ok, %Participant{event: event, role: role} = participant}}
{:ok, when role != :rejected <-
%Participant{role: :not_approved, actor: actor, id: join_id, event: event} =
participant}} <-
{:join_event, get_participant(join_object)}, {:join_event, get_participant(join_object)},
# TODO: The actor that accepts the Join activity may another one that the event organizer ? # 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 # 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}, {:same_actor, true} <- {:same_actor, actor_accepting.id == event.organizer_actor_id},
{:ok, activity, _} <- {:ok, activity, participant} <-
ActivityPub.reject( ActivityPub.reject(:join, participant, false),
%{
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 <- Participation.send_emails_to_local_user(participant) do :ok <- Participation.send_emails_to_local_user(participant) do
{:ok, activity, participant} {:ok, activity, participant}
else else
{:join_event, {:ok, %Participant{role: :participant}}} -> {:join_event, {:ok, %Participant{role: :rejected}}} ->
Logger.debug( Logger.warn(
"Tried to handle an Reject activity on a Join activity with a event object but the participant is already validated" "Tried to handle an Reject activity on a Join activity with a event object but the participant is already rejected"
) )
nil nil
@ -662,49 +532,6 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
end end
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 def prepare_outgoing(%{"type" => _type} = data) do
data = data =
data data
@ -713,145 +540,6 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
{:ok, data} {:ok, data}
end 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() @spec fetch_obj_helper(map() | String.t()) :: Event.t() | Comment.t() | Actor.t() | any()
def fetch_obj_helper(object) do def fetch_obj_helper(object) do
Logger.debug("fetch_obj_helper") Logger.debug("fetch_obj_helper")
@ -862,7 +550,7 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
{:ok, object} {:ok, object}
err -> err ->
Logger.info("Error while fetching #{inspect(object)}") Logger.warn("Error while fetching #{inspect(object)}")
{:error, err} {:error, err}
end end
end end

View file

@ -8,20 +8,11 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
# Various ActivityPub related utils. # Various ActivityPub related utils.
""" """
alias Ecto.Changeset alias Mobilizon.Actors
alias Mobilizon.{Actors, Addresses, Events, Reports, Users}
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Addresses.Address
alias Mobilizon.Events.{Comment, Event}
alias Mobilizon.Media.Picture alias Mobilizon.Media.Picture
alias Mobilizon.Reports.Report
alias Mobilizon.Service.ActivityPub.{Activity, Converter} alias Mobilizon.Service.ActivityPub.{Activity, Converter}
alias Mobilizon.Service.Federator alias Mobilizon.Service.Federator
alias Mobilizon.Storage.Repo
alias MobilizonWeb.{Email, Endpoint}
alias MobilizonWeb.Router.Helpers, as: Routes
require Logger require Logger
@ -37,12 +28,31 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
%{ %{
"@context" => [ "@context" => [
"https://www.w3.org/ns/activitystreams", "https://www.w3.org/ns/activitystreams",
"https://litepub.github.io/litepub/context.jsonld", "https://litepub.social/litepub/context.jsonld",
%{ %{
"sc" => "http://schema.org#", "sc" => "http://schema.org#",
"ical" => "http://www.w3.org/2002/12/cal/ical#",
"Hashtag" => "as:Hashtag", "Hashtag" => "as:Hashtag",
"category" => "sc:category", "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) Map.put_new_lazy(map, "published", &make_date/0)
end end
@doc """ def get_actor(%{"actor" => actor}) when is_binary(actor) do
Inserts a full object if it is contained in an activity. actor
"""
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
end end
def insert_full_object(%{"object" => %{"type" => "Group"} = object_data, "type" => "Create"}) def get_actor(%{"actor" => actor}) when is_list(actor) do
when is_map(object_data) do if is_binary(Enum.at(actor, 0)) do
with object_data <- Enum.at(actor, 0)
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}
else else
err -> actor
Logger.error("Error while inserting a remote comment inside database") |> Enum.find(fn %{"type" => type} -> type in ["Person", "Service", "Application"] end)
Logger.debug(inspect(err)) |> Map.get("id")
{:error, err}
end end
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 """ @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) def origin_check?(id, %{"actor" => actor} = params) when not is_nil(actor) do
when is_map(object_data) do id_uri = URI.parse(id)
with data <- Converter.Flag.as_to_model_data(object_data), actor_uri = URI.parse(get_actor(params))
{: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)
{:ok, report} compare_uris?(actor_uri, id_uri)
else
err ->
Logger.error("Error while inserting report inside database")
Logger.debug(inspect(err))
{:error, err}
end
end end
def insert_full_object(_), do: {:ok, nil} def origin_check?(_id, %{"actor" => nil}), do: false
@doc """ def origin_check?(id, %{"attributedTo" => actor} = params),
Update an object do: origin_check?(id, Map.put(params, "actor", actor))
"""
@spec update_object(struct(), map()) :: {:ok, struct()} | any()
def update_object(object, object_data)
def update_object(event_url, %{ def origin_check?(_id, _data), do: false
"object" => %{"type" => "Event"} = object_data,
"type" => "Update" defp compare_uris?(%URI{} = id_uri, %URI{} = other_uri), do: id_uri.host == other_uri.host
})
when is_map(object_data) do def origin_check_from_id?(id, other_id) when is_binary(other_id) do
with {:event_not_found, %Event{} = event} <- id_uri = URI.parse(id)
{:event_not_found, Events.get_event_by_url(event_url)}, other_uri = URI.parse(other_id)
{:ok, object_data} <- Converter.Event.as_to_model_data(object_data),
{:ok, %Event{} = event} <- Events.update_event(event, object_data) do compare_uris?(id_uri, other_uri)
{:ok, event}
end
end end
def update_object(actor_url, %{ def origin_check_from_id?(id, %{"id" => other_id} = _params) when is_binary(other_id),
"object" => %{"type" => type_actor} = object_data, do: origin_check_from_id?(id, other_id)
"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
@doc """ @doc """
Save picture data from %Plug.Upload{} and return AS Link data. 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 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 """ @doc """
Make announce activity data for the given actor and object Make announce activity data for the given actor and object
""" """
@ -673,42 +362,6 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
|> Map.merge(additional) |> Map.merge(additional)
end 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 """ @doc """
Make accept join activity data Make accept join activity data
""" """
@ -718,7 +371,6 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
"type" => "Accept", "type" => "Accept",
"to" => object["to"], "to" => object["to"],
"cc" => object["cc"], "cc" => object["cc"],
"actor" => object["actor"],
"object" => object, "object" => object,
"id" => object["id"] <> "/activity" "id" => object["id"] <> "/activity"
} }
@ -741,37 +393,39 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
end end
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 def pem_to_public_key_pem(pem) do
public_key = pem_to_public_key(pem) public_key = pem_to_public_key(pem)
public_key = :public_key.pem_entry_encode(:RSAPublicKey, public_key) public_key = :public_key.pem_entry_encode(:RSAPublicKey, public_key)
:public_key.pem_encode([public_key]) :public_key.pem_encode([public_key])
end end
def camelize(word) when is_atom(word) do defp make_signature(id, date) do
camelize(to_string(word)) 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 end
def camelize(word) when is_bitstring(word) do def sign_fetch(headers, id, date) do
{first, rest} = String.split_at(Macro.camelize(word), 1) if Mobilizon.Config.get([:activitypub, :sign_object_fetches]) do
String.downcase(first) <> rest headers ++ make_signature(id, date)
else
headers
end
end end
def underscore(word) when is_atom(word) do def maybe_date_fetch(headers, date) do
underscore(to_string(word)) if Mobilizon.Config.get([:activitypub, :sign_object_fetches]) do
headers ++ [{:Date, date}]
else
headers
end end
def underscore(word) when is_bitstring(word) do
Macro.underscore(word)
end 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: %{"type" => "Tombstone"}}), do: false
def is_public?(%{data: data}), do: is_public?(data) def is_public?(%{data: data}), do: is_public?(data)
def is_public?(%Activity{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?(%Comment{deleted_at: deleted_at}), do: !is_nil(deleted_at)
def is_public?(err), do: raise(ArgumentError, message: "Invalid argument #{inspect(err)}") def is_public?(err), do: raise(ArgumentError, message: "Invalid argument #{inspect(err)}")
end end

View file

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

View file

@ -38,7 +38,8 @@ defmodule Mobilizon.Service.HTML.Scrubber.Default do
"tag", "tag",
"nofollow", "nofollow",
"noopener", "noopener",
"noreferrer" "noreferrer",
"ugc"
]) ])
Meta.allow_tag_with_these_attributes("a", ["name", "title"]) Meta.allow_tag_with_these_attributes("a", ["name", "title"])
@ -61,8 +62,8 @@ defmodule Mobilizon.Service.HTML.Scrubber.Default do
Meta.allow_tag_with_these_attributes("ul", []) Meta.allow_tag_with_these_attributes("ul", [])
Meta.allow_tag_with_these_attributes("img", ["src", "alt"]) Meta.allow_tag_with_these_attributes("img", ["src", "alt"])
Meta.allow_tag_with_this_attribute_values("span", "class", ["h-card"]) Meta.allow_tag_with_this_attribute_values("span", "class", ["h-card", "mention"])
Meta.allow_tag_with_these_attributes("span", []) Meta.allow_tag_with_these_attributes("span", ["data-user"])
Meta.allow_tag_with_these_attributes("h1", []) Meta.allow_tag_with_these_attributes("h1", [])
Meta.allow_tag_with_these_attributes("h2", []) Meta.allow_tag_with_these_attributes("h2", [])

View file

@ -15,6 +15,7 @@ defmodule Mobilizon.Service.HTTPSignatures.Signature do
require Logger require Logger
@spec key_id_to_actor_url(String.t()) :: String.t()
def key_id_to_actor_url(key_id) do def key_id_to_actor_url(key_id) do
%{path: path} = %{path: path} =
uri = uri =
@ -46,12 +47,10 @@ defmodule Mobilizon.Service.HTTPSignatures.Signature do
end end
end end
@doc """ # Gets a public key for a given ActivityPub actor ID (url).
Gets a public key for a given ActivityPub actor ID (url).
"""
@spec get_public_key_for_url(String.t()) :: @spec get_public_key_for_url(String.t()) ::
{:ok, String.t()} | {:error, :actor_fetch_error | :pem_decode_error} {:ok, String.t()} | {:error, :actor_fetch_error | :pem_decode_error}
def get_public_key_for_url(url) do defp get_public_key_for_url(url) do
with {:ok, %Actor{keys: keys}} <- ActivityPub.get_or_fetch_actor_by_url(url), with {:ok, %Actor{keys: keys}} <- ActivityPub.get_or_fetch_actor_by_url(url),
{:ok, public_key} <- prepare_public_key(keys) do {:ok, public_key} <- prepare_public_key(keys) do
{:ok, public_key} {:ok, public_key}
@ -103,16 +102,10 @@ defmodule Mobilizon.Service.HTTPSignatures.Signature do
end end
end end
def generate_date_header(date \\ Timex.now("GMT")) do def generate_date_header, do: generate_date_header(NaiveDateTime.utc_now())
case Timex.format(date, "%a, %d %b %Y %H:%M:%S %Z", :strftime) do
{:ok, date} ->
date
{:error, err} -> def generate_date_header(%NaiveDateTime{} = date) do
Logger.error("Unable to generate date header") Timex.format!(date, "{WDshort}, {0D} {Mshort} {YYYY} {h24}:{m}:{s} GMT")
Logger.debug(inspect(err))
nil
end
end end
def generate_request_target(method, path), do: "#{method} #{path}" def generate_request_target(method, path), do: "#{method} #{path}"

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