Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2020-05-08 22:48:41 +02:00
parent 3e76649b71
commit 1a11bfe2d1
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
90 changed files with 6736 additions and 3123 deletions

View File

@ -206,6 +206,25 @@ config :mobilizon, Oban,
prune: {:maxlen, 10_000},
queues: [default: 10, search: 20, background: 5]
config :mobilizon, :rich_media,
parsers: [
Mobilizon.Service.RichMedia.Parsers.OEmbed,
Mobilizon.Service.RichMedia.Parsers.OGP,
Mobilizon.Service.RichMedia.Parsers.TwitterCard,
Mobilizon.Service.RichMedia.Parsers.Fallback
]
config :mobilizon, :resource_providers,
types: [pad: :etherpad, calc: :ethercalc, visio: :jitsi],
providers: %{}
config :mobilizon, :external_resource_providers, %{
"https://drive.google.com/" => :google_drive,
"https://docs.google.com/document/" => :google_docs,
"https://docs.google.com/presentation/" => :google_presentation,
"https://docs.google.com/spreadsheets/" => :google_spreadsheets
}
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{Mix.env()}.exs"

View File

@ -1,21 +1,39 @@
{"__schema":
{"types":[
{
"possibleTypes":[
{"name":"Person"},
{"name":"Group"}
],
"name":"Actor",
"kind":"INTERFACE"
},
{
"possibleTypes":[
{"name":"Event"},
{"name":"Person"},
{"name":"Group"}
],
"name":"SearchResult",
"kind":"UNION"}
]
{
"__schema": {
"types": [
{
"kind": "INTERFACE",
"name": "ActionLogObject",
"possibleTypes": [
{
"name": "Event"
},
{
"name": "Comment"
},
{
"name": "Report"
},
{
"name": "ReportNote"
}
]
},
{
"kind": "INTERFACE",
"name": "Actor",
"possibleTypes": [
{
"name": "Person"
},
{
"name": "Group"
},
{
"name": "Application"
}
]
}
]
}
}

View File

@ -1,7 +1,7 @@
const fetch = require('node-fetch');
const fs = require('fs');
fetch(`http://localhost:4001`, {
fetch(`http://localhost:4000/api`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({

View File

@ -31,10 +31,10 @@
"bulma-divider": "^0.2.0",
"graphql": "^14.5.8",
"graphql-tag": "^2.10.1",
"intersection-observer": "^0.7.0",
"intersection-observer": "^0.10.0",
"javascript-time-ago": "^2.0.4",
"leaflet": "^1.4.0",
"leaflet.locatecontrol": "^0.70.0",
"leaflet.locatecontrol": "^0.71.0",
"lodash": "^4.17.11",
"ngeohash": "^0.6.3",
"phoenix": "^1.4.11",
@ -50,7 +50,8 @@
"vue-property-decorator": "^8.1.0",
"vue-router": "^3.0.6",
"vue-scrollto": "^2.17.1",
"vue2-leaflet": "^2.0.3"
"vue2-leaflet": "^2.0.3",
"vuedraggable": "^2.23.2"
},
"devDependencies": {
"@types/chai": "^4.2.3",
@ -58,6 +59,7 @@
"@types/leaflet.locatecontrol": "^0.60.7",
"@types/lodash": "^4.14.141",
"@types/mocha": "^7.0.1",
"@types/vuedraggable": "^2.23.0",
"@vue/cli-plugin-babel": "^4.0.3",
"@vue/cli-plugin-e2e-cypress": "^4.0.3",
"@vue/cli-plugin-pwa": "^4.0.3",
@ -66,7 +68,7 @@
"@vue/cli-plugin-unit-mocha": "^4.0.3",
"@vue/cli-service": "^4.0.3",
"@vue/eslint-config-typescript": "^5.0.0",
"@vue/test-utils": "^1.0.0-beta.32",
"@vue/test-utils": "^1.0.0",
"apollo-link-error": "^1.1.12",
"chai": "^4.2.0",
"dotenv-webpack": "^1.7.0",

View File

@ -13,7 +13,7 @@
<template slot-scope="props">
<div class="media">
<div class="media-left">
<img width="32" :src="props.option.avatar.url" v-if="props.option.avatar">
<img width="32" :src="props.option.avatar.url" v-if="props.option.avatar" alt="">
<b-icon v-else icon="account-circle" />
</div>
<div class="media-content">
@ -45,16 +45,14 @@ const SEARCH_PERSON_LIMIT = 10;
@Component
export default class ActorAutoComplete extends Vue {
@Model('change', { type: Object }) readonly defaultSelected!: IPerson;
@Model('change', { type: Object }) readonly defaultSelected!: IPerson|null;
baseData: IPerson[] = [];
selected: IPerson = this.defaultSelected;
name: string = this.defaultSelected.preferredUsername;
selected: IPerson|null = this.defaultSelected;
name: string = this.defaultSelected ? this.defaultSelected.preferredUsername : '';
page: number = 1;
totalPages: number = 1;
SEARCH_PERSON_LIMIT = SEARCH_PERSON_LIMIT;
mounted() {
this.selected = this.defaultSelected;
}
@ -100,4 +98,4 @@ export default class ActorAutoComplete extends Vue {
this.baseData.push(...searchPersons.elements);
}
}
</script>
</script>

View File

@ -20,12 +20,12 @@
</article>
</template>
<script lang="ts">
import {Component, Prop, Vue} from "vue-property-decorator";
import { Component, Prop, Vue } from 'vue-property-decorator';
@Component
export default class ConversationComment extends Vue {
@Prop({ required: true, type: Object }) comment;
}
@Component
export default class ConversationComment extends Vue {
@Prop({ required: true, type: Object }) comment;
}
</script>
<style lang="scss" scoped>
@import "@/variables.scss";

View File

@ -13,21 +13,21 @@
</router-link>
</template>
<script lang="ts">
import {Component, Prop, Vue} from "vue-property-decorator";
import {IConversation} from "@/types/conversations";
import {RouteName} from "@/router";
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IConversation } from '@/types/conversations';
import { RouteName } from '@/router';
@Component
export default class ConversationListItem extends Vue {
@Prop({ required: true, type: Object }) conversation!: IConversation;
RouteName = RouteName;
@Component
export default class ConversationListItem extends Vue {
@Prop({ required: true, type: Object }) conversation!: IConversation;
RouteName = RouteName;
htmlTextEllipsis(HTMLText: string) {
const element = document.createElement('div');
element.innerHTML = HTMLText.replace(/<br\s*\/?>/gi, ' ').replace(/<p>/gi, ' ');
return element.innerText;
}
}
htmlTextEllipsis(HTMLText: string) {
const element = document.createElement('div');
element.innerHTML = HTMLText.replace(/<br\s*\/?>/gi, ' ').replace(/<p>/gi, ' ');
return element.innerText;
}
}
</script>
<style lang="scss" scoped>
.conversation-minimalist-card-wrapper {

View File

@ -10,13 +10,13 @@
</div>
</template>
<script lang="ts">
import {Component, Prop, Vue} from "vue-property-decorator";
import { Component, Prop, Vue } from 'vue-property-decorator';
@Component
export default class EventMetadataBlock extends Vue {
@Prop({ required: false, type: String }) icon!: String;
@Prop({ required: true, type: String }) title!: String;
}
@Component
export default class EventMetadataBlock extends Vue {
@Prop({ required: false, type: String }) icon!: String;
@Prop({ required: true, type: String }) title!: String;
}
</script>
<style lang="scss" scoped>
h3 {

View File

@ -9,20 +9,20 @@
</router-link>
</template>
<script lang="ts">
import {Component, Prop, Vue} from "vue-property-decorator";
import {IEvent} from "@/types/event.model";
import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
import { RouteName } from '@/router';
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IEvent } from '@/types/event.model';
import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
import { RouteName } from '@/router';
@Component({
components: {
DateCalendarIcon
}
})
export default class EventMinimalistCard extends Vue {
@Prop({ required: true, type: Object }) event!: IEvent;
RouteName = RouteName;
}
@Component({
components: {
DateCalendarIcon,
},
})
export default class EventMinimalistCard extends Vue {
@Prop({ required: true, type: Object }) event!: IEvent;
RouteName = RouteName;
}
</script>
<style lang="scss" scoped>
.event-minimalist-card-wrapper {

View File

@ -39,25 +39,25 @@
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import {IGroup, IMember, IPerson} from '@/types/actor';
import { IGroup, IMember, IPerson } from '@/types/actor';
import GroupPicker from '@/components/Group/GroupPicker.vue';
import {PERSON_MEMBERSHIPS} from "@/graphql/actor";
import {Paginate} from "@/types/paginate";
import { PERSON_MEMBERSHIPS } from '@/graphql/actor';
import { Paginate } from '@/types/paginate';
@Component({
components: { GroupPicker },
apollo: {
groupMemberships: {
query: PERSON_MEMBERSHIPS,
variables() {
return {
id: this.identity.id,
};
},
update: data => data.person.memberships,
skip() { return !this.identity.id; },
},
apollo: {
groupMemberships: {
query: PERSON_MEMBERSHIPS,
variables() {
return {
id: this.identity.id,
};
},
update: data => data.person.memberships,
skip() { return !this.identity.id; },
},
},
})
export default class GroupPickerWrapper extends Vue {
@Prop({ type: Object, required: true }) value!: IGroup;

View File

@ -0,0 +1,152 @@
<template>
<div class="resource-wrapper">
<router-link
:to="{ name: RouteName.RESOURCE_FOLDER, params: { path: resourcePathArray(resource), preferredUsername: group.preferredUsername } }"
>
<div class="preview">
<b-icon icon="folder" size="is-large" />
</div>
<div class="body">
<h3>{{ resource.title }}</h3>
<span class="host" v-if="inline">{{ resource.updatedAt | formatDateTimeString }}</span>
</div>
<draggable
v-if="!inline"
class="dropzone"
v-model="list"
:sort="false"
:group="groupObject"
@change="onChange"
/>
</router-link>
<resource-dropdown
class="actions"
v-if="!inline"
@delete="$emit('delete', resource.id)"
@move="$emit('move', resource.id)"
@rename="$emit('rename', resource)"
/>
</div>
</template>
<script lang="ts">
import { Component, Mixins, Prop } from "vue-property-decorator";
import { IResource } from "@/types/resource";
import { RouteName } from "@/router";
import ResourceMixin from "@/mixins/resource";
import Draggable from "vuedraggable";
import { UPDATE_RESOURCE } from "../../graphql/resources";
import { IGroup } from "@/types/actor";
import ResourceDropdown from "@/components/Resource/ResourceDropdown.vue";
@Component({
components: { Draggable, ResourceDropdown }
})
export default class FolderItem extends Mixins(ResourceMixin) {
@Prop({ required: true, type: Object }) resource!: IResource;
@Prop({ required: true, type: Object }) group!: IGroup;
@Prop({ required: false, default: false }) inline!: boolean;
list = [];
groupObject: object = {
name: `folder-${this.resource.title}`,
pull: false,
put: ["resources"]
};
RouteName = RouteName;
async onChange(evt) {
console.log("into folder item");
console.log(evt);
if (evt.added && evt.added.element) {
const movedResource = evt.added.element as IResource;
const updatedResource = await this.moveResource(movedResource);
if (updatedResource && this.resource.path) {
// @ts-ignore
return this.$router.push({
name: RouteName.RESOURCE_FOLDER,
params: {
path: this.resourcePathArray(this.resource),
preferredUsername: this.group.preferredUsername
}
});
}
}
}
async moveResource(resource: IResource): Promise<IResource | undefined> {
const { data } = await this.$apollo.mutate<{ updateResource: IResource }>({
mutation: UPDATE_RESOURCE,
variables: {
id: resource.id,
path: `${this.resource.path}/${resource.title}`,
parentId: this.resource.id
}
});
if (!data) {
console.error("Error while updating resource");
}
return data?.updateResource;
}
}
</script>
<style lang="scss" scoped>
@import "@/variables.scss";
.resource-wrapper {
display: flex;
flex: 1;
align-items: center;
.actions {
flex: 0;
display: block;
margin: auto 1rem auto 2rem;
cursor: pointer;
}
}
.dropzone {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
}
a {
display: flex;
font-size: 14px;
color: #444b5d;
text-decoration: none;
overflow: hidden;
flex: 1;
position: relative;
.preview {
flex: 0 0 100px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.body {
padding: 10px 8px 8px;
flex: 1 1 auto;
overflow: hidden;
h3 {
white-space: nowrap;
display: block;
font-weight: 500;
margin-bottom: 5px;
color: $primary;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none;
}
}
}
</style>

View File

@ -0,0 +1,24 @@
<template>
<b-dropdown aria-role="list" position="is-bottom-left">
<b-icon icon="dots-horizontal" slot="trigger" />
<b-dropdown-item aria-role="listitem" @click="$emit('rename')">
<b-icon icon="pencil" />
{{ $t('Rename') }}
</b-dropdown-item>
<b-dropdown-item aria-role="listitem" @click="$emit('move')">
<b-icon icon="folder-move" />
{{ $t('Move') }}
</b-dropdown-item>
<b-dropdown-item aria-role="listitem" @click="$emit('delete')">
<b-icon icon="delete" />
{{ $t('Delete') }}
</b-dropdown-item>
</b-dropdown>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
@Component
export default class ResourceDropdown extends Vue {}
</script>

View File

@ -0,0 +1,135 @@
<template>
<div class="resource-wrapper">
<a :href="resource.resourceUrl" target="_blank">
<div class="preview">
<div v-if="resource.type && Object.keys(mapServiceTypeToIcon).includes(resource.type)">
<b-icon :icon="mapServiceTypeToIcon[resource.type]" size="is-large" />
</div>
<div
class="preview-image"
v-else-if="resource.metadata && resource.metadata.imageRemoteUrl"
:style="`background-image: url(${resource.metadata.imageRemoteUrl})`"
/>
<div class="preview-type" v-else>
<b-icon icon="link" size="is-large" />
</div>
</div>
<div class="body">
<img
class="favicon"
v-if="resource.metadata && resource.metadata.faviconUrl"
:src="resource.metadata.faviconUrl"
/>
<h3>{{ resource.title }}</h3>
<span class="host" v-if="inline">{{ resource.updatedAt | formatDateTimeString }}</span>
<span class="host" v-else>{{ urlHostname(resource.resourceUrl) }}</span>
</div>
</a>
<resource-dropdown
class="actions"
v-if="!inline"
@delete="$emit('delete', resource.id)"
@move="$emit('move', resource.id)"
@rename="$emit('rename', resource)"
/>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IResource, mapServiceTypeToIcon } from "@/types/resource";
import ResourceDropdown from "@/components/Resource/ResourceDropdown.vue";
@Component({
components: { ResourceDropdown }
})
export default class ResourceItem extends Vue {
@Prop({ required: true, type: Object }) resource!: IResource;
@Prop({ required: false, default: false }) inline!: boolean;
list = [];
mapServiceTypeToIcon = mapServiceTypeToIcon;
urlHostname(url: string): string {
return new URL(url).hostname.replace(/^(www\.)/, "");
}
}
</script>
<style lang="scss" scoped>
@import "@/variables.scss";
.resource-wrapper {
display: flex;
flex: 1;
align-items: center;
.actions {
flex: 0;
display: block;
margin: auto 1rem auto 2rem;
cursor: pointer;
}
}
a {
display: flex;
font-size: 14px;
color: #444b5d;
text-decoration: none;
overflow: hidden;
flex: 1;
.preview {
flex: 0 0 100px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
.preview-image {
border-radius: 4px 0 0 4px;
display: block;
margin: 0;
width: 100%;
height: 100%;
object-fit: cover;
background-size: cover;
background-position: 50%;
}
}
.body {
padding: 10px 8px 8px;
flex: 1 1 auto;
overflow: hidden;
img.favicon {
display: inline-block;
width: 16px;
height: 16px;
margin-right: 6px;
vertical-align: middle;
}
h3 {
white-space: nowrap;
display: inline-block;
font-weight: 500;
margin-bottom: 5px;
color: $primary;
overflow: hidden;
text-overflow: ellipsis;
text-decoration: none;
vertical-align: middle;
}
.host {
display: block;
margin-top: 5px;
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>

View File

@ -31,6 +31,7 @@ export default class Todo extends Vue {
@Prop({ required: true, type: Object }) todo!: ITodo;
RouteName = RouteName;
editMode: boolean = false;
debounceUpdateTodo;
// We put this in data because of issues like https://github.com/vuejs/vue-class-component/issues/263
data() {
@ -52,8 +53,8 @@ export default class Todo extends Vue {
this.debounceUpdateTodo({ assignedToId: (person ? person.id : null) });
}
get dueDate(): Date { return new Date(this.todo.dueDate); }
set dueDate(dueDate: Date) { this.debounceUpdateTodo({ dueDate }); }
get dueDate(): Date|undefined { return new Date(this.todo.dueDate); }
set dueDate(dueDate: Date|undefined) { this.debounceUpdateTodo({ dueDate }); }
updateTodo(params: object) {
this.$apollo.mutate({
@ -66,4 +67,4 @@ export default class Todo extends Vue {
this.editMode = false;
}
}
</script>
</script>

View File

@ -1,5 +1,6 @@
import gql from 'graphql-tag';
import {CONVERSATION_BASIC_FIELDS_FRAGMENT} from "@/graphql/conversation";
import { CONVERSATION_BASIC_FIELDS_FRAGMENT } from '@/graphql/conversation';
import { RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT } from '@/graphql/resources';
export const FETCH_PERSON = gql`
query($username: String!) {
@ -367,6 +368,7 @@ export const FETCH_GROUP = gql`
}
organizedEvents {
elements {
id,
uuid,
title,
beginsOn
@ -394,10 +396,18 @@ export const FETCH_GROUP = gql`
},
total
},
collections {
resources(page: 1, limit: 3) {
elements {
id,
title
title,
resourceUrl,
summary,
updatedAt,
type,
path,
metadata {
...ResourceMetadataBasicFields
}
},
total
},
@ -409,7 +419,12 @@ export const FETCH_GROUP = gql`
elements {
id,
title,
status
status,
dueDate,
assignedTo {
id,
preferredUsername
}
},
total
}
@ -419,7 +434,8 @@ export const FETCH_GROUP = gql`
}
}
${CONVERSATION_BASIC_FIELDS_FRAGMENT}
`;
${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT}`
;
export const CREATE_GROUP = gql`
mutation CreateGroup(

View File

@ -50,6 +50,11 @@ query {
geocoding {
provider,
autocomplete
},
resourceProviders {
type,
endpoint,
software
}
}
}

View File

@ -1,4 +1,4 @@
import gql from "graphql-tag";
import gql from 'graphql-tag';
export const CONVERSATION_BASIC_FIELDS_FRAGMENT = gql`
fragment ConversationBasicFields on Conversation {

113
js/src/graphql/resources.ts Normal file
View File

@ -0,0 +1,113 @@
import gql from 'graphql-tag';
export const RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT = gql`
fragment ResourceMetadataBasicFields on ResourceMetadata {
imageRemoteUrl,
height,
width,
type,
faviconUrl
},
`;
export const GET_RESOURCE = gql`
query GetResource($path: String!, $username: String!) {
resource(path: $path, username: $username) {
id,
title,
summary,
url,
path,
metadata {
...ResourceMetadataBasicFields
authorName,
authorUrl,
providerName,
providerUrl,
html
},
parent {
id
},
actor {
id,
preferredUsername
},
children {
total,
elements {
id,
title,
summary,
url,
type,
path,
resourceUrl,
metadata {
...ResourceMetadataBasicFields
}
}
}
}
}
${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT}`;
export const CREATE_RESOURCE = gql`
mutation CreateResource($title: String!, $parentId: ID, $summary: String, $actorId: ID!, $resourceUrl: String, $type: String, $path: String) {
createResource(title: $title, parentId: $parentId, summary: $summary, actorId: $actorId, resourceUrl: $resourceUrl, type: $type, path: $path) {
id,
title,
summary,
url,
resourceUrl,
updatedAt,
path,
type,
metadata {
...ResourceMetadataBasicFields
authorName,
authorUrl,
providerName,
providerUrl,
html
}
}
}
${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT}`;
export const UPDATE_RESOURCE = gql`
mutation UpdateResource($id: ID!, $title: String, $summary: String, $parentId: ID, $resourceUrl: String) {
updateResource(id: $id, title: $title, parentId: $parentId, summary: $summary, resourceUrl: $resourceUrl) {
id,
title,
summary,
url,
path,
resourceUrl
}
}
`;
export const DELETE_RESOURCE = gql`
mutation DeleteResource($id: ID!) {
deleteResource(id: $id) {
id
}
}
`;
export const PREVIEW_RESOURCE_LINK = gql`
mutation PreviewResourceLink($resourceUrl: String!) {
previewResourceLink(resourceUrl: $resourceUrl) {
title,
description,
...ResourceMetadataBasicFields
authorName,
authorUrl,
providerName,
providerUrl,
html
}
}
${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT}`;

View File

@ -517,5 +517,6 @@
"No one is going to this event": "No one is going to this event|One person going|{going} persons going",
"By @{group}": "By @{group}",
"Date and time": "Date and time",
"Location": "Location"
"Location": "Location",
"No resources selected": "No resources selected|One resources selected|{count} resources selected"
}

View File

@ -525,5 +525,6 @@
"No one is going to this event": "Personne n'a va encore|Une personne y va|{going} personnes y vont",
"By @{group}": "Par @{group}",
"Date and time": "Date et heure",
"Location": "Lieu"
"Location": "Lieu",
"No resources selected": "Aucune ressource sélectionnée|Une ressource sélectionnée|{count} ressources sélectionnées"
}

17
js/src/mixins/resource.ts Normal file
View File

@ -0,0 +1,17 @@
import { Component, Vue } from 'vue-property-decorator';
import { IResource } from '@/types/resource';
@Component
export default class ResourceMixin extends Vue {
resourcePath(resource: IResource): string {
const path = resource.path;
if (path && path[0] === '/') {
return path.slice(1);
}
return path || '';
}
resourcePathArray(resource: IResource): string[] {
return this.resourcePath(resource).split('/');
}
}

View File

@ -1,34 +1,34 @@
import {RouteConfig} from "vue-router";
import CreateConversation from "@/views/Conversations/Create.vue";
import ConversationsList from "@/views/Conversations/ConversationsList.vue";
import Conversation from "@/views/Conversations/Conversation.vue";
import { RouteConfig } from 'vue-router';
import CreateConversation from '@/views/Conversations/Create.vue';
import ConversationsList from '@/views/Conversations/ConversationsList.vue';
import Conversation from '@/views/Conversations/Conversation.vue';
export enum ConversationRouteName {
CONVERSATION_LIST = 'CONVERSATION_LIST',
CREATE_CONVERSATION = 'CREATE_CONVERSATION',
CONVERSATION = 'CONVERSATION'
CONVERSATION = 'CONVERSATION',
}
export const conversationRoutes: RouteConfig[] = [
{
path: '/@:preferredUsername/conversations',
name: ConversationRouteName.CONVERSATION_LIST,
component: ConversationsList,
props: true,
meta: { requiredAuth: false },
},
{
path: '/@:preferredUsername/conversations/new',
name: ConversationRouteName.CREATE_CONVERSATION,
component: CreateConversation,
props: true,
meta: { requiredAuth: true },
},
{
path: '/@:preferredUsername/:slug/:id/:comment_id?',
name: ConversationRouteName.CONVERSATION,
component: Conversation,
props: true,
meta: { requiredAuth: false },
},
{
path: '/@:preferredUsername/conversations',
name: ConversationRouteName.CONVERSATION_LIST,
component: ConversationsList,
props: true,
meta: { requiredAuth: false },
},
{
path: '/@:preferredUsername/conversations/new',
name: ConversationRouteName.CREATE_CONVERSATION,
component: CreateConversation,
props: true,
meta: { requiredAuth: true },
},
{
path: '/@:preferredUsername/:slug/:id/:comment_id?',
name: ConversationRouteName.CONVERSATION,
component: Conversation,
props: true,
meta: { requiredAuth: false },
},
];

View File

@ -2,11 +2,19 @@ import { RouteConfig } from 'vue-router';
import TodoLists from '@/views/Todos/TodoLists.vue';
import TodoList from '@/views/Todos/TodoList.vue';
import Todo from '@/views/Todos/Todo.vue';
import Settings from '@/views/Group/Settings.vue';
import Resources from '@/views/Resources/Resources.vue';
import ResourceFolder from '@/views/Resources/ResourceFolder.vue';
export enum GroupsRouteName {
TODO_LISTS = 'TODO_LISTS',
TODO_LIST = 'TODO_LIST',
TODO = 'TODO',
TODO_LISTS = 'TODO_LISTS',
TODO_LIST = 'TODO_LIST',
TODO = 'TODO',
GROUP_SETTINGS = 'GROUP_SETTINGS',
PUBLIC_SETTINGS = 'PUBLIC_SETTINGS',
RESOURCES = 'RESOURCES',
RESOURCE_FOLDER_ROOT = 'RESOURCE_FOLDER_ROOT',
RESOURCE_FOLDER = 'RESOURCE_FOLDER',
}
export const groupsRoutes: RouteConfig[] = [
@ -31,4 +39,32 @@ export const groupsRoutes: RouteConfig[] = [
props: true,
meta: { requiredAuth: true },
},
{
path: '/@:preferredUsername/resources',
name: GroupsRouteName.RESOURCE_FOLDER_ROOT,
component: ResourceFolder,
props: { path: '/' },
meta: { requiredAuth: true },
},
{
path: '/@:preferredUsername/resources/:path+',
name: GroupsRouteName.RESOURCE_FOLDER,
component: ResourceFolder,
props: true,
meta: { requiredAuth: true },
},
{
path: '/settings',
component: Settings,
props: true,
meta: { requiredAuth: true },
redirect: { name: GroupsRouteName.PUBLIC_SETTINGS },
name: GroupsRouteName.GROUP_SETTINGS,
children: [
{
path: 'public',
name: GroupsRouteName.PUBLIC_SETTINGS,
},
],
},
];

View File

@ -11,7 +11,7 @@ import { authGuardIfNeeded } from '@/router/guards/auth-guard';
import Search from '@/views/Search.vue';
import { SettingsRouteName, settingsRoutes } from '@/router/settings';
import { GroupsRouteName, groupsRoutes } from '@/router/groups';
import {ConversationRouteName, conversationRoutes} from "@/router/conversation";
import { ConversationRouteName, conversationRoutes } from '@/router/conversation';
Vue.use(Router);

View File

@ -1,9 +1,9 @@
import {Actor, ActorType, IActor} from '@/types/actor/actor.model';
import {Paginate} from '@/types/paginate';
import {ICollection} from '@/types/collection';
import {ITodoList} from '@/types/todos';
import {IEvent} from '@/types/event.model';
import {IConversation} from "@/types/conversations";
import { Actor, ActorType, IActor } from '@/types/actor/actor.model';
import { Paginate } from '@/types/paginate';
import { IResource } from '@/types/resource';
import { ITodoList } from '@/types/todos';
import { IEvent } from '@/types/event.model';
import { IConversation } from '@/types/conversations';
export enum MemberRole {
PENDING,
@ -14,7 +14,7 @@ export enum MemberRole {
export interface IGroup extends IActor {
members: Paginate<IMember>;
collections: Paginate<ICollection>;
resources: Paginate<IResource>;
todoLists: Paginate<ITodoList>;
conversations: Paginate<IConversation>;
organizedEvents: Paginate<IEvent>;
@ -29,7 +29,7 @@ export interface IMember {
export class Group extends Actor implements IGroup {
members: Paginate<IMember> = { elements: [], total: 0 };
collections: Paginate<ICollection> = { elements: [], total: 0 };
resources: Paginate<IResource> = { elements: [], total: 0 };
todoLists: Paginate<ITodoList> = { elements: [], total: 0 };
conversations: Paginate<IConversation> = { elements: [], total: 0 };
organizedEvents!: Paginate<IEvent>;

View File

@ -1,13 +0,0 @@
import { Paginate } from '@/types/paginate';
export interface ICollection {
id: string;
title: string;
resources: Paginate<IResource>;
}
export interface IResource {
id: string;
title: string;
url: string;
}

View File

@ -55,4 +55,9 @@ export interface IConfig {
type: InstanceTermsType;
url: string;
};
resourceProviders: {
type: string,
endpoint: string,
software: string,
};
}

View File

@ -1,13 +1,13 @@
import {IActor, IPerson} from "@/types/actor";
import {IComment} from "@/types/comment.model";
import {Paginate} from "@/types/paginate";
import { IActor, IPerson } from '@/types/actor';
import { IComment } from '@/types/comment.model';
import { Paginate } from '@/types/paginate';
export interface IConversation {
id: string;
title: string;
slug: string;
creator: IPerson;
actor: IActor;
lastComment: IComment;
comments: Paginate<IComment>
id: string;
title: string;
slug: string;
creator: IPerson;
actor: IActor;
lastComment: IComment;
comments: Paginate<IComment>;
}

View File

@ -1,4 +1,4 @@
import {Actor, Group, IActor, IPerson} from './actor';
import { Actor, Group, IActor, IPerson } from './actor';
import { Address, IAddress } from '@/types/address.model';
import { ITag } from '@/types/tag.model';
import { IPicture } from '@/types/picture.model';

41
js/src/types/resource.ts Normal file
View File

@ -0,0 +1,41 @@
import { Paginate } from '@/types/paginate';
import { IActor } from '@/types/actor';
export interface IResource {
id?: string;
title: string;
summary?: string;
actor?: IActor;
url?: string;
resourceUrl: string;
path?: string;
children: Paginate<IResource>;
parent?: IResource;
metadata: IResourceMetadata;
insertedAt?: Date;
updatedAt?: Date;
creator?: IActor;
type?: string;
}
export interface IResourceMetadata {
title?: string;
description?: string;
imageRemoteUrl?: string;
height?: number;
width?: number;
type?: string;
authorName?: string;
authorUrl?: string;
providerName?: string;
providerUrl?: string;
html?: string;
faviconUrl?: string;
}
export const mapServiceTypeToIcon: object = {
pad: "file-document-outline",
calc: "google-spreadsheet",
visio: "webcam"
}

View File

@ -59,127 +59,127 @@
</div>
</template>
<script lang="ts">
import {Component, Prop, Vue} from "vue-property-decorator";
import {GET_CONVERSATION, REPLY_TO_CONVERSATION, UPDATE_CONVERSATION} from "@/graphql/conversation";
import {IConversation} from "@/types/conversations";
import ConversationComment from "@/components/Conversation/ConversationComment.vue";
import {RouteName} from '@/router';
import { Component, Prop, Vue } from 'vue-property-decorator';
import { GET_CONVERSATION, REPLY_TO_CONVERSATION, UPDATE_CONVERSATION } from '@/graphql/conversation';
import { IConversation } from '@/types/conversations';
import ConversationComment from '@/components/Conversation/ConversationComment.vue';
import { RouteName } from '@/router';
@Component({
apollo: {
conversation: {
query: GET_CONVERSATION,
variables() {
return {
id: this.id,
page: 1,
};
},
skip() {
return !this.id;
}
},
@Component({
apollo: {
conversation: {
query: GET_CONVERSATION,
variables() {
return {
id: this.id,
page: 1,
};
},
skip() {
return !this.id;
},
},
},
components: {
ConversationComment,
editor: () => import(/* webpackChunkName: "editor" */ '@/components/Editor.vue'),
},
})
export default class Conversation extends Vue {
@Prop({ type: String, required: true }) id!: string;
conversation!: IConversation;
newComment: string = '';
newTitle: string = '';
editTitleMode: boolean = false;
page: number = 1;
hasMoreComments = true;
RouteName = RouteName;
async reply() {
await this.$apollo.mutate({
mutation: REPLY_TO_CONVERSATION,
variables: {
conversationId: this.conversation.id,
text: this.newComment,
},
update: (store, { data: { replyToConversation } }) => {
const conversationData = store.readQuery<{ conversation: IConversation }>({
query: GET_CONVERSATION,
variables: {
id: this.id,
page: this.page,
},
});
if (!conversationData) return;
const { conversation } = conversationData;
conversation.lastComment = replyToConversation.lastComment;
conversation.comments.elements.push(replyToConversation.lastComment);
conversation.comments.total += 1;
store.writeQuery({
query: GET_CONVERSATION,
variables: { id: this.id, page: this.page },
data: { conversation },
});
},
});
this.newComment = '';
}
async loadMoreComments() {
this.page += 1;
try {
console.log(this.$apollo.queries.conversation);
await this.$apollo.queries.conversation.fetchMore({
// New variables
variables: {
id: this.id,
page: this.page,
},
components: {
ConversationComment,
editor: () => import(/* webpackChunkName: "editor" */ '@/components/Editor.vue'),
// Transform the previous result with new data
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newComments = fetchMoreResult.conversation.comments.elements;
this.hasMoreComments = newComments.length === 1;
const conversation = previousResult.conversation;
conversation.comments.elements = [...previousResult.conversation.comments.elements, ...newComments];
return { conversation };
},
})
export default class Conversation extends Vue {
@Prop({type: String, required: true}) id!: string;
conversation!: IConversation;
newComment: string = '';
newTitle: string = '';
editTitleMode: boolean = false;
page: number = 1;
hasMoreComments = true;
RouteName = RouteName;
async reply() {
await this.$apollo.mutate({
mutation: REPLY_TO_CONVERSATION,
variables: {
conversationId: this.conversation.id,
text: this.newComment,
},
update: (store, {data: {replyToConversation}}) => {
const conversationData = store.readQuery<{ conversation: IConversation }>({
query: GET_CONVERSATION,
variables: {
id: this.id,
page: this.page
},
});
if (!conversationData) return;
const {conversation} = conversationData;
conversation.lastComment = replyToConversation.lastComment;
conversation.comments.elements.push(replyToConversation.lastComment);
conversation.comments.total += 1;
store.writeQuery({
query: GET_CONVERSATION,
variables: {id: this.id, page: this.page},
data: {conversation}
})
},
});
this.newComment = '';
}
async loadMoreComments() {
this.page += 1;
try {
console.log(this.$apollo.queries.conversation)
await this.$apollo.queries.conversation.fetchMore({
// New variables
variables: {
id: this.id,
page: this.page,
},
// Transform the previous result with new data
updateQuery: (previousResult, {fetchMoreResult}) => {
if (!fetchMoreResult) return previousResult;
const newComments = fetchMoreResult.conversation.comments.elements;
this.hasMoreComments = newComments.length === 1;
const conversation = previousResult.conversation;
conversation.comments.elements = [...previousResult.conversation.comments.elements, ...newComments];
return {conversation};
},
});
} catch (e) {
console.error(e);
}
}
async updateConversation() {
await this.$apollo.mutate({
mutation: UPDATE_CONVERSATION,
variables: {
conversationId: this.conversation.id,
title: this.newTitle
},
update: (store, {data: {updateConversation}}) => {
const conversationData = store.readQuery<{ conversation: IConversation }>({
query: GET_CONVERSATION,
variables: {
id: this.id,
page: this.page
},
});
if (!conversationData) return;
const {conversation} = conversationData;
conversation.title = updateConversation.title;
store.writeQuery({
query: GET_CONVERSATION,
variables: {id: this.id, page: this.page},
data: {conversation}
})
},
});
this.editTitleMode = false;
}
});
} catch (e) {
console.error(e);
}
}
async updateConversation() {
await this.$apollo.mutate({
mutation: UPDATE_CONVERSATION,
variables: {
conversationId: this.conversation.id,
title: this.newTitle,
},
update: (store, { data: { updateConversation } }) => {
const conversationData = store.readQuery<{ conversation: IConversation }>({
query: GET_CONVERSATION,
variables: {
id: this.id,
page: this.page,
},
});
if (!conversationData) return;
const { conversation } = conversationData;
conversation.title = updateConversation.title;
store.writeQuery({
query: GET_CONVERSATION,
variables: { id: this.id, page: this.page },
data: { conversation },
});
},
});
this.editTitleMode = false;
}
}
</script>
<style lang="scss" scoped>
div.container.section {

View File

@ -1,42 +1,67 @@
<template>
<section class="container section">
<div v-if="group && group.conversations.elements.length > 0">
<conversation-list-item :conversation="conversation" v-for="conversation in group.conversations.elements" :key="conversation.id" />
</div>
<b-button tag="router-link" :to="{ name: RouteName.CREATE_CONVERSATION, params: { preferredUsername: this.preferredUsername } }">{{ $t('New conversation') }}</b-button>
</section>
<div class="container section" v-if="group">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link :to="{ name: RouteName.MY_GROUPS }">{{ $t('My groups') }}</router-link>
</li>
<li>
<router-link
:to="{ name: RouteName.GROUP, params:{ preferredUsername: group.preferredUsername } }">
{{ `@${group.preferredUsername}` }}
</router-link>
</li>
<li class="is-active">
<router-link
:to="{ name: RouteName.CONVERSATION_LIST, params:{ preferredUsername: group.preferredUsername } }">
{{ $t('Conversations') }}
</router-link>
</li>
</ul>
</nav>
<section>
<div v-if="group.conversations.elements.length > 0">
<conversation-list-item :conversation="conversation"
v-for="conversation in group.conversations.elements" :key="conversation.id"/>
</div>
<b-button tag="router-link"
:to="{ name: RouteName.CREATE_CONVERSATION, params: { preferredUsername: this.preferredUsername } }">
{{ $t('New conversation') }}
</b-button>
</section>
</div>
</template>
<script lang="ts">
import {Component, Prop, Vue} from "vue-property-decorator";
import {FETCH_GROUP} from "@/graphql/actor";
import {IGroup} from "@/types/actor";
import {RouteName} from "@/router";
import ConversationListItem from "@/components/Conversation/ConversationListItem.vue";
import { Component, Prop, Vue } from 'vue-property-decorator';
import { FETCH_GROUP } from '@/graphql/actor';
import { IGroup } from '@/types/actor';
import { RouteName } from '@/router';
import ConversationListItem from '@/components/Conversation/ConversationListItem.vue';
@Component({
components: {ConversationListItem},
apollo: {
group: {
query: FETCH_GROUP,
variables() {
return {
name: this.preferredUsername,
};
},
skip() {
return !this.preferredUsername;
}
},
}
})
export default class ConversationsList extends Vue {
@Prop({type: String, required: true}) preferredUsername!: string;
group!: IGroup;
RouteName = RouteName;
}
@Component({
components: { ConversationListItem },
apollo: {
group: {
query: FETCH_GROUP,
variables() {
return {
name: this.preferredUsername,
};
},
skip() {
return !this.preferredUsername;
},
},
},
})
export default class ConversationsList extends Vue {
@Prop({ type: String, required: true }) preferredUsername!: string;
group!: IGroup;
RouteName = RouteName;
}
</script>
<style lang="scss">
section.container.section {
div.container.section {
background: white;
}
</style>

View File

@ -2,7 +2,7 @@
<section class="section container">
<h1>{{ $t('Create a new conversation') }}</h1>
<div>
<form @submit.prevent="createConversation">
<b-field :label="$t('Title')">
<b-input aria-required="true" required v-model="conversation.title"/>
</b-field>
@ -11,70 +11,70 @@
<editor v-model="conversation.text" />
</b-field>
<button class="button is-primary" @click="createConversation()">
<button class="button is-primary" type="submit">
{{ $t('Create my group') }}
</button>
</div>
</form>
</section>
</template>
<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator';
import {IGroup, IPerson} from '@/types/actor';
import {CURRENT_ACTOR_CLIENT, FETCH_GROUP} from '@/graphql/actor';
import {CREATE_CONVERSATION} from "@/graphql/conversation";
import {RouteName} from "@/router";
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IGroup, IPerson } from '@/types/actor';
import { CURRENT_ACTOR_CLIENT, FETCH_GROUP } from '@/graphql/actor';
import { CREATE_CONVERSATION } from '@/graphql/conversation';
import { RouteName } from '@/router';
@Component({
components: {
editor: () => import(/* webpackChunkName: "editor" */ '@/components/Editor.vue'),
@Component({
components: {
editor: () => import(/* webpackChunkName: "editor" */ '@/components/Editor.vue'),
},
apollo: {
currentActor: CURRENT_ACTOR_CLIENT,
group: {
query: FETCH_GROUP,
variables() {
return {
name: this.preferredUsername,
};
},
skip() {
return !this.preferredUsername;
},
},
},
})
export default class CreateConversation extends Vue {
@Prop({ type: String, required: true }) preferredUsername!: string;
group!: IGroup;
currentActor!: IPerson;
conversation = { title: '', text: '' };
async createConversation() {
try {
const { data } = await this.$apollo.mutate({
mutation: CREATE_CONVERSATION,
variables: {
title: this.conversation.title,
text: this.conversation.text,
actorId: this.group.id,
creatorId: this.currentActor.id,
},
apollo: {
currentActor: CURRENT_ACTOR_CLIENT,
group: {
query: FETCH_GROUP,
variables() {
return {
name: this.preferredUsername,
};
},
skip() {
return !this.preferredUsername;
}
},
},
})
export default class CreateConversation extends Vue {
@Prop({type: String, required: true}) preferredUsername!: string;
group!: IGroup;
currentActor!: IPerson;
// update: (store, { data: { createConversation } }) => {
// // TODO: update group list cache
// },
});
conversation = {title: '', text: ''};
async createConversation() {
try {
const {data} = await this.$apollo.mutate({
mutation: CREATE_CONVERSATION,
variables: {
title: this.conversation.title,
text: this.conversation.text,
actorId: this.group.id,
creatorId: this.currentActor.id
},
// update: (store, { data: { createConversation } }) => {
// // TODO: update group list cache
// },
});
await this.$router.push({
name: RouteName.CONVERSATION,
params: {id: data?.createConversation.id, slug: data?.createConversation.slug}
});
} catch (err) {
console.error(err);
}
}
await this.$router.push({
name: RouteName.CONVERSATION,
params: { id: data?.createConversation.id, slug: data?.createConversation.slug },
});
} catch (err) {
console.error(err);
}
}
}
</script>
<style>

View File

@ -311,7 +311,7 @@ import {
IEvent, ParticipantRole,
} from '@/types/event.model';
import { CURRENT_ACTOR_CLIENT, LOGGED_USER_DRAFTS, LOGGED_USER_PARTICIPATIONS } from '@/graphql/actor';
import {Group, IPerson, Person} from '@/types/actor';
import { Group, IPerson, Person } from '@/types/actor';
import PictureUpload from '@/components/PictureUpload.vue';
import EditorComponent from '@/components/Editor.vue';
import DateTimePicker from '@/components/Event/DateTimePicker.vue';
@ -349,16 +349,16 @@ const DEFAULT_LIMIT_NUMBER_OF_PLACES = 10;
query: FETCH_EVENT,
variables() {
return {
uuid: this.eventId
}
uuid: this.eventId,
};
},
update(data) {
return new EventModel(data.event);
},
skip() {
return !this.eventId;
}
}
},
},
},
metaInfo() {
return {

View File

@ -395,467 +395,467 @@
</template>
<script lang="ts">
import {
EVENT_PERSON_PARTICIPATION,
EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
FETCH_EVENT,
JOIN_EVENT,
} from '@/graphql/event';
import {Component, Prop, Watch} from 'vue-property-decorator';
import {CURRENT_ACTOR_CLIENT} from '@/graphql/actor';
import {
EventModel,
EventStatus,
EventVisibility,
IEvent,
IParticipant,
ParticipantRole,
EventJoinOptions
} from '@/types/event.model';
import {IPerson, Person} from '@/types/actor';
import {GRAPHQL_API_ENDPOINT} from '@/api/_entrypoint';
import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
import BIcon from 'buefy/src/components/icon/Icon.vue';
import EventCard from '@/components/Event/EventCard.vue';
import EventFullDate from '@/components/Event/EventFullDate.vue';
import ActorLink from '@/components/Account/ActorLink.vue';
import ReportModal from '@/components/Report/ReportModal.vue';
import {IReport} from '@/types/report.model';
import {CREATE_REPORT} from '@/graphql/report';
import EventMixin from '@/mixins/event';
import IdentityPicker from '@/views/Account/IdentityPicker.vue';
import ParticipationButton from '@/components/Event/ParticipationButton.vue';
import {GraphQLError} from 'graphql';
import {RouteName} from '@/router';
import {Address} from '@/types/address.model';
import CommentTree from '@/components/Comment/CommentTree.vue';
import 'intersection-observer';
import {CONFIG} from '@/graphql/config';
import {
AnonymousParticipationNotFoundError,
getLeaveTokenForParticipation,
isParticipatingInThisEvent,
removeAnonymousParticipation,
} from '@/services/AnonymousParticipationStorage';
import {IConfig} from '@/types/config.model';
import Subtitle from '@/components/Utils/Subtitle.vue';
import Tag from '@/components/Tag.vue';
import EventMetadataBlock from "@/components/Event/EventMetadataBlock.vue";
import {
EVENT_PERSON_PARTICIPATION,
EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
FETCH_EVENT,
JOIN_EVENT,
} from '@/graphql/event';
import { Component, Prop, Watch } from 'vue-property-decorator';
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
import {
EventModel,
EventStatus,
EventVisibility,
IEvent,
IParticipant,
ParticipantRole,
EventJoinOptions,
} from '@/types/event.model';
import { IPerson, Person } from '@/types/actor';
import { GRAPHQL_API_ENDPOINT } from '@/api/_entrypoint';
import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
import BIcon from 'buefy/src/components/icon/Icon.vue';
import EventCard from '@/components/Event/EventCard.vue';
import EventFullDate from '@/components/Event/EventFullDate.vue';
import ActorLink from '@/components/Account/ActorLink.vue';
import ReportModal from '@/components/Report/ReportModal.vue';
import { IReport } from '@/types/report.model';
import { CREATE_REPORT } from '@/graphql/report';
import EventMixin from '@/mixins/event';
import IdentityPicker from '@/views/Account/IdentityPicker.vue';
import ParticipationButton from '@/components/Event/ParticipationButton.vue';
import { GraphQLError } from 'graphql';
import { RouteName } from '@/router';
import { Address } from '@/types/address.model';
import CommentTree from '@/components/Comment/CommentTree.vue';
import 'intersection-observer';
import { CONFIG } from '@/graphql/config';
import {
AnonymousParticipationNotFoundError,
getLeaveTokenForParticipation,
isParticipatingInThisEvent,
removeAnonymousParticipation,
} from '@/services/AnonymousParticipationStorage';
import { IConfig } from '@/types/config.model';
import Subtitle from '@/components/Utils/Subtitle.vue';
import Tag from '@/components/Tag.vue';
import EventMetadataBlock from '@/components/Event/EventMetadataBlock.vue';
@Component({
components: {
EventMetadataBlock,
Subtitle,
ActorLink,
EventFullDate,
EventCard,
BIcon,
DateCalendarIcon,
ReportModal,
IdentityPicker,
ParticipationButton,
CommentTree,
Tag,
// tslint:disable:space-in-parens
'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'),
// tslint:enable
@Component({
components: {
EventMetadataBlock,
Subtitle,
ActorLink,
EventFullDate,
EventCard,
BIcon,
DateCalendarIcon,
ReportModal,
IdentityPicker,
ParticipationButton,
CommentTree,
Tag,
// tslint:disable:space-in-parens
'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'),
// tslint:enable
},
apollo: {
event: {
query: FETCH_EVENT,
variables() {
return {
uuid: this.uuid,
};
},
error({ graphQLErrors }) {
this.handleErrors(graphQLErrors);
},
},
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
participations: {
query: EVENT_PERSON_PARTICIPATION,
variables() {
return {
eventId: this.event.id,
actorId: this.currentActor.id,
};
},
subscribeToMore: {
document: EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
variables() {
return {
eventId: this.event.id,
actorId: this.currentActor.id,
};
},
apollo: {
event: {
query: FETCH_EVENT,
variables() {
return {
uuid: this.uuid,
};
},
error({graphQLErrors}) {
this.handleErrors(graphQLErrors);
},
},
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
participations: {
query: EVENT_PERSON_PARTICIPATION,
variables() {
return {
eventId: this.event.id,
actorId: this.currentActor.id,
};
},
subscribeToMore: {
document: EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
variables() {
return {
eventId: this.event.id,
actorId: this.currentActor.id,
};
},
},
update: (data) => {
if (data && data.person) return data.person.participations;
return [];
},
skip() {
return !this.currentActor || !this.event || !this.event.id || !this.currentActor.id;
},
},
config: CONFIG,
},
metaInfo() {
return {
// if no subcomponents specify a metaInfo.title, this title will be used
},
update: (data) => {
if (data && data.person) return data.person.participations;
return [];
},
skip() {
return !this.currentActor || !this.event || !this.event.id || !this.currentActor.id;
},
},
config: CONFIG,
},
metaInfo() {
return {
// if no subcomponents specify a metaInfo.title, this title will be used
// @ts-ignore
title: this.eventTitle,
// all titles will be injected into this template
titleTemplate: '%s | Mobilizon',
meta: [
// @ts-ignore
title: this.eventTitle,
// all titles will be injected into this template
titleTemplate: '%s | Mobilizon',
meta: [
// @ts-ignore
{name: 'description', content: this.eventDescription},
],
};
},
})
export default class Event extends EventMixin {
@Prop({type: String, required: true}) uuid!: string;
{ name: 'description', content: this.eventDescription },
],
};
},
})
export default class Event extends EventMixin {
@Prop({ type: String, required: true }) uuid!: string;
event: IEvent = new EventModel();
currentActor!: IPerson;
identity: IPerson = new Person();
config!: IConfig;
participations: IParticipant[] = [];
oldParticipationRole!: String;
showMap: boolean = false;
isReportModalActive: boolean = false;
isJoinModalActive: boolean = false;
isJoinConfirmationModalActive: boolean = false;
EventVisibility = EventVisibility;
EventStatus = EventStatus;
EventJoinOptions = EventJoinOptions;
RouteName = RouteName;
observer!: IntersectionObserver;
loadComments: boolean = false;
anonymousParticipation: boolean | null = null;
actorForConfirmation!: IPerson;
messageForConfirmation: string = '';
event: IEvent = new EventModel();
currentActor!: IPerson;
identity: IPerson = new Person();
config!: IConfig;
participations: IParticipant[] = [];
oldParticipationRole!: String;
showMap: boolean = false;
isReportModalActive: boolean = false;
isJoinModalActive: boolean = false;
isJoinConfirmationModalActive: boolean = false;
EventVisibility = EventVisibility;
EventStatus = EventStatus;
EventJoinOptions = EventJoinOptions;
RouteName = RouteName;
observer!: IntersectionObserver;
loadComments: boolean = false;
anonymousParticipation: boolean | null = null;
actorForConfirmation!: IPerson;
messageForConfirmation: string = '';
get eventTitle() {
if (!this.event) return undefined;
return this.event.title;
}
get eventTitle() {
if (!this.event) return undefined;
return this.event.title;
}
get eventDescription() {
if (!this.event) return undefined;
return this.event.description;
}
get eventDescription() {
if (!this.event) return undefined;
return this.event.description;
}
async mounted() {
this.identity = this.currentActor;
if (this.$route.hash.includes('#comment-')) {
this.loadComments = true;
}
try {
this.anonymousParticipation = await this.anonymousParticipationConfirmed();
} catch (e) {
if (e instanceof AnonymousParticipationNotFoundError) {
this.anonymousParticipation = null;
} else {
console.error(e);
}
}
this.observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry) {
this.loadComments = entry.isIntersecting || this.loadComments;
}
}
}, {
rootMargin: '-50px 0px -50px',
});
this.observer.observe(this.$refs.commentsObserver as Element);
this.$watch('eventDescription', function (eventDescription) {
if (!eventDescription) return;
const eventDescriptionElement = this.$refs.eventDescriptionElement as HTMLElement;
eventDescriptionElement.addEventListener('click', ($event) => {
// TODO: Find the right type for target
let {target}: { target: any } = $event;
while (target && target.tagName !== 'A') target = target.parentNode;
// handle only links that occur inside the component and do not reference external resources
if (target && target.matches('.hashtag') && target.href) {
// some sanity checks taken from vue-router:
// https://github.com/vuejs/vue-router/blob/dev/src/components/link.js#L106
const {altKey, ctrlKey, metaKey, shiftKey, button, defaultPrevented} = $event;
// don't handle with control keys
if (metaKey || altKey || ctrlKey || shiftKey) return;
// don't handle when preventDefault called
if (defaultPrevented) return;
// don't handle right clicks
if (button !== undefined && button !== 0) return;
// don't handle if `target="_blank"`
if (target && target.getAttribute) {
const linkTarget = target.getAttribute('target');
if (/\b_blank\b/i.test(linkTarget)) return;
}
// don't handle same page links/anchors
const url = new URL(target.href);
const to = url.pathname;
if (window.location.pathname !== to && $event.preventDefault) {
$event.preventDefault();
this.$router.push(to);
}
}
});
});
}
/**
* Delete the event, then redirect to home.
*/
async openDeleteEventModalWrapper() {
await this.openDeleteEventModal(this.event, this.currentActor);
}
async reportEvent(content: string, forward: boolean) {
this.isReportModalActive = false;
if (!this.event.organizerActor) return;
const eventTitle = this.event.title;
try {
await this.$apollo.mutate<IReport>({
mutation: CREATE_REPORT,
variables: {
eventId: this.event.id,
reporterId: this.currentActor.id,
reportedId: this.event.organizerActor.id,
content,
forward,
},
});
this.$notifier.success(this.$t('Event {eventTitle} reported', {eventTitle}) as string);
} catch (error) {
console.error(error);
}
}
joinEventWithConfirmation(actor: IPerson) {
this.isJoinConfirmationModalActive = true;
this.actorForConfirmation = actor;
}
async joinEvent(identity: IPerson, message: string | null = null) {
this.isJoinConfirmationModalActive = false;
this.isJoinModalActive = false;
try {
const {data} = await this.$apollo.mutate<{ joinEvent: IParticipant }>({
mutation: JOIN_EVENT,
variables: {
eventId: this.event.id,
actorId: identity.id,
message,
},
update: (store, {data}) => {
if (data == null) return;
const participationCachedData = store.readQuery<{ person: IPerson }>({
query: EVENT_PERSON_PARTICIPATION,
variables: {eventId: this.event.id, actorId: identity.id},
});
if (participationCachedData == null) return;
const {person} = participationCachedData;
if (person === null) {
console.error('Cannot update participation cache, because of null value.');
return;
}
person.participations.push(data.joinEvent);
store.writeQuery({
query: EVENT_PERSON_PARTICIPATION,
variables: {eventId: this.event.id, actorId: identity.id},
data: {person},
});
const cachedData = store.readQuery<{ event: IEvent }>({
query: FETCH_EVENT,
variables: {uuid: this.event.uuid}
});
if (cachedData == null) return;
const {event} = cachedData;
if (event === null) {
console.error('Cannot update event participant cache, because of null value.');
return;
}
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
event.participantStats.notApproved = event.participantStats.notApproved + 1;
} else {
event.participantStats.going = event.participantStats.going + 1;
event.participantStats.participant = event.participantStats.participant + 1;
}
store.writeQuery({query: FETCH_EVENT, variables: {uuid: this.uuid}, data: {event}});
},
});
if (data) {
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
this.participationRequestedMessage();
} else {
this.participationConfirmedMessage();
}
}
} catch (error) {
console.error(error);
}
}
confirmLeave() {
this.$buefy.dialog.confirm({
title: this.$t('Leaving event "{title}"', {title: this.event.title}) as string,
message: this.$t('Are you sure you want to cancel your participation at event "{title}"?', {title: this.event.title}) as string,
confirmText: this.$t('Leave event') as string,
cancelText: this.$t('Cancel') as string,
type: 'is-danger',
hasIcon: true,
onConfirm: () => {
if (this.currentActor.id) {
this.leaveEvent(this.event, this.currentActor.id);
}
},
});
}
@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);
}
async downloadIcsEvent() {
const data = await (await fetch(`${GRAPHQL_API_ENDPOINT}/events/${this.uuid}/export/ics`)).text();
const blob = new Blob([data], {type: 'text/calendar'});
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = `${this.event.title}.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
async handleErrors(errors: GraphQLError) {
if (errors[0].message.includes('not found') || errors[0].message.includes('has invalid value $uuid')) {
await this.$router.push({name: RouteName.PAGE_NOT_FOUND});
}
}
get actorIsParticipant() {
if (this.actorIsOrganizer) return true;
return this.participations.length > 0 && this.participations[0].role === ParticipantRole.PARTICIPANT;
}
get actorIsOrganizer() {
return this.participations.length > 0 && this.participations[0].role === ParticipantRole.CREATOR;
}
get endDate() {
return this.event.endsOn !== null && this.event.endsOn > this.event.beginsOn ? this.event.endsOn : this.event.beginsOn;
}
get twitterShareUrl(): string {
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(this.event.url)}&text=${this.event.title}`;
}
get facebookShareUrl(): string {
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(this.event.url)}`;
}
get linkedInShareUrl(): string {
return `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(this.event.url)}&title=${this.event.title}`;
}
get emailShareUrl(): string {
return `mailto:?to=&body=${this.event.url}${encodeURIComponent('\n\n')}${this.textDescription}&subject=${this.event.title}`;
}
get diasporaShareUrl(): string {
return `https://share.diasporafoundation.org/?title=${encodeURIComponent(this.event.title)}&url=${encodeURIComponent(this.event.url)}`;
}
get textDescription(): string {
const meta = document.querySelector("meta[property='og:description']");
if (!meta) return '';
const desc = meta.getAttribute('content') || '';
return desc.substring(0, 1000);
}
get eventCapacityOK(): boolean {
if (this.event.draft) return true;
if (!this.event.options.maximumAttendeeCapacity) return true;
return this.event.options.maximumAttendeeCapacity > this.event.participantStats.participant;
}
get numberOfPlacesStillAvailable(): number {
if (this.event.draft) return this.event.options.maximumAttendeeCapacity;
return this.event.options.maximumAttendeeCapacity - this.event.participantStats.participant;
}
urlToHostname(url: string): string | null {
try {
return (new URL(url)).hostname;
} catch (e) {
return null;
}
}
get physicalAddress(): Address | null {
if (!this.event.physicalAddress) return null;
return new Address(this.event.physicalAddress);
}
async anonymousParticipationConfirmed(): Promise<boolean> {
return await isParticipatingInThisEvent(this.uuid);
}
async cancelAnonymousParticipation() {
const token = await getLeaveTokenForParticipation(this.uuid) as String;
await this.leaveEvent(this.event, this.config.anonymous.actorId, token);
await removeAnonymousParticipation(this.uuid);
this.anonymousParticipation = null;
}
async mounted() {
this.identity = this.currentActor;
if (this.$route.hash.includes('#comment-')) {
this.loadComments = true;
}
try {
this.anonymousParticipation = await this.anonymousParticipationConfirmed();
} catch (e) {
if (e instanceof AnonymousParticipationNotFoundError) {
this.anonymousParticipation = null;
} else {
console.error(e);
}
}
this.observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry) {
this.loadComments = entry.isIntersecting || this.loadComments;
}
}
}, {
rootMargin: '-50px 0px -50px',
});
this.observer.observe(this.$refs.commentsObserver as Element);
this.$watch('eventDescription', function (eventDescription) {
if (!eventDescription) return;
const eventDescriptionElement = this.$refs.eventDescriptionElement as HTMLElement;
eventDescriptionElement.addEventListener('click', ($event) => {
// TODO: Find the right type for target
let { target }: { target: any } = $event;
while (target && target.tagName !== 'A') target = target.parentNode;
// handle only links that occur inside the component and do not reference external resources
if (target && target.matches('.hashtag') && target.href) {
// some sanity checks taken from vue-router:
// https://github.com/vuejs/vue-router/blob/dev/src/components/link.js#L106
const { altKey, ctrlKey, metaKey, shiftKey, button, defaultPrevented } = $event;
// don't handle with control keys
if (metaKey || altKey || ctrlKey || shiftKey) return;
// don't handle when preventDefault called
if (defaultPrevented) return;
// don't handle right clicks
if (button !== undefined && button !== 0) return;
// don't handle if `target="_blank"`
if (target && target.getAttribute) {
const linkTarget = target.getAttribute('target');
if (/\b_blank\b/i.test(linkTarget)) return;
}
// don't handle same page links/anchors
const url = new URL(target.href);
const to = url.pathname;
if (window.location.pathname !== to && $event.preventDefault) {
$event.preventDefault();
this.$router.push(to);
}
}
});
});
}
/**
* Delete the event, then redirect to home.
*/
async openDeleteEventModalWrapper() {
await this.openDeleteEventModal(this.event, this.currentActor);
}
async reportEvent(content: string, forward: boolean) {
this.isReportModalActive = false;
if (!this.event.organizerActor) return;
const eventTitle = this.event.title;
try {
await this.$apollo.mutate<IReport>({
mutation: CREATE_REPORT,
variables: {
eventId: this.event.id,
reporterId: this.currentActor.id,
reportedId: this.event.organizerActor.id,
content,
forward,
},
});
this.$notifier.success(this.$t('Event {eventTitle} reported', { eventTitle }) as string);
} catch (error) {
console.error(error);
}
}
joinEventWithConfirmation(actor: IPerson) {
this.isJoinConfirmationModalActive = true;
this.actorForConfirmation = actor;
}
async joinEvent(identity: IPerson, message: string | null = null) {
this.isJoinConfirmationModalActive = false;
this.isJoinModalActive = false;
try {
const { data } = await this.$apollo.mutate<{ joinEvent: IParticipant }>({
mutation: JOIN_EVENT,
variables: {
eventId: this.event.id,
actorId: identity.id,
message,
},
update: (store, { data }) => {
if (data == null) return;
const participationCachedData = store.readQuery<{ person: IPerson }>({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: this.event.id, actorId: identity.id },
});
if (participationCachedData == null) return;
const { person } = participationCachedData;
if (person === null) {
console.error('Cannot update participation cache, because of null value.');
return;
}
person.participations.push(data.joinEvent);
store.writeQuery({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: this.event.id, actorId: identity.id },
data: { person },
});
const cachedData = store.readQuery<{ event: IEvent }>({
query: FETCH_EVENT,
variables: { uuid: this.event.uuid },
});
if (cachedData == null) return;
const { event } = cachedData;
if (event === null) {
console.error('Cannot update event participant cache, because of null value.');
return;
}
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
event.participantStats.notApproved = event.participantStats.notApproved + 1;
} else {
event.participantStats.going = event.participantStats.going + 1;
event.participantStats.participant = event.participantStats.participant + 1;
}
store.writeQuery({ query: FETCH_EVENT, variables: { uuid: this.uuid }, data: { event } });
},
});
if (data) {
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
this.participationRequestedMessage();
} else {
this.participationConfirmedMessage();
}
}
} catch (error) {
console.error(error);
}
}
confirmLeave() {
this.$buefy.dialog.confirm({
title: this.$t('Leaving event "{title}"', { title: this.event.title }) as string,
message: this.$t('Are you sure you want to cancel your participation at event "{title}"?', { title: this.event.title }) as string,
confirmText: this.$t('Leave event') as string,
cancelText: this.$t('Cancel') as string,
type: 'is-danger',
hasIcon: true,
onConfirm: () => {
if (this.currentActor.id) {
this.leaveEvent(this.event, this.currentActor.id);
}
},
});
}
@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);
}
async downloadIcsEvent() {
const data = await (await fetch(`${GRAPHQL_API_ENDPOINT}/events/${this.uuid}/export/ics`)).text();
const blob = new Blob([data], { type: 'text/calendar' });
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = `${this.event.title}.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
async handleErrors(errors: GraphQLError) {
if (errors[0].message.includes('not found') || errors[0].message.includes('has invalid value $uuid')) {
await this.$router.push({ name: RouteName.PAGE_NOT_FOUND });
}
}
get actorIsParticipant() {
if (this.actorIsOrganizer) return true;
return this.participations.length > 0 && this.participations[0].role === ParticipantRole.PARTICIPANT;
}
get actorIsOrganizer() {
return this.participations.length > 0 && this.participations[0].role === ParticipantRole.CREATOR;
}
get endDate() {
return this.event.endsOn !== null && this.event.endsOn > this.event.beginsOn ? this.event.endsOn : this.event.beginsOn;
}
get twitterShareUrl(): string {
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(this.event.url)}&text=${this.event.title}`;
}
get facebookShareUrl(): string {
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(this.event.url)}`;
}
get linkedInShareUrl(): string {
return `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(this.event.url)}&title=${this.event.title}`;
}
get emailShareUrl(): string {
return `mailto:?to=&body=${this.event.url}${encodeURIComponent('\n\n')}${this.textDescription}&subject=${this.event.title}`;
}
get diasporaShareUrl(): string {
return `https://share.diasporafoundation.org/?title=${encodeURIComponent(this.event.title)}&url=${encodeURIComponent(this.event.url)}`;
}
get textDescription(): string {
const meta = document.querySelector("meta[property='og:description']");
if (!meta) return '';
const desc = meta.getAttribute('content') || '';
return desc.substring(0, 1000);
}
get eventCapacityOK(): boolean {
if (this.event.draft) return true;
if (!this.event.options.maximumAttendeeCapacity) return true;
return this.event.options.maximumAttendeeCapacity > this.event.participantStats.participant;
}
get numberOfPlacesStillAvailable(): number {
if (this.event.draft) return this.event.options.maximumAttendeeCapacity;
return this.event.options.maximumAttendeeCapacity - this.event.participantStats.participant;
}
urlToHostname(url: string): string | null {
try {
return (new URL(url)).hostname;
} catch (e) {
return null;
}
}
get physicalAddress(): Address | null {
if (!this.event.physicalAddress) return null;
return new Address(this.event.physicalAddress);
}
async anonymousParticipationConfirmed(): Promise<boolean> {
return await isParticipatingInThisEvent(this.uuid);
}
async cancelAnonymousParticipation() {
const token = await getLeaveTokenForParticipation(this.uuid) as String;
await this.leaveEvent(this.event, this.config.anonymous.actorId, token);
await removeAnonymousParticipation(this.uuid);
this.anonymousParticipation = null;
}
}
</script>
<style lang="scss" scoped>
@import "../../variables";

View File

@ -1,287 +1,308 @@
<template>
<div class="container is-widescreen">
<div v-if="group && groupMemberships && groupMemberships.includes(group.id)" class="block-container">
<div class="block-column">
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="group">
<ul>
<li>
<router-link :to="{ name: RouteName.MY_GROUPS }">{{ $t('My groups') }}</router-link>
</li>
<li class="is-active">
<router-link
:to="{ name: RouteName.GROUP, params:{ preferredUsername: group.preferredUsername } }">
{{ `@${group.preferredUsername}` }}
</router-link>
</li>
</ul>
</nav>
<!-- <section class="card-image" v-if="group.banner">-->
<!-- <figure class="image">-->
<!-- <img :src="group.banner.url"/>-->
<!-- </figure>-->
<!-- </section>-->
<section class="presentation">
<div class="media">
<div class="media-left">
<figure class="image is-128x128" v-if="group.avatar">
<img :src="group.avatar.url"/>
</figure>
<b-icon v-else size="is-large" icon="account-group"/>
</div>
<div class="media-content">
<h2>{{ group.name }}</h2>
<small class="has-text-grey">@{{ group.preferredUsername }}</small>
<span v-if="group.domain">{{ group.domain }}</span>
</div>
</div>
<div class="members">
<figure class="image is-48x48 is-rounded"
:title="$t(`@{username} ({role})`, {username: member.actor.preferredUsername, role: member.role})"
v-for="member in group.members.elements" :key="member.actor.id">
<img :src="member.actor.avatar.url"/>
</figure>
</div>
</section>
<section>
<subtitle>
{{ $t('Upcoming events') }}
</subtitle>
<div class="organized-events-wrapper" v-if="group.organizedEvents.total > 0">
<EventMinimalistCard
v-for="event in group.organizedEvents.elements"
:event="event"
:key="event.uuid"
class="organized-event"
/>
</div>
<router-link :to="{}">{{ $t('View all upcoming events') }}</router-link>
</section>
<section>
<subtitle>
{{ $t('Collections') }}
</subtitle>
<b-button type="is-primary">{{ $t('Créer une collection') }}</b-button>
<div class="columns" v-if="group.collections.elements.length > 0">
<span
v-for="collection in group.collections.elements"
:key="collection.id"
>{{ collection }}</span>
</div>
</section>
<div class="container is-widescreen">
<div
v-if="group && groupMemberships && groupMemberships.includes(group.id)"
class="block-container"
>
<div class="block-column">
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="group">
<ul>
<li>
<router-link :to="{ name: RouteName.MY_GROUPS }">{{ $t('My groups') }}</router-link>
</li>
<li class="is-active">
<router-link
:to="{ name: RouteName.GROUP, params:{ preferredUsername: group.preferredUsername } }"
>{{ `@${group.preferredUsername}` }}</router-link>
</li>
</ul>
</nav>
<!-- <section class="card-image" v-if="group.banner">-->
<!-- <figure class="image">-->
<!-- <img :src="group.banner.url"/>-->
<!-- </figure>-->
<!-- </section>-->
<section class="presentation">
<div class="media">
<div class="media-left">
<figure class="image is-128x128" v-if="group.avatar">
<img :src="group.avatar.url" />
</figure>
<b-icon v-else size="is-large" icon="account-group" />
</div>
<div class="block-column">
<section>
<p>{{ $t('Recent activity') }}</p>
</section>
<section>
<subtitle>
{{ $t('Public page') }}
</subtitle>
<p>{{ $t('Followed by {count} persons', { count: group.members.total }) }}</p>
<b-button type="is-light">{{ $t('Edit biography') }}</b-button>
<b-button type="is-primary">{{ $t('Post a public message') }}</b-button>
</section>
<section>
<subtitle>
{{ $t('Ongoing tasks') }}
</subtitle>
<b-button type="is-primary">{{ $t('Create a new task list') }}</b-button>
<div v-if="group.todoLists.elements.length > 0" v-for="todoList in group.todoLists.elements"
:key="todoList.id">
<router-link
:to="{ name: RouteName.TODO_LIST, params: { id: todoList.id } }"
>
<h3 class="is-size-3">{{ $tc('{title} ({count} todos)', todoList.todos.total, { count:
todoList.todos.total, title: todoList.title }) }}</h3>
</router-link>
<compact-todo :todo="todo" v-for="todo in todoList.todos.elements.slice(0, 3)" :key="todo.id"/>
</div>
<router-link :to="{ name: RouteName.TODO_LISTS }">{{ $t('View all todos') }}</router-link>
</section>
<section>
<subtitle>
{{ $t('Discussions') }}
</subtitle>
<conversation-list-item v-if="group.conversations.total > 0"
v-for="conversation in group.conversations.elements" :key="conversation.id"
:conversation="conversation"/>
<router-link :to="{ name: RouteName.CONVERSATION_LIST, params: { preferredUsername: group.preferredUsername }}">{{ $t('View all conversations') }}</router-link>
</section>
<div class="media-content">
<h2>{{ group.name }}</h2>
<small class="has-text-grey">@{{ group.preferredUsername }}</small>
<span v-if="group.domain">{{ group.domain }}</span>
</div>
</div>
<div v-else-if="group">
<section class="presentation">
<div class="media">
<div class="media-left">
<figure class="image is-128x128" v-if="group.avatar">
<img :src="group.avatar.url" alt=""/>
</figure>
<b-icon v-else size="is-large" icon="account-group"/>
</div>
<div class="media-content">
<h2>{{ group.name }}</h2>
<small class="has-text-grey">@{{ group.preferredUsername }}</small>
<span v-if="group.domain">{{ group.domain }}</span>
</div>
</div>
</section>
<section>
<subtitle>
{{ $t('Upcoming events') }}
</subtitle>
<div class="organized-events-wrapper" v-if="group.organizedEvents.total > 0">
<EventMinimalistCard
v-for="event in group.organizedEvents.elements"
:event="event"
:key="event.uuid"
class="organized-event"
/>
<router-link :to="{}">{{ $t('View all upcoming events') }}</router-link>
</div>
<span v-else>
{{ $t('No public upcoming events')}}
</span>
</section>
{{ group }}
</div>
<b-message v-else-if="!group && $apollo.loading === false" type="is-danger">
{{ $t('No group found') }}
</b-message>
</div>
<div class="members">
<figure
class="image is-48x48 is-rounded"
:title="$t(`@{username} ({role})`, {username: member.actor.preferredUsername, role: member.role})"
v-for="member in group.members.elements"
:key="member.actor.id"
>
<img :src="member.actor.avatar.url" />
</figure>
</div>
</section>
<section>
<subtitle>{{ $t('Upcoming events') }}</subtitle>
<div class="organized-events-wrapper" v-if="group.organizedEvents.total > 0">
<EventMinimalistCard
v-for="event in group.organizedEvents.elements"
:event="event"
:key="event.uuid"
class="organized-event"
/>
</div>
<router-link :to="{}">{{ $t('View all upcoming events') }}</router-link>
</section>
<section>
<subtitle>{{ $t('Resources') }}</subtitle>
<div v-if="group.resources.elements.length > 0">
<div v-for="resource in group.resources.elements" :key="resource.id">
<resource-item :resource="resource" v-if="resource.type !== 'folder'" :inline="true" />
<folder-item :resource="resource" :group="group" v-else :inline="true" />
</div>
</div>
<router-link
:to="{ name: RouteName.RESOURCE_FOLDER_ROOT, params: { preferredUsername: group.preferredUsername } }"
>{{ $t('View all resources') }}</router-link>
</section>
</div>
<div class="block-column">
<section>
<subtitle>{{ $t('Public page') }}</subtitle>
<p>{{ $t('Followed by {count} persons', { count: group.members.total }) }}</p>
<b-button type="is-light">{{ $t('Edit biography') }}</b-button>
<b-button type="is-primary">{{ $t('Post a public message') }}</b-button>
</section>
<section>
<subtitle>{{ $t('Ongoing tasks') }}</subtitle>
<div
v-if="group.todoLists.elements.length > 0"
v-for="todoList in group.todoLists.elements"
:key="todoList.id"
>
<router-link :to="{ name: RouteName.TODO_LIST, params: { id: todoList.id } }">
<h3 class="is-size-3">
{{ $tc('{title} ({count} todos)', todoList.todos.total, { count:
todoList.todos.total, title: todoList.title }) }}
</h3>
</router-link>
<compact-todo
:todo="todo"
v-for="todo in todoList.todos.elements.slice(0, 3)"
:key="todo.id"
/>
</div>
<router-link :to="{ name: RouteName.TODO_LISTS }">{{ $t('View all todos') }}</router-link>
</section>
<section>
<subtitle>{{ $t('Discussions') }}</subtitle>
<conversation-list-item
v-if="group.conversations.total > 0"
v-for="conversation in group.conversations.elements"
:key="conversation.id"
:conversation="conversation"
/>
<router-link
:to="{ name: RouteName.CONVERSATION_LIST, params: { preferredUsername: group.preferredUsername }}"
>{{ $t('View all conversations') }}</router-link>
</section>
</div>
</div>
<div v-else-if="group">
<section class="presentation">
<div class="media">
<div class="media-left">
<figure class="image is-128x128" v-if="group.avatar">
<img :src="group.avatar.url" alt />
</figure>
<b-icon v-else size="is-large" icon="account-group" />
</div>
<div class="media-content">
<h2>{{ group.name }}</h2>
<small class="has-text-grey">@{{ group.preferredUsername }}</small>
<span v-if="group.domain">{{ group.domain }}</span>
</div>
</div>
</section>
<section>
<subtitle>{{ $t('Upcoming events') }}</subtitle>
<div class="organized-events-wrapper" v-if="group.organizedEvents.total > 0">
<EventMinimalistCard
v-for="event in group.organizedEvents.elements"
:event="event"
:key="event.uuid"
class="organized-event"
/>
<router-link :to="{}">{{ $t('View all upcoming events') }}</router-link>
</div>
<span v-else>{{ $t('No public upcoming events')}}</span>
</section>
<!-- {{ group }}-->
<section>
<subtitle>{{ $t('Latest posts') }}</subtitle>
</section>
<b-button>Join group</b-button>
</div>
<b-message
v-else-if="!group && $apollo.loading === false"
type="is-danger"
>{{ $t('No group found') }}</b-message>
</div>
</template>
<script lang="ts">
import {Component, Prop, Vue} from 'vue-property-decorator';
import EventCard from '@/components/Event/EventCard.vue';
import {FETCH_GROUP, CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS} from '@/graphql/actor';
import {IActor, IGroup, IPerson} from '@/types/actor';
import Subtitle from '@/components/Utils/Subtitle.vue';
import {RouteName} from '@/router';
import CompactTodo from '@/components/Todo/CompactTodo.vue';
import EventMinimalistCard from "@/components/Event/EventMinimalistCard.vue";
import ConversationListItem from "@/components/Conversation/ConversationListItem.vue";
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import EventCard from "@/components/Event/EventCard.vue";
import {
FETCH_GROUP,
CURRENT_ACTOR_CLIENT,
PERSON_MEMBERSHIPS
} from "@/graphql/actor";
import { IActor, IGroup, IPerson } from "@/types/actor";
import Subtitle from "@/components/Utils/Subtitle.vue";
import { RouteName } from "@/router";
import CompactTodo from "@/components/Todo/CompactTodo.vue";
import EventMinimalistCard from "@/components/Event/EventMinimalistCard.vue";
import ConversationListItem from "@/components/Conversation/ConversationListItem.vue";
import ResourceItem from "@/components/Resource/ResourceItem.vue";
import FolderItem from "@/components/Resource/FolderItem.vue";
@Component({
apollo: {
group: {
query: FETCH_GROUP,
variables() {
return {
name: this.preferredUsername,
};
},
},
person: {
query: PERSON_MEMBERSHIPS,
variables() {
return {
id: this.currentActor.id
}
},
skip() {
return !this.currentActor || !this.currentActor.id;
}
},
currentActor: CURRENT_ACTOR_CLIENT,
},
components: {
ConversationListItem,
EventMinimalistCard,
CompactTodo,
Subtitle,
EventCard,
},
metaInfo() {
return {
// if no subcomponents specify a metaInfo.title, this title will be used
// @ts-ignore
title: this.groupTitle,
// all titles will be injected into this template
titleTemplate: '%s | Mobilizon',
meta: [
// @ts-ignore
{name: 'description', content: this.groupSummary},
],
};
},
})
export default class Group extends Vue {
@Prop({type: String, required: true}) preferredUsername!: string;
currentActor!: IActor;
person!: IPerson;
@Component({
apollo: {
group: {
query: FETCH_GROUP,
variables() {
return {
name: this.preferredUsername
};
}
},
person: {
query: PERSON_MEMBERSHIPS,
variables() {
return {
id: this.currentActor.id
};
},
skip() {
return !this.currentActor || !this.currentActor.id;
}
},
currentActor: CURRENT_ACTOR_CLIENT
},
components: {
ConversationListItem,
EventMinimalistCard,
CompactTodo,
Subtitle,
EventCard,
FolderItem,
ResourceItem
},
metaInfo() {
return {
// if no subcomponents specify a metaInfo.title, this title will be used
// @ts-ignore
title: this.groupTitle,
// all titles will be injected into this template
titleTemplate: "%s | Mobilizon",
meta: [
// @ts-ignore
{ name: "description", content: this.groupSummary }
]
};
}
})
export default class Group extends Vue {
@Prop({ type: String, required: true }) preferredUsername!: string;
currentActor!: IActor;
person!: IPerson;
group!: IGroup;
loading = true;
RouteName = RouteName;
group!: IGroup;
loading = true;
RouteName = RouteName;
get groupTitle() {
if (!this.group) return undefined;
return this.group.preferredUsername;
}
get groupSummary() {
if (!this.group) return undefined;
return this.group.summary;
}
get groupMemberships() {
if (!this.person || !this.person.id) return undefined;
return this.person.memberships.elements.map(({ parent: { id }}) => id);
}
@Watch("currentActor")
watchCurrentActor(currentActor: IActor, oldActor: IActor) {
if (currentActor.id && oldActor && currentActor.id !== oldActor.id) {
this.$apollo.queries.group.refetch();
}
}
get groupTitle() {
if (!this.group) return undefined;
return this.group.preferredUsername;
}
get groupSummary() {
if (!this.group) return undefined;
return this.group.summary;
}
get groupMemberships() {
if (!this.person || !this.person.id) return undefined;
return this.person.memberships.elements.map(({ parent: { id } }) => id);
}
}
</script>
<style lang="scss" scoped>
div.container {
background: white;
margin-bottom: 3rem;
padding: 2rem 0;
div.container {
background: white;
margin-bottom: 3rem;
padding: 2rem 0;
.block-container {
display: flex;
flex-wrap: wrap;
.block-container {
display: flex;
flex-wrap: wrap;
.block-column {
flex: 1;
margin: 0 2rem;
.block-column {
flex: 1;
margin: 0 2rem;
section {
/deep/ h3 span {
display: block;
}
.organized-events-wrapper {
display: flex;
flex-wrap: wrap;
.organized-event {
margin: 0.25rem 0;
}
}
&.presentation {
.media-left {
span.icon.is-large {
height: 5rem;
width: 5rem;
/deep/ i.mdi.mdi-account-group.mdi-48px:before {
font-size: 100px;
}
}
}
.media-content {
h2 {
color: #3C376E;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif;
font-size: 1.5rem;
font-weight: 700;
}
}
}
}
}
section {
/deep/ h3 span {
display: block;
}
.organized-events-wrapper {
display: flex;
flex-wrap: wrap;
.organized-event {
margin: 0.25rem 0;
}
}
&.presentation {
.media-left {
span.icon.is-large {
height: 5rem;
width: 5rem;
/deep/ i.mdi.mdi-account-group.mdi-48px:before {
font-size: 100px;
}
}
}
.media-content {
h2 {
color: #3c376e;
font-family: "Liberation Sans", "Helvetica Neue", Roboto,
Helvetica, Arial, serif;
font-size: 1.5rem;
font-weight: 700;
}
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,165 @@
<template>
<aside class="section container">
<h1 class="title">{{ $t('Settings') }}</h1>
<div class="columns">
<SettingsMenu class="column is-one-quarter-desktop" :menu="menu" />
<div class="column">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li v-for="route in routes.get($route.name)" :class="{ 'is-active': route.to.name === $route.name }"><router-link :to="{ name: route.to.name }">{{ route.title }}</router-link></li>
</ul>
</nav>
<router-view />
</div>
</div>
</aside>
</template>
<script lang="ts">
import { Component, Vue, Watch } from 'vue-property-decorator';
import SettingsMenu from '@/components/Settings/SettingsMenu.vue';
import { RouteName } from '@/router';
import { ISettingMenuSection } from '@/types/setting-menu.model';
import { Route } from 'vue-router';
import { IGroup } from '@/types/actor';
import { FETCH_GROUP } from '@/graphql/actor';
@Component({
components: { SettingsMenu },
apollo: {
group: {
query: FETCH_GROUP,
},
},
})
export default class Settings extends Vue {
RouteName = RouteName;
menu: ISettingMenuSection[] = [];
group!: IGroup[];
newIdentity!: ISettingMenuSection;
mounted() {
this.newIdentity = {
title: this.$t('New profile') as string,
to: { name: RouteName.CREATE_IDENTITY } as Route,
};
this.menu = [
{
title: this.$t('Account') as string,
to: { name: RouteName.ACCOUNT_SETTINGS } as Route,
items: [
{
title: this.$t('General') as string,
to: { name: RouteName.ACCOUNT_SETTINGS_GENERAL } as Route,
},
{
title: this.$t('Preferences') as string,
to: { name: RouteName.PREFERENCES } as Route,
},
{
title: this.$t('Notifications') as string,
to: { name: RouteName.NOTIFICATIONS } as Route,
},
],
},
{
title: this.$t('Profiles') as string,
to: { name: RouteName.IDENTITIES } as Route,
items: [this.newIdentity],
},
{
title: this.$t('Moderation') as string,
to: { name: RouteName.MODERATION } as Route,
items: [
{
title: this.$t('Reports') as string,
to: { name: RouteName.REPORTS } as Route,
items: [
{
title: this.$t('Report') as string,
to: { name: RouteName.REPORT } as Route,
},
],
},
{
title: this.$t('Moderation log') as string,
to: { name: RouteName.REPORT_LOGS } as Route,
},
],
},
{
title: this.$t('Admin') as string,
to: { name: RouteName.ADMIN } as Route,
items: [
{
title: this.$t('Dashboard') as string,
to: { name: RouteName.ADMIN_DASHBOARD } as Route,
},
{
title: this.$t('Instance settings') as string,
to: { name: RouteName.ADMIN_SETTINGS } as Route,
},
{
title: this.$t('Federation') as string,
to: { name: RouteName.RELAYS } as Route,
items: [
{
title: this.$t('Followings') as string,
to: { name: RouteName.RELAY_FOLLOWINGS } as Route,
},
{
title: this.$t('Followers') as string,
to: { name: RouteName.RELAY_FOLLOWERS } as Route,
},
],
},
],
},
];
}
@Watch('identities')
updateIdentities(identities) {
if (!identities) return;
if (!this.menu[1].items) return;
this.menu[1].items = [];
this.menu[1].items.push(...identities.map((identity: IPerson) => {
return {
to: { name: RouteName.UPDATE_IDENTITY, params: { identityName: identity.preferredUsername } } as unknown as Route,
title: `@${identity.preferredUsername}`,
};
}));
this.menu[1].items.push(this.newIdentity);
}
get routes(): Map<string, Route[]> {
return this.getPath(this.menu);
}
getPath(object: ISettingMenuSection[]) {
function iter(menu: ISettingMenuSection[]|ISettingMenuSection, acc: ISettingMenuSection[]) {
if (Array.isArray(menu)) {
return menu.forEach((item: ISettingMenuSection) => {
iter(item, acc.concat(item));
});
}
if (menu.items && menu.items.length > 0) {
return menu.items.forEach((item: ISettingMenuSection) => {
iter(item, acc.concat(item));
});
}
result.set(menu.to.name, acc);
}
const result = new Map();
iter(object, []);
return result;
}
}
</script>
<style lang="scss" scoped>
aside.section {
padding-top: 1rem;
}
</style>

View File

@ -0,0 +1,515 @@
<template>
<div class="container section" v-if="resource">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link
:to="{ name: RouteName.GROUP, params: { preferredUsername: resource.actor.preferredUsername } }"
>
{{
resource.actor.preferredUsername }}
</router-link>
</li>
<li>
<router-link
:to="{ name: RouteName.RESOURCE_FOLDER_ROOT, params: { preferredUsername: resource.actor.preferredUsername } }"
>
{{
$t('Resources') }}
</router-link>
</li>
<li
v-if="resource.path !== '/'"
:class="{ 'is-active': index + 1 === resourcePathArray(resource).length}"
v-for="(pathFragment, index) in resourcePathArray(resource)"
:key="pathFragment"
>
<router-link
:to="{ name: RouteName.RESOURCE_FOLDER, params: { path: resourcePathArray(resource).slice(0, index + 1), preferredUsername: resource.actor.preferredUsername } }"
>{{ pathFragment }}</router-link>
</li>
<li>
<b-dropdown aria-role="list">
<b-button class="button is-primary" slot="trigger">+</b-button>
<b-dropdown-item aria-role="listitem" @click="createFolderModal">
<b-icon icon="folder" />
{{ $t('New folder') }}
</b-dropdown-item>
<b-dropdown-item aria-role="listitem" @click="createLinkResourceModal = true">
<b-icon icon="link" />
{{ $t('New link') }}
</b-dropdown-item>
<hr class="dropdown-divider" />
<b-dropdown-item
aria-role="listitem"
v-for="resourceProvider in config.resourceProviders"
:key="resourceProvider.software"
@click="createResourceFromProvider(resourceProvider)"
>
<b-icon :icon="mapServiceTypeToIcon[resourceProvider.software]" />
{{ createSentenceForType(resourceProvider.software) }}
</b-dropdown-item>
</b-dropdown>
</li>
</ul>
</nav>
<section>
<div class="list-header">
<div class="list-header-right">
<b-checkbox v-model="checkedAll" />
<div class="actions" v-if="validCheckedResources.length > 0">
<small>
{{ $tc('No resources selected', validCheckedResources.length, { count:
validCheckedResources.length }) }}
</small>
<b-button
type="is-danger"
icon-right="delete"
size="is-small"
@click="deleteMultipleResources"
>{{ $t('Delete') }}</b-button>
</div>
</div>
</div>
<draggable v-model="resource.children.elements" :sort="false" :group="groupObject">
<transition-group>
<div v-for="localResource in resource.children.elements" :key="localResource.id">
<div class="resource-item">
<div
class="resource-checkbox"
:class="{ checked: checkedResources[localResource.id] }"
>
<b-checkbox v-model="checkedResources[localResource.id]" />
</div>
<resource-item
:resource="localResource"
v-if="localResource.type !== 'folder'"
@delete="deleteResource"
@rename="handleRename"
/>
<folder-item
:resource="localResource"
:group="resource.actor"
@delete="deleteResource"
@rename="handleRename"
v-else
/>
</div>
</div>
</transition-group>
</draggable>
</section>
<b-modal :active.sync="renameModal" has-modal-card>
<div class="modal-card">
<section class="modal-card-body">
<form @submit.prevent="renameResource">
<b-field :label="$t('Title')">
<b-input aria-required="true" v-model="updatedResource.title" />
</b-field>
<b-button native-type="submit">{{ $t('Rename resource') }}</b-button>
</form>
</section>
</div>
</b-modal>
<b-modal :active.sync="createResourceModal" has-modal-card>
<div class="modal-card">
<section class="modal-card-body">
<form @submit.prevent="createResource">
<b-field :label="$t('Title')">
<b-input aria-required="true" v-model="newResource.title" />
</b-field>
<b-button native-type="submit">{{ createResourceButtonLabel }}</b-button>
</form>
</section>
</div>
</b-modal>
<b-modal :active.sync="createLinkResourceModal" has-modal-card>
<div class="modal-card">
<section class="modal-card-body">
<form @submit.prevent="createResource">
<b-field :label="$t('URL')">
<b-input
type="url"
required
v-model="newResource.resourceUrl"
@blur="previewResource"
/>
</b-field>
<div class="new-resource-preview" v-if="newResource.title">
<resource-item :resource="newResource" />
</div>
<b-field :label="$t('Title')">
<b-input aria-required="true" v-model="newResource.title" />
</b-field>
<b-field :label="$t('Text')">
<b-input type="textarea" v-model="newResource.summary" />
</b-field>
<b-button native-type="submit">{{ $t('Create resource') }}</b-button>
</form>
</section>
</div>
</b-modal>
</div>
</template>
<script lang="ts">
import { Component, Mixins, Prop, Watch } from "vue-property-decorator";
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
import { IActor } from "@/types/actor";
import { RouteName } from "@/router";
import { IResource, mapServiceTypeToIcon } from "@/types/resource";
import {
CREATE_RESOURCE,
DELETE_RESOURCE,
PREVIEW_RESOURCE_LINK,
GET_RESOURCE,
UPDATE_RESOURCE
} from "@/graphql/resources";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import ResourceItem from "@/components/Resource/ResourceItem.vue";
import FolderItem from "@/components/Resource/FolderItem.vue";
import ResourceMixin from "@/mixins/resource";
import Draggable from "vuedraggable";
@Component({
components: { FolderItem, ResourceItem, Draggable },
apollo: {
resource: {
query: GET_RESOURCE,
variables() {
let path = Array.isArray(this.$route.params.path)
? this.$route.params.path.join("/")
: this.$route.params.path || this.path;
path = path[0] !== "/" ? `/${path}` : path;
return {
path,
username: this.$route.params.preferredUsername
};
}
},
config: CONFIG,
currentActor: CURRENT_ACTOR_CLIENT
}
})
export default class Resources extends Mixins(ResourceMixin) {
@Prop({ required: true }) path!: string;
resource!: IResource;
config!: IConfig;
currentActor!: IActor;
RouteName = RouteName;
newResource: IResource = {
title: "",
summary: "",
resourceUrl: "",
children: { elements: [], total: 0 },
metadata: {},
type: "link"
};
updatedResource: IResource = {
title: "",
resourceUrl: '',
metadata: {},
children: { elements: [], total: 0 },
path: undefined
};
checkedResources: object = {};
validCheckedResources: string[] = [];
checkedAll: boolean = false;
createResourceModal: boolean = false;
createLinkResourceModal: boolean = false;
renameModal: boolean = false;
groupObject: object = {
name: "resources",
pull: "clone",
put: true
};
mapServiceTypeToIcon = mapServiceTypeToIcon;
async createResource() {
try {
const { data } = await this.$apollo.mutate({
mutation: CREATE_RESOURCE,
variables: {
title: this.newResource.title,
summary: this.newResource.summary,
actorId: this.resource.actor?.id,
resourceUrl: this.newResource.resourceUrl,
parentId:
this.resource.id && this.resource.id.startsWith("root_")
? null
: this.resource.id,
path: `${this.resource.path === "/" ? "" : this.resource.path}/${
this.newResource.title
}`,
type: this.newResource.type
},
update: (store, { data: { createResource } }) => {
if (createResource == null) return;
const cachedData = store.readQuery<{ resource: IResource }>({
query: GET_RESOURCE,
variables: {
path: this.resource.path,
username: this.resource.actor?.preferredUsername
}
});
if (cachedData == null) return;
const { resource } = cachedData;
if (resource == null) {
console.error(
"Cannot update resource cache, because of null value."
);
return;
}
const newResource: IResource = createResource;
resource.children.elements = resource.children.elements.concat([
newResource
]);
store.writeQuery({
query: GET_RESOURCE,
variables: {
path: this.resource.path,
username: this.resource.actor?.preferredUsername
},
data: { resource }
});
}
});
this.createLinkResourceModal = false;
this.createResourceModal = false;
this.newResource.title = "";
this.newResource.summary = "";
this.newResource.resourceUrl = "";
} catch (err) {
console.error(err);
}
}
async previewResource() {
if (this.newResource.resourceUrl === "") return;
const { data } = await this.$apollo.mutate({
mutation: PREVIEW_RESOURCE_LINK,
variables: {
resourceUrl: this.newResource.resourceUrl
}
});
this.newResource.title = data.previewResourceLink.title;
this.newResource.summary = data.previewResourceLink.description;
this.newResource.metadata = data.previewResourceLink;
this.newResource.type = "link";
}
createSentenceForType(type: string) {
switch (type) {
case "pad":
return this.$t("Create a pad");
case "calc":
return this.$t("Create a calc");
case "visio":
return this.$t("Create a visioconference");
}
}
createFolderModal() {
this.newResource.type = "folder";
this.createResourceModal = true;
}
createResourceFromProvider(provider) {
console.log(provider);
this.newResource.resourceUrl = this.generateFullResourceUrl(provider);
this.newResource.type = provider.software;
this.createResourceModal = true;
}
generateFullResourceUrl(provider): string {
const randomString = [...Array(10)]
.map(() => Math.random().toString(36)[3])
.join("")
.replace(/(.|$)/g, c =>
c[!Math.round(Math.random()) ? "toString" : "toLowerCase"]()
);
switch (provider.type) {
case "ethercalc":
case "etherpad":
case "jitsi":
default:
return `${provider.endpoint}${randomString}`;
}
}
get createResourceButtonLabel() {
switch (this.newResource.type) {
case "folder":
return this.$t("Create folder");
case "pad":
return this.$t("Create pad");
case "calc":
return this.$t("Create calc");
case "visio":
return this.$t("Create visio");
}
}
@Watch("checkedAll")
watchCheckedAll() {
this.resource.children.elements.forEach(({ id }) => {
if (!id) return;
this.checkedResources[id] = this.checkedAll;
});
}
@Watch("checkedResources", { deep: true })
watchValidCheckedResources(): string[] {
const validCheckedResources: string[] = [];
for (const [key, value] of Object.entries(this.checkedResources)) {
if (value) {
validCheckedResources.push(key);
}
}
return (this.validCheckedResources = validCheckedResources);
}
async deleteMultipleResources() {
for (const resourceID of this.validCheckedResources) {
await this.deleteResource(resourceID);
}
}
async deleteResource(resourceID: string) {
try {
await this.$apollo.mutate({
mutation: DELETE_RESOURCE,
variables: {
id: resourceID
},
update: (store, { data: { deleteResource } }) => {
if (deleteResource == null) return;
const cachedData = store.readQuery<{ resource: IResource }>({
query: GET_RESOURCE,
variables: {
path: this.resource.path,
username: this.resource.actor?.preferredUsername
}
});
if (cachedData == null) return;
const { resource } = cachedData;
if (resource == null) {
console.error(
"Cannot update resource cache, because of null value."
);
return;
}
const oldResource: IResource = deleteResource;
resource.children.elements = resource.children.elements.filter(
resource => resource.id !== oldResource.id
);
store.writeQuery({
query: GET_RESOURCE,
variables: {
path: this.resource.path,
username: this.resource.actor?.preferredUsername
},
data: { resource }
});
}
});
delete this.validCheckedResources[resourceID];
delete this.checkedResources[resourceID];
} catch (e) {
console.error(e);
}
}
handleRename(resource: IResource) {
this.renameModal = true;
this.updatedResource = resource;
}
async renameResource() {
await this.updateResource(this.updatedResource);
}
async updateResource(resource: IResource) {
try {
await this.$apollo.mutate<{ updateResource: IResource }>({
mutation: UPDATE_RESOURCE,
variables: {
id: resource.id,
title: resource.title,
parentId: resource.parent?.id,
path: resource.path
}
});
} catch (e) {
console.error(e);
}
}
}
</script>
<style lang="scss" scoped>
nav.breadcrumb ul {
align-items: center;
li:last-child .dropdown {
margin-left: 5px;
a {
justify-content: left;
color: inherit;
padding: 0.375rem 1rem;
}
}
}
.list-header {
display: flex;
justify-content: space-between;
.list-header-right {
display: flex;
align-items: center;
.actions {
margin-right: 5px;
& > * {
margin-left: 5px;
}
}
}
}
.resource-item,
.new-resource-preview {
display: flex;
font-size: 14px;
border: 1px solid #c0cdd9;
border-radius: 4px;
color: #444b5d;
margin-top: 14px;
.resource-checkbox {
align-self: center;
padding: 0 3px 0 10px;
opacity: 0.3;
}
&:hover .resource-checkbox,
.resource-checkbox.checked {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,348 @@
<template>
<div class="container section" v-if="group">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
<router-link
:to="{ name: RouteName.GROUP, params: { preferredUsername: group.preferredUsername } }"
>
{{
group.preferredUsername }}
</router-link>
</li>
<li class="is-active">
<router-link
:to="{ name: RouteName.RESOURCES, params: { preferredUsername: group.preferredUsername } }"
>
{{
$t('Resources') }}
</router-link>
</li>
</ul>
</nav>
<section>
<div class="list-header">
<b-dropdown aria-role="list">
<b-button class="button is-primary" slot="trigger">+</b-button>
<b-dropdown-item aria-role="listitem" @click="createFolderModal = true">
<b-icon icon="folder" />
{{ $t('New folder') }}
</b-dropdown-item>
<b-dropdown-item aria-role="listitem" @click="createLinkResourceModal = true">
<b-icon icon="link" />
{{ $t('New link') }}
</b-dropdown-item>
<hr class="dropdown-divider" />
<b-dropdown-item
aria-role="listitem"
v-for="resourceProvider in config.resourceProviders"
:key="resourceProvider.software"
>
<b-icon :icon="mapServiceTypeToIcon[resourceProvider.software]" />
{{ createSentenceForType(resourceProvider.software) }}
</b-dropdown-item>
</b-dropdown>
<div class="list-header-right">
<div class="actions" v-if="validCheckedResources.length > 0">
<small>
{{ $tc('No resources selected', validCheckedResources.length, { count:
validCheckedResources.length }) }}
</small>
<b-button
type="is-danger"
icon-right="delete"
size="is-small"
@click="deleteMultipleResources"
>{{ $t('Delete') }}</b-button>
</div>
<b-checkbox v-model="checkedAll" />
</div>
</div>
<div v-for="resource in group.resources.elements" :key="resource.id">
<div class="resource-item">
<resource-item :resource="resource" v-if="resource.type !== 'folder'" @delete="deleteResource" />
<folder-item :resource="resource" :group="group" v-else />
<div class="resource-checkbox" :class="{ checked: checkedResources[resource.id] }">
<b-checkbox v-model="checkedResources[resource.id]" />
</div>
</div>
</div>
</section>
<b-modal :active.sync="createFolderModal" has-modal-card>
<div class="modal-card">
<section class="modal-card-body">
<form @submit.prevent="createResource('folder')">
<b-field :label="$t('Title')">
<b-input aria-required="true" v-model="newResource.title" />
</b-field>
<b-button native-type="submit">{{ $t('Create folder') }}</b-button>
</form>
</section>
</div>
</b-modal>
<b-modal :active.sync="createLinkResourceModal" has-modal-card>
<div class="modal-card">
<section class="modal-card-body">
<form @submit.prevent="createResource()">
<b-field :label="$t('URL')">
<b-input
type="url"
required
v-model="newResource.resourceUrl"
@blur="previewResource"
/>
</b-field>
<div class="new-resource-preview" v-if="newResource.title">
<resource-item :resource="newResource" />
</div>
<b-field :label="$t('Title')">
<b-input aria-required="true" v-model="newResource.title" />
</b-field>
<b-field :label="$t('Text')">
<b-input type="textarea" v-model="newResource.summary" />
</b-field>
<b-button native-type="submit">{{ $t('Create resource') }}</b-button>
</form>
</section>
</div>
</b-modal>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator';
import { CURRENT_ACTOR_CLIENT, FETCH_GROUP } from '@/graphql/actor';
import { IActor, IGroup } from '@/types/actor';
import { RouteName } from '@/router';
import { IResource } from '@/types/resource';
import {
CREATE_RESOURCE,
DELETE_RESOURCE,
PREVIEW_RESOURCE_LINK,
} from '@/graphql/resources';
import { CONFIG } from '@/graphql/config';
import { IConfig } from '@/types/config.model';
import ResourceItem from '@/components/Resource/ResourceItem.vue';
import FolderItem from '@/components/Resource/FolderItem.vue';
@Component({
components: { FolderItem, ResourceItem },
apollo: {
group: {
query: FETCH_GROUP,
variables() {
return {
name: this.$route.params.preferredUsername,
};
},
},
config: CONFIG,
currentActor: CURRENT_ACTOR_CLIENT,
},
})
export default class Resources extends Vue {
@Prop({ type: String, required: true }) preferredUsername!: string;
group!: IGroup;
config!: IConfig;
currentActor!: IActor;
RouteName = RouteName;
newResource: IResource = {
title: '',
summary: '',
resourceUrl: '',
children: { elements: [], total: 0 },
metadata: {},
};
checkedResources: object = {};
validCheckedResources: string[] = [];
checkedAll: boolean = false;
createFolderModal: boolean = false;
createLinkResourceModal: boolean = false;
mapServiceTypeToIcon: object = {
pad: 'file-document-outline',
calc: 'google-spreadsheet',
visio: 'webcam',
};
async createResource(type: string = 'link') {
try {
const { data } = await this.$apollo.mutate({
mutation: CREATE_RESOURCE,
variables: {
title: this.newResource.title,
summary: this.newResource.summary,
actorId: this.group.id,
resourceUrl: this.newResource.resourceUrl,
path: `/${this.newResource.title}`,
type,
},
update: (store, { data: { createResource } }) => {
if (createResource == null) return;
const cachedData = store.readQuery<{ group: IGroup }>({
query: FETCH_GROUP,
variables: { name: this.group.preferredUsername },
});
if (cachedData == null) return;
const { group } = cachedData;
if (group == null) {
console.error('Cannot update group cache, because of null value.');
return;
}
const newResource: IResource = createResource;
group.resources.elements = group.resources.elements.concat([
newResource,
]);
store.writeQuery({
query: FETCH_GROUP,
variables: { name: this.group.preferredUsername },
data: { group },
});
},
});
this.createLinkResourceModal = false;
this.createFolderModal = false;
this.newResource.title = '';
this.newResource.summary = '';
this.newResource.resourceUrl = '';
} catch (err) {
console.error(err);
}
}
async previewResource() {
if (this.newResource.resourceUrl === '') return;
const { data } = await this.$apollo.mutate({
mutation: PREVIEW_RESOURCE_LINK,
variables: {
resourceUrl: this.newResource.resourceUrl,
},
});
this.newResource.title = data.previewResourceLink.title;
this.newResource.summary = data.previewResourceLink.description;
this.newResource.metadata = data.previewResourceLink;
}
createSentenceForType(type: string) {
switch (type) {
case 'pad':
return this.$t('Create a pad');
case 'calc':
return this.$t('Create a calc');
case 'visio':
return this.$t('Create a visioconference');
}
}
@Watch('checkedAll')
watchCheckedAll() {
this.group.resources.elements.forEach(({ id }) => {
if (!id) return;
this.checkedResources[id] = this.checkedAll;
});
}
@Watch('checkedResources', { deep: true })
watchValidCheckedResources(): string[] {
const validCheckedResources: string[] = [];
for (const [key, value] of Object.entries(this.checkedResources)) {
if (value) {
validCheckedResources.push(key);
}
}
return (this.validCheckedResources = validCheckedResources);
}
async deleteMultipleResources() {
for (const resourceID of this.validCheckedResources) {
await this.deleteResource(resourceID);
}
}
async deleteResource(resourceID: string) {
try {
await this.$apollo.mutate({
mutation: DELETE_RESOURCE,
variables: {
id: resourceID,
},
update: (store, { data: { deleteResource } }) => {
if (deleteResource == null) return;
const cachedData = store.readQuery<{ group: IGroup }>({
query: FETCH_GROUP,
variables: { name: this.group.preferredUsername },
});
if (cachedData == null) return;
const { group } = cachedData;
if (group == null) {
console.error('Cannot update group cache, because of null value.');
return;
}
const oldResource: IResource = deleteResource;
group.resources.elements = group.resources.elements.filter(
resource => resource.id !== oldResource.id,
);
store.writeQuery({
query: FETCH_GROUP,
variables: { name: this.group.preferredUsername },
data: { group },
});
},
});
delete this.validCheckedResources[resourceID];
delete this.checkedResources[resourceID];
} catch (e) {
console.error(e);
}
}
}
</script>
<style lang="scss" scoped>
.list-header {
display: flex;
justify-content: space-between;
.list-header-right {
display: flex;
align-items: center;
.actions {
margin-right: 5px;
& > * {
margin-left: 5px;
}
}
}
}
.resource-item,
.new-resource-preview {
display: flex;
font-size: 14px;
border: 1px solid #c0cdd9;
border-radius: 4px;
color: #444b5d;
margin-top: 14px;
.resource-checkbox {
align-self: center;
padding: 0 3px 0 10px;
visibility: hidden;
}
&:hover .resource-checkbox,
.resource-checkbox.checked {
visibility: visible;
}
}
</style>

View File

@ -3,6 +3,7 @@
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><router-link :to="{ name: RouteName.GROUP, params: { preferredUsername: todoList.actor.preferredUsername } }">{{ todoList.actor.preferredUsername }}</router-link></li>
<li><router-link :to="{ name: RouteName.TODO_LISTS, params: { preferredUsername: todoList.actor.preferredUsername } }">{{ $t('Task lists') }}</router-link></li>
<li class="is-active"><router-link :to="{ name: RouteName.TODO_LIST, params: { id: todoList.id } }">{{ todoList.title }}</router-link></li>
</ul>
</nav>
@ -79,4 +80,4 @@ export default class TodoList extends Vue {
this.newTodo = { title: '', status: false };
}
}
</script>
</script>

View File

@ -1,15 +1,27 @@
<template>
<section class="container section" v-if="group">
<form class="form" @submit.prevent="createNewTodoList">
<b-field :label="$t('List title')">
<b-input v-model="newTodoList.title" />
</b-field>
<b-button native-type="submit">{{ $t('Add this list') }}</b-button>
</form>
<div v-for="todoList in todoLists" :key="todoList.id">
<pre>{{ todoList }}</pre>
</div>
</section>
<div class="container section" v-if="group">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li><router-link :to="{ name: RouteName.GROUP, params: { preferredUsername: group.preferredUsername } }">{{ group.preferredUsername }}</router-link></li>
<li class="is-active"><router-link :to="{ name: RouteName.TODO_LISTS, params: { preferredUsername: group.preferredUsername } }">{{ $t('Task lists') }}</router-link></li>
</ul>
</nav>
<section>
<form class="form" @submit.prevent="createNewTodoList">
<b-field :label="$t('List title')">
<b-input v-model="newTodoList.title"/>
</b-field>
<b-button native-type="submit">{{ $t('Create a new list') }}</b-button>
</form>
<div v-for="todoList in todoLists" :key="todoList.id">
<router-link :to="{ name: RouteName.TODO_LIST, params: { id: todoList.id } }">
<h3 class="is-size-3">{{ $tc('{title} ({count} todos)', todoList.todos.total, { count:
todoList.todos.total, title: todoList.title }) }}</h3>
</router-link>
<compact-todo :todo="todo" v-for="todo in todoList.todos.elements" :key="todo.id"/>
</div>
</section>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
@ -17,6 +29,8 @@ import { FETCH_GROUP } from '@/graphql/actor';
import { IGroup } from '@/types/actor';
import { ITodoList } from '@/types/todos';
import { CREATE_TODO_LIST } from '@/graphql/todos';
import CompactTodo from '@/components/Todo/CompactTodo.vue';
import { RouteName } from '@/router';
@Component({
apollo: {
@ -29,11 +43,15 @@ import { CREATE_TODO_LIST } from '@/graphql/todos';
},
},
},
components: {
CompactTodo,
},
})
export default class TodoLists extends Vue {
@Prop({ type: String, required: true }) preferredUsername!: string;
group!: IGroup;
newTodoList: ITodoList = { title: '', id: '', todos: { elements: [], total: 0 } };
RouteName = RouteName;
get todoLists() {
return this.group.todoLists.elements;
@ -53,4 +71,4 @@ export default class TodoLists extends Vue {
});
}
}
</script>
</script>

File diff suppressed because it is too large Load Diff

View File

@ -10,11 +10,23 @@ defmodule Mobilizon.Federation.ActivityPub do
import Mobilizon.Federation.ActivityPub.Utils
alias Mobilizon.{Actors, Config, Conversations, Events, Reports, Share, Todos, Users}
alias Mobilizon.{
Actors,
Config,
Conversations,
Events,
Reports,
Resources,
Share,
Todos,
Users
}
alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Reports.Report
alias Mobilizon.Resources.Resource
alias Mobilizon.Todos.{Todo, TodoList}
alias Mobilizon.Tombstone
@ -33,6 +45,7 @@ defmodule Mobilizon.Federation.ActivityPub do
alias Mobilizon.Federation.WebFinger
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
alias Mobilizon.Service.RichMedia.Parser
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Email.{Admin, Mailer}
@ -42,7 +55,7 @@ defmodule Mobilizon.Federation.ActivityPub do
@doc """
Wraps an object into an activity
"""
@spec create_activity(map, boolean) :: {:ok, Activity.t()}
@spec create_activity(map(), boolean()) :: {:ok, Activity.t()}
def create_activity(map, local \\ true) when is_map(map) do
with map <- lazy_put_activity_defaults(map) do
{:ok,
@ -77,7 +90,7 @@ defmodule Mobilizon.Federation.ActivityPub do
{:existing_comment, nil} <- {:existing_comment, Conversations.get_comment_from_url(url)},
{:existing_actor, {:error, _err}} <-
{:existing_actor, get_or_fetch_actor_by_url(url)},
{:ok, %{body: body, status_code: code}} when code in 200..299 <-
{:ok, %HTTPoison.Response{body: body, status_code: code}} when code in 200..299 <-
HTTPoison.get(
url,
headers,
@ -180,6 +193,7 @@ defmodule Mobilizon.Federation.ActivityPub do
:group -> create_group(args, additional)
:todo_list -> create_todo_list(args, additional)
:todo -> create_todo(args, additional)
:resource -> create_resource(args, additional)
end),
{:ok, activity} <- create_activity(create_data, local),
:ok <- maybe_federate(activity) do
@ -212,6 +226,7 @@ defmodule Mobilizon.Federation.ActivityPub do
:comment -> update_comment(old_entity, args, additional)
:actor -> update_actor(old_entity, args, additional)
:todo -> update_todo(old_entity, args, additional)
:resource -> update_resource(old_entity, args, additional)
end),
{:ok, activity} <- create_activity(update_data, local),
:ok <- maybe_federate(activity) do
@ -406,6 +421,24 @@ defmodule Mobilizon.Federation.ActivityPub do
end
end
def delete(%Resource{url: url, actor: %Actor{url: actor_url}} = resource, local) do
data = %{
"type" => "Delete",
"actor" => actor_url,
"object" => url,
"id" => url <> "/delete",
"to" => [actor_url <> "/followers", "https://www.w3.org/ns/activitystreams#Public"]
}
# TODO : When a resource is a folder, delete everything in it.
with {:ok, _resource} <- Resources.delete_resource(resource),
{:ok, true} <- Cachex.del(:activity_pub, "resource_#{resource.id}"),
{:ok, activity} <- create_activity(data, local),
:ok <- maybe_federate(activity) do
{:ok, activity, resource}
end
end
def flag(args, local \\ false, _additional \\ %{}) do
with {:build_args, args} <- {:build_args, prepare_args_for_report(args)},
{:create_report, {:ok, %Report{} = report}} <-
@ -827,6 +860,59 @@ defmodule Mobilizon.Federation.ActivityPub do
end
end
defp create_resource(%{type: "folder"} = args, additional) do
with {:ok, %Resource{actor_id: group_id} = resource} <-
Resources.create_resource(args),
# %Resource{actor_id: group_id} = resource <-
# Resources.get_resource(parent_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group}),
audience <- %{"to" => [group.url], "cc" => []},
create_data <-
make_create_data(resource_as_data, Map.merge(audience, additional)) do
{:ok, resource, create_data}
else
err ->
Logger.error(inspect(err))
err
end
end
defp create_resource(%{resource_url: resource_url} = args, additional) do
args =
case Parser.parse(resource_url) do
{:ok, metadata} -> Map.put(args, :metadata, metadata)
_ -> args
end
with {:ok, %Resource{actor_id: group_id} = resource} <-
Resources.create_resource(args),
# %Resource{actor_id: group_id} = resource <-
# Resources.get_resource(parent_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group}),
audience <- %{"to" => [group.url], "cc" => []},
create_data <-
make_create_data(resource_as_data, Map.merge(audience, additional)) do
{:ok, resource, create_data}
else
err ->
Logger.error(inspect(err))
err
end
end
defp string_or_default(args, key, default) do
Map.update(args, key, "", fn value ->
case String.strip(args[key]) do
"" -> default
_ -> args[key]
end
end)
end
@spec check_for_tombstones(map()) :: Tombstone.t() | nil
defp check_for_tombstones(%{url: url}), do: Tombstone.find_tombstone(url)
defp check_for_tombstones(_), do: nil
@ -888,9 +974,28 @@ defmodule Mobilizon.Federation.ActivityPub do
todo_as_data <-
Convertible.model_to_as(%{todo | todo_list: %{todo_list | actor: group}}),
audience <- %{"to" => [group.url], "cc" => []},
create_data <-
update_data <-
make_update_data(todo_as_data, Map.merge(audience, additional)) do
{:ok, todo, create_data}
{:ok, todo, update_data}
end
end
defp update_resource(%Resource{} = old_resource, args, additional) do
with {:ok, %Resource{actor_id: group_id} = resource} <-
Resources.update_resource(old_resource, args),
# %Resource{actor_id: group_id} = resource <-
# Resources.get_resource(parent_id),
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
resource_as_data <-
Convertible.model_to_as(%{resource | actor: group}),
audience <- %{"to" => [group.url], "cc" => []},
update_data <-
make_update_data(resource_as_data, Map.merge(audience, additional)) do
{:ok, resource, update_data}
else
err ->
Logger.error(inspect(err))
err
end
end

View File

@ -584,7 +584,8 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
# else
# {:join_event, {:ok, %Participant{role: :participant}}} ->
# Logger.debug(
# "Tried to handle an Accept activity on a Join activity with a event object but the participant is already validated"
# "Tried to handle an Accept activity on a Join activity with a event object but the participant
# is already validated"
# )
#
# nil

View File

@ -0,0 +1,85 @@
defmodule Mobilizon.Federation.ActivityStream.Converter.Resource do
@moduledoc """
Resource converter.
This module allows to convert resources from ActivityStream format to our own
internal one, and back.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Resources
alias Mobilizon.Resources.Resource
@behaviour Converter
defimpl Convertible, for: Resource do
alias Mobilizon.Federation.ActivityStream.Converter.Resource, as: ResourceConverter
defdelegate model_to_as(resource), to: ResourceConverter
end
@doc """
Convert an resource struct to an ActivityStream representation
"""
@impl Converter
@spec model_to_as(Resource.t()) :: map
def model_to_as(%Resource{actor: %Actor{url: actor_url}} = resource) do
res = %{
"type" => "Resource",
"actor" => actor_url,
"id" => resource.url,
"name" => resource.title,
"summary" => resource.summary
# TODO add resource_url
}
# if parent do
# Map.put(res, "parent", parent.url)
# else
# res
# end
end
@doc """
Converts an AP object data to our internal data structure.
"""
@impl Converter
@spec as_to_model_data(map) :: {:ok, map} | {:error, any()}
def as_to_model_data(%{"type" => "Resource", "actor" => actor_url} = object) do
with {:ok, %Actor{id: actor_id} = actor} <-
ActivityPub.get_or_fetch_actor_by_url(actor_url) do
data = %{
title: object["name"],
summary: object["summary"],
url: object["id"],
actor_id: actor_id
# parent_id: get_eventual_parent(object)
# TODO add resource_url
}
{:ok, data}
end
end
defp get_eventual_parent_id(%{"parent" => parent_url})
when not is_nil(parent_url) do
with %Resource{id: resource_id} = _resource <-
Resources.get_resource_by_url(parent_url) do
resource_id
else
nil ->
with {:ok, %Resource{id: resource_id} = _resource} <-
ActivityPub.fetch_object_from_url(parent_url) do
resource_id
else
_ -> nil
end
_ ->
nil
end
end
defp get_eventual_parent_id(_), do: nil
end

View File

@ -1,223 +0,0 @@
defmodule Mobilizon.GraphQL.Resolvers.Collections do
@moduledoc """
Handles the collection-related GraphQL calls
"""
alias Mobilizon.{Actors, Collections, Users}
alias Mobilizon.Actors.Actor
alias Mobilizon.Collections.{Collection, Resource}
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
require Logger
@doc """
Find collections for group.
Returns only if actor requesting is a member of the group
"""
def find_collections_for_group(
%Actor{id: group_id} = group,
_args,
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
%Page{} = page <- Collections.get_collections_for_group(group) do
{:ok, page}
else
{:member, _} ->
with %Page{} = page <- Collections.get_collections_for_group(group) do
{:ok, %Page{page | elements: []}}
end
end
end
def find_collections_for_group(
_group,
_args,
_resolution
) do
{:ok, %Page{total: 0, elements: []}}
end
def find_resources_for_collection(
%Collection{actor_id: group_id} = collection,
_args,
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
%Page{} = page <- Collections.get_resources_for_collection(collection) do
{:ok, page}
else
{:is_owned, nil} ->
{:error, "Actor id is not owned by authenticated user"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def create_collection(
_parent,
%{actor_id: actor_id, group_id: group_id},
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Resource{} = resource} <- ActivityPub.create_collection(actor, true, %{}) do
{:ok, resource}
else
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def update_collection(
_parent,
%{id: collection_id, actor_id: actor_id},
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
{:collection, %Collection{actor_id: group_id} = collection} <-
{:collection, Collections.get_collection(collection_id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Collection{} = resource} <-
ActivityPub.update_collection(collection, actor, true, %{}) do
{:ok, resource}
else
{:collection, _} ->
{:error, "Collection doesn't exist"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def delete_collection(
_parent,
%{id: collection_id, actor_id: actor_id},
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
{:collection, %Collection{actor_id: group_id} = collection} <-
{:collection, Collections.get_collection(collection_id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Collection{} = resource} <-
ActivityPub.delete_collection(collection, actor, true, %{}) do
{:ok, resource}
else
{:collection, _} ->
{:error, "Collection doesn't exist"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def create_resource(
_parent,
%{actor_id: actor_id, collection_id: collection_id},
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
{:collection, %Collection{actor_id: group_id} = collection} <-
{:collection, Collections.get_collection(collection_id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Resource{} = resource} <-
ActivityPub.create_resource(actor, collection, true, %{}) do
{:ok, resource}
else
{:collection, _} ->
{:error, "Collection doesn't exist"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def update_resource(
_parent,
%{id: resource_id, actor_id: actor_id},
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
{:resource, %Resource{collection_id: collection_id} = resource} <-
{:resource, Collections.get_resource(resource_id)},
{:collection, %Collection{actor_id: group_id}} <-
{:collection, Collections.get_collection(collection_id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Resource{} = resource} <-
ActivityPub.update_resource(resource, actor, true, %{}) do
{:ok, resource}
else
{:collection, _} ->
{:error, "Collection doesn't exist"}
{:resource, _} ->
{:error, "Resource doesn't exist"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def delete_resource(
_parent,
%{id: resource_id, actor_id: actor_id},
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with {:is_owned, %Actor{} = actor} <- User.owns_actor(user, actor_id),
{:resource, %Resource{collection_id: collection_id} = resource} <-
{:resource, Collections.get_resource(resource_id)},
{:collection, %Collection{actor_id: group_id}} <-
{:collection, Collections.get_collection(collection_id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Resource{} = resource} <-
ActivityPub.delete_resource(resource, actor, true, %{}) do
{:ok, resource}
else
{:collection, _} ->
{:error, "Collection doesn't exist"}
{:resource, _} ->
{:error, "Resource doesn't exist"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
end

View File

@ -100,7 +100,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
endpoint: Config.instance_maps_tiles_endpoint(),
attribution: Config.instance_maps_tiles_attribution()
}
}
},
resource_providers: Config.instance_resource_providers()
}
end
end

View File

@ -5,13 +5,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
alias Mobilizon.{Actors, Events, Users}
alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Users.User
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.GraphQL.API
alias Mobilizon.GraphQL.Resolvers.Person
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
require Logger

View File

@ -0,0 +1,230 @@
defmodule Mobilizon.GraphQL.Resolvers.Resource do
@moduledoc """
Handles the resources-related GraphQL calls
"""
alias Mobilizon.{Actors, Resources, Users}
alias Mobilizon.Actors.Actor
alias Mobilizon.Resources.Resource
alias Mobilizon.Resources.Resource.Metadata
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Service.RichMedia.Parser
require Logger
@doc """
Find resources for group.
Returns only if actor requesting is a member of the group
"""
def find_resources_for_group(
%Actor{id: group_id} = group,
%{page: page, limit: limit},
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
%Page{} = page <- Resources.get_resources_for_group(group, page, limit) do
{:ok, page}
else
{:member, _} ->
find_resources_for_group(nil, nil, nil)
end
end
def find_resources_for_group(
_group,
_args,
_resolution
) do
{:ok, %Page{total: 0, elements: []}}
end
def find_resources_for_parent(
%Resource{actor_id: group_id} = parent,
_args,
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
%Page{} = page <- Resources.get_resources_for_folder(parent) do
{:ok, page}
end
end
def find_resources_for_parent(_parent, _args, _resolution),
do: {:ok, %Page{total: 0, elements: []}}
def get_resource(
_parent,
%{path: path, username: username},
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
%Actor{id: group_id} <- Actors.get_actor_by_name(username, :Group),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:resource, %Resource{} = resource} <-
{:resource, Resources.get_resource_by_group_and_path_with_preloads(group_id, path)} do
{:ok, resource}
else
{:member, false} -> {:error, "Actor is not member of group"}
{:resource, _} -> {:error, "No such resource"}
end
end
def get_resource(_parent, _args, _resolution) do
{:error, "You need to be logged-in to access resources"}
end
def create_resource(
_parent,
%{actor_id: group_id} = args,
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id),
parent <- get_eventual_parent(args),
{:own_check, true} <- {:own_check, check_resource_owned_by_group(parent, group_id)},
{:ok, _, %Resource{} = resource} <-
ActivityPub.create(
:resource,
args
|> Map.put(:actor_id, group_id)
|> Map.put(:creator_id, actor_id),
true,
%{}
) do
{:ok, resource}
else
{:own_check, _} ->
{:error, "Parent resource doesn't match this group"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def create_resource(_parent, _args, _resolution) do
{:error, "You need to be logged-in to create resources"}
end
def update_resource(
_parent,
%{id: resource_id} = args,
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:resource, %Resource{actor_id: group_id} = resource} <-
{:resource, Resources.get_resource_with_preloads(resource_id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Resource{} = resource} <-
ActivityPub.update(:resource, resource, args, true, %{}) do
{:ok, resource}
else
{:resource, _} ->
{:error, "Resource doesn't exist"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def update_resource(_parent, _args, _resolution) do
{:error, "You need to be logged-in to update resources"}
end
def delete_resource(
_parent,
%{id: resource_id},
%{
context: %{
current_user: %User{} = user
}
} = _resolution
) do
with %Actor{id: actor_id} <- Users.get_actor_for_user(user),
{:resource, %Resource{parent_id: _parent_id, actor_id: group_id} = resource} <-
{:resource, Resources.get_resource_with_preloads(resource_id)},
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Resource{} = resource} <-
ActivityPub.delete(resource) do
{:ok, resource}
else
{:resource, _} ->
{:error, "Resource doesn't exist"}
{:member, _} ->
{:error, "Actor id is not member of group"}
end
end
def delete_resource(_parent, _args, _resolution) do
{:error, "You need to be logged-in to delete resources"}
end
def preview_resource_link(
_parent,
%{resource_url: resource_url},
%{
context: %{
current_user: %User{} = _user
}
} = _resolution
) do
with {:ok, data} when is_map(data) <- Parser.parse(resource_url) do
{:ok, struct(Metadata, data)}
end
end
def preview_resource_link(_parent, _args, _resolution) do
{:error, "You need to be logged-in to view a resource preview"}
end
@spec get_eventual_parent(map()) :: Resource.t() | nil
defp get_eventual_parent(args) do
parent = args |> Map.get(:parent_id) |> get_parent_resource()
case parent do
%Resource{} -> parent
_ -> nil
end
end
@spec get_parent_resource(integer | nil) :: nil | Resource.t()
defp get_parent_resource(nil), do: nil
defp get_parent_resource(parent_id), do: Resources.get_resource(parent_id)
@spec check_resource_owned_by_group(Resource.t() | nil, integer) :: boolean
defp check_resource_owned_by_group(nil, _group_id), do: true
defp check_resource_owned_by_group(%Resource{actor_id: actor_id} = resource, group_id)
when is_binary(group_id),
do: actor_id == String.to_integer(group_id)
defp check_resource_owned_by_group(%Resource{actor_id: actor_id}, group_id)
when is_number(group_id),
do: actor_id == group_id
end

View File

@ -26,8 +26,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_types(Schema.Conversations.CommentType)
import_types(Schema.Conversations.ConversationType)
import_types(Schema.SearchType)
import_types(Schema.Collections.CollectionType)
import_types(Schema.Collections.ResourceType)
import_types(Schema.ResourceType)
import_types(Schema.Todos.TodoListType)
import_types(Schema.Todos.TodoType)
import_types(Schema.ConfigType)
@ -137,6 +136,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:todo_list_queries)
import_fields(:todo_queries)
import_fields(:conversation_queries)
import_fields(:resource_queries)
end
@desc """
@ -157,6 +157,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:todo_list_mutations)
import_fields(:todo_mutations)
import_fields(:conversation_mutations)
import_fields(:resource_mutations)
end
@desc """

View File

@ -5,7 +5,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
use Absinthe.Schema.Notation
alias Mobilizon.GraphQL.Resolvers.{Collections, Conversation, Group, Member, Todos}
alias Mobilizon.GraphQL.Resolvers.{Conversation, Group, Member, Resource, Todos}
alias Mobilizon.GraphQL.Schema
import_types(Schema.Actors.MemberType)
@ -62,9 +62,11 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
description("List of group members")
end
field :collections, :paginated_collection_list do
resolve(&Collections.find_collections_for_group/3)
description("A paginated list of the collections this group has")
field :resources, :paginated_resource_list do
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&Resource.find_resources_for_group/3)
description("A paginated list of the resources this group has")
end
field :todo_lists, :paginated_todo_list_list do

View File

@ -1,23 +0,0 @@
defmodule Mobilizon.GraphQL.Schema.Collections.CollectionType do
@moduledoc """
Schema representation for Collections
"""
use Absinthe.Schema.Notation
alias Mobilizon.GraphQL.Resolvers.Collections
@desc "A collection"
object :collection do
field(:id, :id, description: "The collection's ID")
field(:title, :string, description: "The collection's title")
field(:resources, :paginated_resource_list,
resolve: &Collections.find_resources_for_collection/3,
description: "The collection's resources"
)
end
object :paginated_collection_list do
field(:elements, list_of(:collection), description: "A list of collections")
field(:total, :integer, description: "The total number of collections in the list")
end
end

View File

@ -1,50 +0,0 @@
defmodule Mobilizon.GraphQL.Schema.Collections.ResourceType do
@moduledoc """
Schema representation for Resources
"""
use Absinthe.Schema.Notation
alias Mobilizon.GraphQL.Resolvers.Collections
@desc "A resource"
object :resource do
field(:id, :id, description: "The resource's ID")
field(:title, :string, description: "The resource's title")
field(:summary, :string, description: "The resource's summary")
field(:url, :string, description: "The resource's URL")
end
object :paginated_resource_list do
field(:elements, list_of(:resource), description: "A list of resources")
field(:total, :integer, description: "The total number of resources in the list")
end
object :resource_mutations do
@desc "Create a resource"
field :create_resource, :resource do
arg(:collection_id, non_null(:id))
arg(:actor_id, non_null(:id))
arg(:title, non_null(:string))
arg(:summary, non_null(:string))
arg(:url, non_null(:string))
resolve(&Collections.create_resource/3)
end
@desc "Update a resource"
field :update_resource, :resource do
arg(:id, non_null(:id))
arg(:actor_id, non_null(:id))
arg(:title, non_null(:string))
arg(:summary, non_null(:string))
arg(:url, non_null(:string))
resolve(&Collections.update_resource/3)
end
@desc "Delete a resource"
field :delete_resource, :deleted_object do
arg(:id, non_null(:id))
resolve(&Collections.delete_resource/3)
end
end
end

View File

@ -20,6 +20,7 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
field(:geocoding, :geocoding)
field(:maps, :maps)
field(:anonymous, :anonymous)
field(:resource_providers, list_of(:resource_provider))
field(:terms, :terms, description: "The instance's terms") do
arg(:locale, :string, default_value: "en")
@ -97,6 +98,12 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
field(:enabled, :boolean)
end
object :resource_provider do
field(:type, :string)
field(:endpoint, :string)
field(:software, :string)
end
object :config_queries do
@desc "Get the instance config"
field :config, :config do

View File

@ -0,0 +1,95 @@
defmodule Mobilizon.GraphQL.Schema.ResourceType do
@moduledoc """
Schema representation for Resources
"""
use Absinthe.Schema.Notation
alias Mobilizon.GraphQL.Resolvers.Resource
@desc "A resource"
object :resource do
field(:id, :id, description: "The resource's ID")
field(:title, :string, description: "The resource's title")
field(:summary, :string, description: "The resource's summary")
field(:url, :string, description: "The resource's URL")
field(:resource_url, :string, description: "The resource's URL")
field(:metadata, :resource_metadata, description: "The resource's metadata")
field(:creator, :actor, description: "The resource's creator")
field(:actor, :actor, description: "The resource's owner")
field(:inserted_at, :naive_datetime, description: "The resource's creation date")
field(:updated_at, :naive_datetime, description: "The resource's last update date")
field(:type, :string, description: "The resource's type (if it's a folder)")
field(:path, :string, description: "The resource's path")
field(:parent, :resource, description: "The resource's parent")
field :children, :paginated_resource_list do
description("Children resources in folder")
resolve(&Resource.find_resources_for_parent/3)
end
end
object :paginated_resource_list do
field(:elements, list_of(:resource), description: "A list of resources")
field(:total, :integer, description: "The total number of resources in the list")
end
object :resource_metadata do
field(:type, :string, description: "The type of the resource")
field(:title, :string, description: "The resource's metadata title")
field(:description, :string, description: "The resource's metadata description")
field(:image_remote_url, :string, description: "The resource's metadata image")
field(:width, :integer)
field(:height, :integer)
field(:author_name, :string)
field(:author_url, :string)
field(:provider_name, :string)
field(:provider_url, :string)
field(:html, :string)
field(:favicon_url, :string)
end
object :resource_queries do
@desc "Get a resource"
field :resource, :resource do
arg(:path, non_null(:string))
arg(:username, non_null(:string))
resolve(&Resource.get_resource/3)
end
end
object :resource_mutations do
@desc "Create a resource"
field :create_resource, :resource do
arg(:parent_id, :id)
arg(:actor_id, non_null(:id))
arg(:title, non_null(:string))
arg(:summary, :string)
arg(:resource_url, :string)
arg(:type, :string, default_value: "link")
resolve(&Resource.create_resource/3)
end
@desc "Update a resource"
field :update_resource, :resource do
arg(:id, non_null(:id))
arg(:title, :string)
arg(:summary, :string)
arg(:parent_id, :id)
arg(:resource_url, :string)
resolve(&Resource.update_resource/3)
end
@desc "Delete a resource"
field :delete_resource, :deleted_object do
arg(:id, non_null(:id))
resolve(&Resource.delete_resource/3)
end
@desc "Get a preview for a resource link"
field :preview_resource_link, :resource_metadata do
arg(:resource_url, non_null(:string))
resolve(&Resource.preview_resource_link/3)
end
end
end

View File

@ -19,7 +19,7 @@ defmodule Mobilizon.GraphQL.Schema.Todos.TodoListType do
field(:todos, :paginated_todo_list,
resolve: &Todos.find_todos_for_todo_list/3,
description: "The collection's todos"
description: "The todo-list's todos"
)
end

View File

@ -55,6 +55,7 @@ defmodule Mobilizon do
),
cachex_spec(:statistics, 10, 60, 60),
cachex_spec(:config, 10, 60, 60),
cachex_spec(:rich_media_cache, 10, 60, 60),
cachex_spec(:activity_pub, 2500, 3, 15)
] ++
task_children(@env)

View File

@ -285,18 +285,13 @@ defmodule Mobilizon.Actors.Actor do
def group_creation_changeset(%__MODULE__{} = actor, params) do
actor
|> cast(params, @group_creation_attrs)
|> cast_embed(:avatar)
|> cast_embed(:banner)
|> build_urls(:Group)
|> common_changeset()
|> put_change(:domain, nil)
|> put_change(:keys, Crypto.generate_rsa_2048_private_key())
|> put_change(:type, :Group)
|> unique_username_validator()
|> validate_required(@group_creation_required_attrs)
|> unique_constraint(:preferred_username,
name: :actors_preferred_username_domain_type_index
)
|> unique_constraint(:url, name: :actors_url_index)
|> validate_length(:summary, max: 5000)
|> validate_length(:preferred_username, max: 100)
end

View File

@ -1,37 +0,0 @@
defmodule Mobilizon.Collections.Collection do
@moduledoc """
Represents a collection of resources
"""
use Ecto.Schema
import Ecto.Changeset
alias Mobilizon.Actors.Actor
@type t :: %__MODULE__{
title: String.t(),
summary: String.t(),
actor: Actor.t(),
public: boolean()
}
@primary_key {:id, :binary_id, autogenerate: true}
schema "collections" do
field(:public, :boolean, default: false)
field(:summary, :string)
field(:title, :string)
field(:url, :string)
belongs_to(:actor, Actor)
timestamps()
end
@required_attrs [:title, :actor_id, :url]
@optional_attrs [:summary, :public]
@attrs @required_attrs ++ @optional_attrs
@doc false
def changeset(collection, attrs) do
collection
|> cast(attrs, @attrs)
|> validate_required(@required_attrs)
end
end

View File

@ -1,100 +0,0 @@
defmodule Mobilizon.Collections do
@moduledoc """
The Collections context.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Collections.{Collection, Resource}
alias Mobilizon.Storage.{Page, Repo}
import Ecto.Query
import EctoEnum
defenum(ResourceType, :resource_type, [
:url
])
@doc """
Get a collection by it's ID
"""
@spec get_collection(integer | String.t()) :: Collection.t() | nil
def get_collection(id), do: Repo.get(Collection, id)
@doc """
Returns the list of collections for a group.
"""
@spec get_collections_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def get_collections_for_group(%Actor{id: group_id, type: :Group}, page \\ nil, limit \\ nil) do
Collection
|> where(actor_id: ^group_id)
|> Page.build_page(page, limit)
end
@doc """
Returns the list of collections for a group.
"""
@spec get_resources_for_collection(Collection.t(), integer | nil, integer | nil) :: Page.t()
def get_resources_for_collection(%Collection{id: collection_id}, page \\ nil, limit \\ nil) do
Resource
|> where(collection_id: ^collection_id)
|> Page.build_page(page, limit)
end
@doc """
Creates a collection.
"""
@spec create_collection(map) :: {:ok, Collection.t()} | {:error, Ecto.Changeset.t()}
def create_collection(attrs \\ %{}) do
%Collection{}
|> Collection.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a collection.
"""
@spec update_collection(Collection.t(), map) ::
{:ok, Collection.t()} | {:error, Ecto.Changeset.t()}
def update_collection(%Collection{} = collection, attrs) do
collection
|> Collection.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a collection
"""
@spec delete_collection(Collection.t()) :: {:ok, Collection.t()} | {:error, Ecto.Changeset.t()}
def delete_collection(%Collection{} = collection), do: Repo.delete(collection)
@doc """
Get a resource by it's ID
"""
@spec get_resource(integer | String.t()) :: Resource.t() | nil
def get_resource(id), do: Repo.get(Resource, id)
@doc """
Creates a resource.
"""
@spec create_resource(map) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
def create_resource(attrs \\ %{}) do
%Resource{}
|> Resource.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a resource.
"""
@spec update_resource(Resource.t(), map) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
def update_resource(%Resource{} = resource, attrs) do
resource
|> Resource.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a resource
"""
@spec delete_resource(Resource.t()) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
def delete_resource(%Resource{} = resource), do: Repo.delete(resource)
end

View File

@ -1,44 +0,0 @@
defmodule Mobilizon.Collections.Resource do
@moduledoc """
Represents a web resource
"""
use Ecto.Schema
import Ecto.Changeset
import Mobilizon.Storage.Ecto, only: [ensure_url: 2]
alias Mobilizon.Collections.{Collection, ResourceType}
@type t :: %__MODULE__{
title: String.t(),
summary: String.t(),
collection: Collection.t(),
url: String.t()
}
@primary_key {:id, :binary_id, autogenerate: true}
schema "resource" do
field(:summary, :string)
field(:title, :string)
field(:url, :string)
field(:resource_url, :string)
embeds_one :metadata, Metadata, on_replace: :delete do
field(:type, ResourceType)
end
belongs_to(:collection, Collection, type: :binary_id)
timestamps()
end
@required_attrs [:title, :url, :collection_id, :resource_url]
@optional_attrs [:summary]
@attrs @required_attrs ++ @optional_attrs
@doc false
def changeset(resource, attrs) do
resource
|> cast(attrs, @attrs)
|> ensure_url(:resource)
|> validate_required(@required_attrs)
end
end

View File

@ -139,6 +139,23 @@ defmodule Mobilizon.Config do
:enabled
]
def instance_resource_providers() do
types = get_in(Application.get_env(:mobilizon, Mobilizon.Service.ResourceProviders), [:types])
providers =
get_in(Application.get_env(:mobilizon, Mobilizon.Service.ResourceProviders), [:providers])
providers_map = :maps.filter(fn key, _value -> key in Keyword.values(types) end, providers)
Enum.map(providers_map, fn {key, value} ->
%{
type: key,
software: types |> Enum.find(fn {_key, val} -> val == key end) |> elem(0),
endpoint: value
}
end)
end
def anonymous_actor_id, do: get_cached_value(:anonymous_actor_id)
def relay_actor_id, do: get_cached_value(:relay_actor_id)

View File

@ -1,4 +1,7 @@
defmodule Mobilizon.Conversations.Conversation.TitleSlug do
@moduledoc """
Module to generate the slug for conversations
"""
use EctoAutoslugField.Slug, from: :title, to: :slug
end

View File

@ -0,0 +1,101 @@
defmodule Mobilizon.Resources.Resource do
@moduledoc """
Represents a web resource
"""
use Ecto.Schema
import Ecto.Changeset
alias Ecto.Changeset
import Mobilizon.Storage.Ecto, only: [ensure_url: 2]
import EctoEnum
defenum(TypeEnum, folder: 0, link: 1, picture: 20, pad: 30, calc: 40, visio: 50)
alias Mobilizon.Actors.Actor
@type t :: %__MODULE__{
title: String.t(),
summary: String.t(),
url: String.t(),
resource_url: String.t(),
type: atom(),
metadata: Mobilizon.Resources.Resource.Metadata.t(),
children: list(__MODULE__),
parent: __MODULE__,
actor: Actor.t(),
creator: Actor.t()
}
@primary_key {:id, :binary_id, autogenerate: true}
schema "resource" do
field(:summary, :string)
field(:title, :string)
field(:url, :string)
field(:resource_url, :string)
field(:type, TypeEnum)
field(:path, :string)
embeds_one :metadata, Metadata, on_replace: :delete do
field(:type, :string)
field(:title, :string)
field(:description, :string)
field(:image_remote_url, :string)
field(:width, :integer)
field(:height, :integer)
field(:author_name, :string)
field(:author_url, :string)
field(:provider_name, :string)
field(:provider_url, :string)
field(:html, :string)
field(:favicon_url, :string)
end
has_many(:children, __MODULE__, foreign_key: :parent_id)
belongs_to(:parent, __MODULE__, type: :binary_id)
belongs_to(:actor, Actor)
belongs_to(:creator, Actor)
timestamps()
end
@required_attrs [:title, :url, :actor_id, :creator_id, :type, :path]
@optional_attrs [:summary, :parent_id, :resource_url]
@attrs @required_attrs ++ @optional_attrs
@metadata_attrs [
:type,
:title,
:description,
:image_remote_url,
:width,
:height,
:author_name,
:author_url,
:provider_name,
:provider_url,
:html,
:favicon_url
]
@doc false
def changeset(resource, attrs) do
resource
|> cast(attrs, @attrs)
|> cast_embed(:metadata, with: &metadata_changeset/2)
|> ensure_url(:resource)
|> validate_resource_or_folder()
|> validate_required(@required_attrs)
end
defp metadata_changeset(schema, params) do
schema
|> cast(params, @metadata_attrs)
end
@spec validate_resource_or_folder(Changeset.t()) :: Changeset.t()
defp validate_resource_or_folder(%Changeset{} = changeset) do
with {status, type} when status in [:changes, :data] <- fetch_field(changeset, :type),
true <- type != :folder do
validate_required(changeset, [:resource_url])
else
_ -> changeset
end
end
end

View File

@ -0,0 +1,192 @@
defmodule Mobilizon.Resources do
@moduledoc """
The Resources context.
"""
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Resources.Resource
alias Mobilizon.Storage.{Page, Repo}
alias Ecto.Multi
import Ecto.Query
require Logger
@resource_preloads [:actor, :creator, :children]
@doc """
Returns the list of top-level resources for a group
"""
@spec get_resources_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
def get_resources_for_group(%Actor{id: group_id}, page \\ nil, limit \\ nil) do
Resource
|> where(actor_id: ^group_id)
|> order_by(desc: :updated_at)
|> Page.build_page(page, limit)
end
@doc """
Returns the list of resources for a resource folder.
"""
@spec get_resources_for_folder(Resource.t(), integer | nil, integer | nil) :: Page.t()
def get_resources_for_folder(resource, page \\ nil, limit \\ nil)
def get_resources_for_folder(
%Resource{id: "root_" <> _group_id, actor_id: group_id},
page,
limit
) do
Resource
|> where([r], r.actor_id == ^group_id and is_nil(r.parent_id))
|> order_by(asc: :type)
|> Page.build_page(page, limit)
end
def get_resources_for_folder(%Resource{id: resource_id}, page, limit) do
Resource
|> where([r], r.parent_id == ^resource_id)
|> order_by(asc: :type)
|> Page.build_page(page, limit)
end
@doc """
Get a resource by it's ID
"""
@spec get_resource(integer | String.t()) :: Resource.t() | nil
def get_resource(nil), do: nil
def get_resource(id), do: Repo.get(Resource, id)
@spec get_resource_with_preloads(integer | String.t()) :: Resource.t() | nil
def get_resource_with_preloads(id) do
Resource
|> Repo.get(id)
|> Repo.preload(@resource_preloads)
end
@spec get_resource_by_group_and_path_with_preloads(String.t() | integer, String.t()) ::
Resource.t() | nil
def get_resource_by_group_and_path_with_preloads(group_id, "/") do
with {:ok, %Actor{} = group} <- Actors.get_group_by_actor_id(group_id) do
%Resource{
actor_id: group_id,
id: "root_#{group_id}",
actor: group,
path: "/",
title: "Root"
}
end
end
def get_resource_by_group_and_path_with_preloads(group_id, path) do
Resource
|> Repo.get_by(actor_id: group_id, path: path)
|> Repo.preload(@resource_preloads)
end
@doc """
Get a resource by it's URL
"""
@spec get_resource_by_url(String.t()) :: Resource.t() | nil
def get_resource_by_url(url), do: Repo.get_by(Resource, url: url)
@doc """
Creates a resource.
"""
@spec create_resource(map) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
def create_resource(attrs \\ %{}) do
Multi.new()
|> do_find_parent_path(Map.get(attrs, :parent_id))
|> Multi.insert(:insert, fn %{find_parent_path: path} ->
Resource.changeset(%Resource{}, Map.put(attrs, :path, "#{path}/#{attrs.title}"))
end)
|> Repo.transaction()
|> case do
{:ok, %{insert: %Resource{} = resource}} ->
{:ok, resource}
{:error, operation, reason, _changes} ->
{:error, "Error while inserting resource when #{operation} because of #{inspect(reason)}"}
end
end
@doc """
Updates a resource.
"""
@spec update_resource(Resource.t(), map) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
def update_resource(%Resource{} = resource, attrs) do
Multi.new()
|> find_parent_path(resource, attrs)
|> update_children(resource, attrs)
|> Multi.update(:update, fn %{find_parent_path: path} ->
Resource.changeset(resource, Map.put(attrs, :path, "#{path}/#{attrs.title}"))
end)
|> Repo.transaction()
|> case do
{:ok, %{update: %Resource{} = resource}} ->
{:ok, resource}
# collect errors into record changesets
{:error, operation, reason, _changes} ->
{:error, "Error while updating resource when #{operation} because of #{inspect(reason)}"}
end
end
defp find_parent_path(
multi,
%Resource{parent_id: old_parent_id} = _resource,
attrs
) do
updated_parent_id = Map.get(attrs, :parent_id, old_parent_id)
Logger.debug("Finding parent path for updated_parent_id #{inspect updated_parent_id}")
do_find_parent_path(multi, updated_parent_id)
end
defp do_find_parent_path(multi, nil),
do: Multi.run(multi, :find_parent_path, fn _, _ -> {:ok, ""} end)
defp do_find_parent_path(multi, parent_id) do
Multi.run(multi, :find_parent_path, fn repo, _changes ->
case get_resource(parent_id) do
%Resource{path: path} = _resource -> {:ok, path}
_ -> {:error, :not_found}
end
end)
end
defp update_children(
multi,
%Resource{type: :folder, title: old_title, actor_id: actor_id, parent_id: old_parent_id} =
resource,
attrs
) do
title = Map.get(attrs, :title, old_title)
Multi.run(multi, :update_children, fn repo, %{find_parent_path: path} ->
{query, params} =
case old_parent_id do
nil ->
{"UPDATE resource SET path = CONCAT($1, title) WHERE actor_id = $2 AND parent_id IS NULL",
["#{path}/#{title}/", actor_id]}
old_parent_id ->
{"UPDATE resource SET path = CONCAT($1, title) WHERE actor_id = $2 AND parent_id = $3",
["#{path}/#{title}/", actor_id, old_parent_id]}
end
Ecto.Adapters.SQL.query!(
repo,
query,
params
)
repo.update_all(resource.children, set: [path: "#{path}/#{title}"])
end)
end
defp update_children(multi, _, _), do: multi
@doc """
Deletes a resource
"""
@spec delete_resource(Resource.t()) :: {:ok, Resource.t()} | {:error, Ecto.Changeset.t()}
def delete_resource(%Resource{} = resource), do: Repo.delete(resource)
end

View File

@ -26,16 +26,19 @@ defmodule Mobilizon.Todos do
def get_todo_lists_for_group(%Actor{id: group_id, type: :Group}, page \\ nil, limit \\ nil) do
TodoList
|> where(actor_id: ^group_id)
|> order_by(desc: :updated_at)
|> Page.build_page(page, limit)
end
@doc """
Returns the list of collections for a group.
Returns the list of todos for a group.
"""
@spec get_todos_for_todo_list(TodoList.t(), integer | nil, integer | nil) :: Page.t()
def get_todos_for_todo_list(%TodoList{id: todo_list_id}, page \\ nil, limit \\ nil) do
Todo
|> where(todo_list_id: ^todo_list_id)
|> order_by(asc: :status)
# |> order_by(desc: :updated_at)
|> Page.build_page(page, limit)
end

View File

@ -8,19 +8,19 @@ defmodule Mobilizon.Service.Formatter.DefaultScrubbler do
Custom strategy to filter HTML content.
"""
alias HtmlSanitizeEx.Scrubber.Meta
require HtmlSanitizeEx.Scrubber.Meta
require FastSanitize.Sanitizer.Meta
alias FastSanitize.Sanitizer.Meta
# credo:disable-for-previous-line
# No idea how to fix this one…
Meta.remove_cdata_sections_before_scrub()
@valid_schemes ~w(https http)
Meta.strip_comments()
Meta.allow_tag_with_uri_attributes("a", ["href", "data-user", "data-tag"], ["https", "http"])
Meta.allow_tag_with_uri_attributes(:a, ["href", "data-user", "data-tag"], @valid_schemes)
Meta.allow_tag_with_this_attribute_values("a", "class", [
Meta.allow_tag_with_this_attribute_values(:a, "class", [
"hashtag",
"u-url",
"mention",
@ -28,7 +28,7 @@ defmodule Mobilizon.Service.Formatter.DefaultScrubbler do
"mention u-url"
])
Meta.allow_tag_with_this_attribute_values("a", "rel", [
Meta.allow_tag_with_this_attribute_values(:a, "rel", [
"tag",
"nofollow",
"noopener",
@ -36,34 +36,42 @@ defmodule Mobilizon.Service.Formatter.DefaultScrubbler do
"ugc"
])
Meta.allow_tag_with_these_attributes("a", ["name", "title"])
Meta.allow_tag_with_these_attributes(:a, ["name", "title"])
Meta.allow_tag_with_these_attributes("abbr", ["title"])
Meta.allow_tag_with_these_attributes(:abbr, ["title"])
Meta.allow_tag_with_these_attributes("b", [])
Meta.allow_tag_with_these_attributes("blockquote", [])
Meta.allow_tag_with_these_attributes("br", [])
Meta.allow_tag_with_these_attributes("code", [])
Meta.allow_tag_with_these_attributes("del", [])
Meta.allow_tag_with_these_attributes("em", [])
Meta.allow_tag_with_these_attributes("i", [])
Meta.allow_tag_with_these_attributes("li", [])
Meta.allow_tag_with_these_attributes("ol", [])
Meta.allow_tag_with_these_attributes("p", [])
Meta.allow_tag_with_these_attributes("pre", [])
Meta.allow_tag_with_these_attributes("strong", [])
Meta.allow_tag_with_these_attributes("u", [])
Meta.allow_tag_with_these_attributes("ul", [])
Meta.allow_tag_with_these_attributes("img", ["src", "alt"])
Meta.allow_tag_with_these_attributes(:b, [])
Meta.allow_tag_with_these_attributes(:blockquote, [])
Meta.allow_tag_with_these_attributes(:br, [])
Meta.allow_tag_with_these_attributes(:code, [])
Meta.allow_tag_with_these_attributes(:del, [])
Meta.allow_tag_with_these_attributes(:em, [])
Meta.allow_tag_with_these_attributes(:i, [])
Meta.allow_tag_with_these_attributes(:li, [])
Meta.allow_tag_with_these_attributes(:ol, [])
Meta.allow_tag_with_these_attributes(:p, [])
Meta.allow_tag_with_these_attributes(:pre, [])
Meta.allow_tag_with_these_attributes(:strong, [])
Meta.allow_tag_with_these_attributes(:u, [])
Meta.allow_tag_with_these_attributes(:ul, [])
Meta.allow_tag_with_uri_attributes(:img, ["src"], @valid_schemes)
Meta.allow_tag_with_this_attribute_values("span", "class", ["h-card", "mention"])
Meta.allow_tag_with_these_attributes("span", ["data-user"])
Meta.allow_tag_with_these_attributes(:img, [
"width",
"height",
"class",
"title",
"alt"
])
Meta.allow_tag_with_these_attributes("h1", [])
Meta.allow_tag_with_these_attributes("h2", [])
Meta.allow_tag_with_these_attributes("h3", [])
Meta.allow_tag_with_these_attributes("h4", [])
Meta.allow_tag_with_these_attributes("h5", [])
Meta.allow_tag_with_this_attribute_values(:span, "class", ["h-card", "mention"])
Meta.allow_tag_with_these_attributes(:span, ["data-user"])
Meta.allow_tag_with_these_attributes(:h1, [])
Meta.allow_tag_with_these_attributes(:h2, [])
Meta.allow_tag_with_these_attributes(:h3, [])
Meta.allow_tag_with_these_attributes(:h4, [])
Meta.allow_tag_with_these_attributes(:h5, [])
Meta.strip_everything_not_covered()
end

View File

@ -95,7 +95,9 @@ defmodule Mobilizon.Service.Formatter do
end
def html_escape(text, "text/html") do
HTML.filter_tags(text)
with {:ok, content} <- HTML.filter_tags(text) do
content
end
end
def html_escape(text, "text/plain") do

View File

@ -8,9 +8,11 @@ defmodule Mobilizon.Service.Formatter.HTML do
Service to filter tags out of HTML content.
"""
alias HtmlSanitizeEx.Scrubber
alias FastSanitize.Sanitizer
alias Mobilizon.Service.Formatter.DefaultScrubbler
alias Mobilizon.Service.Formatter.{DefaultScrubbler, OEmbed}
def filter_tags(html), do: Scrubber.scrub(html, DefaultScrubbler)
def filter_tags(html), do: Sanitizer.scrub(html, DefaultScrubbler)
def filter_tags_for_oembed(html), do: Sanitizer.scrub(html, OEmbed)
end

View File

@ -0,0 +1,34 @@
defmodule Mobilizon.Service.Formatter.OEmbed do
@moduledoc """
Custom strategy to filter HTML content in OEmbed html
"""
require FastSanitize.Sanitizer.Meta
alias FastSanitize.Sanitizer.Meta
@valid_schemes ~w(https http)
Meta.strip_comments()
Meta.allow_tag_with_uri_attributes(:a, ["href"], @valid_schemes)
Meta.allow_tag_with_uri_attributes(:img, ["src"], @valid_schemes)
Meta.allow_tag_with_these_attributes(:audio, ["controls"])
Meta.allow_tag_with_uri_attributes(:embed, ["src"], @valid_schemes)
Meta.allow_tag_with_these_attributes(:embed, ["height type width"])
Meta.allow_tag_with_uri_attributes(:iframe, ["src"], @valid_schemes)
Meta.allow_tag_with_these_attributes(
:iframe,
["allowfullscreen frameborder allow height scrolling width"]
)
Meta.allow_tag_with_uri_attributes(:source, ["src"], @valid_schemes)
Meta.allow_tag_with_these_attributes(:source, ["type"])
Meta.allow_tag_with_these_attributes(:video, ["controls height loop width"])
Meta.strip_everything_not_covered()
end

View File

@ -0,0 +1,104 @@
defmodule Mobilizon.Service.RichMedia.Favicon do
@moduledoc """
Module to fetch favicon information from a website
Taken and adapted from https://github.com/ricn/favicon
"""
require Logger
alias Mobilizon.Config
@options [
max_body: 2_000_000,
timeout: 10_000,
recv_timeout: 20_000,
follow_redirect: true
]
@spec fetch(String.t(), List.t()) :: {:ok, String.t()} | {:error, any()}
def fetch(url, options \\ []) do
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
case HTTPoison.get(url, headers, @options) do
{:ok, %HTTPoison.Response{status_code: code, body: body}} when code in 200..299 ->
find_favicon_url(url, body, headers)
{:ok, %HTTPoison.Response{}} ->
{:error, "Error while fetching the page"}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, reason}
end
end
@spec find_favicon_url(String.t(), String.t(), List.t()) :: {:ok, String.t()} | {:error, any()}
defp find_favicon_url(url, body, headers) do
Logger.debug("finding favicon URL for #{url}")
with {:ok, tag} <- find_favicon_link_tag(body) do
Logger.debug("Found link #{inspect(tag)}")
{"link", attrs, _} = tag
{"href", path} =
Enum.find(attrs, fn {name, _} ->
name == "href"
end)
{:ok, format_url(url, path)}
else
_ ->
find_favicon_in_root(url, headers)
end
end
@spec format_url(String.t(), String.t()) :: String.t()
defp format_url(url, path) do
image_uri = URI.parse(path)
uri = URI.parse(url)
cond do
is_nil(image_uri.host) -> "#{uri.scheme}://#{uri.host}#{path}"
is_nil(image_uri.scheme) -> "#{uri.scheme}:#{path}"
true -> path
end
end
@spec find_favicon_link_tag(String.t()) :: {:ok, tuple()} | {:error, any()}
defp find_favicon_link_tag(html) do
with {:ok, html} <- Floki.parse_document(html),
:ok <- Logger.debug(inspect(html)),
links <- Floki.find(html, "link"),
:ok <- Logger.debug(inspect(links)),
{:link, link} when not is_nil(link) <-
{:link,
Enum.find(links, fn {"link", attrs, _} ->
Enum.any?(attrs, fn {name, value} ->
name == "rel" && String.contains?(value, "icon") &&
!String.contains?(value, "-icon-")
end)
end)} do
{:ok, link}
else
{:link, nil} -> {:error, "No link found"}
err -> err
end
end
@spec find_favicon_in_root(String.t(), List.t()) :: {:ok, String.t()} | {:error, any()}
defp find_favicon_in_root(url, headers) do
uri = URI.parse(url)
favicon_url = "#{uri.scheme}://#{uri.host}/favicon.ico"
case HTTPoison.head(favicon_url, headers, @options) do
{:ok, %HTTPoison.Response{status_code: code}} when code in 200..299 ->
{:ok, favicon_url}
{:ok, %HTTPoison.Response{}} ->
{:error, "Error while doing a HEAD request on the favicon"}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, reason}
end
end
end

View File

@ -0,0 +1,280 @@
# Portions of this file are derived from Pleroma:
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mobilizon.Service.RichMedia.Parser do
@options [
max_body: 2_000_000,
timeout: 10_000,
recv_timeout: 20_000,
follow_redirect: true
]
alias Mobilizon.Config
alias Mobilizon.Service.RichMedia.Favicon
require Logger
defp parsers do
Mobilizon.Config.get([:rich_media, :parsers])
end
def parse(nil), do: {:error, "No URL provided"}
@spec parse(String.t()) :: {:ok, map()} | {:error, any()}
def parse(url) do
try do
case Cachex.fetch(:rich_media_cache, url, fn _ ->
case parse_url(url) do
{:ok, data} -> {:commit, data}
{:error, err} -> {:ignore, err}
end
end) do
{status, value} when status in [:ok, :commit] ->
{:ok, value}
{_, err} ->
{:error, err}
end
rescue
e ->
{:error, "Cachex error: #{inspect(e)}"}
end
end
@spec parse_url(String.t(), List.t()) :: {:ok, map()} | {:error, any()}
defp parse_url(url, options \\ []) do
user_agent = Keyword.get(options, :user_agent, Config.instance_user_agent())
headers = [{"User-Agent", user_agent}]
try do
with {:ok, _} <- prevent_local_address(url),
{:ok, %HTTPoison.Response{body: body, status_code: code, headers: response_headers}}
when code in 200..299 <-
HTTPoison.get(
url,
headers,
@options
),
{:is_html, response_headers, true} <-
{:is_html, response_headers, is_html(response_headers)} do
body
|> parse_html()
|> maybe_parse()
|> Map.put(:url, url)
|> maybe_add_favicon()
|> clean_parsed_data()
|> check_parsed_data()
|> check_remote_picture_path()
else
{:is_html, response_headers, false} ->
data = get_data_for_media(response_headers, url)
{:ok, data}
{:error, err} ->
{:error, "HTTP error: #{inspect(err)}"}
end
rescue
e ->
{:error, "Parsing error: #{inspect(e)} #{inspect(__STACKTRACE__)}"}
end
end
@spec get_data_for_media(List.t(), String.t()) :: map()
defp get_data_for_media(response_headers, url) do
data = %{title: get_filename_from_headers(response_headers) || get_filename_from_url(url)}
if is_image(response_headers) do
Map.put(data, :image_remote_url, url)
else
data
end
end
@spec is_html(List.t()) :: boolean
defp is_html(headers) do
headers
|> get_header("Content-Type")
|> content_type_header_matches(["text/html", "application/xhtml"])
end
@spec is_image(List.t()) :: boolean
defp is_image(headers) do
headers
|> get_header("Content-Type")
|> content_type_header_matches(["image/"])
end
@spec is_media(List.t()) :: boolean
defp is_media(headers) do
headers
|> get_header("Content-Type")
|> content_type_header_matches(["image/", "video/"])
end
@spec content_type_header_matches(String.t() | nil, List.t()) :: boolean
defp content_type_header_matches(header, content_types \\ [])
defp content_type_header_matches(nil, content_types), do: false
defp content_type_header_matches(header, content_types) when is_binary(header) do
Enum.any?(content_types, fn content_type -> String.starts_with?(header, content_type) end)
end
@spec get_header(List.t(), String.t()) :: String.t() | nil
defp get_header(headers, key) do
case List.keyfind(headers, key, 0) do
{^key, value} -> String.downcase(value)
nil -> nil
end
end
@spec get_filename_from_headers(List.t()) :: String.t() | nil
defp get_filename_from_headers(headers) do
case get_header(headers, "Content-Disposition") do
nil -> nil
content_disposition -> parse_content_disposition(content_disposition)
end
end
@spec get_filename_from_url(String.t()) :: String.t()
defp get_filename_from_url(url) do
%URI{path: path} = URI.parse(url)
path
|> String.split("/", trim: true)
|> Enum.at(-1)
|> URI.decode()
end
# The following is taken from https://github.com/elixir-plug/plug/blob/65986ad32f9aaae3be50dc80cbdd19b326578da7/lib/plug/parsers/multipart.ex#L207
@spec parse_content_disposition(String.t()) :: String.t() | nil
defp parse_content_disposition(disposition) do
with [_, params] <- :binary.split(disposition, ";"),
%{"name" => _name} = params <- Plug.Conn.Utils.params(params) do
handle_disposition(params)
else
_ -> nil
end
end
@spec handle_disposition(map()) :: String.t() | nil
defp handle_disposition(params) do
case params do
%{"filename" => ""} ->
nil
%{"filename" => filename} ->
filename
%{"filename*" => ""} ->
nil
%{"filename*" => "utf-8''" <> filename} ->
URI.decode(filename)
_ ->
nil
end
end
defp parse_html(html), do: Floki.parse_document!(html)
defp maybe_parse(html) do
Enum.reduce_while(parsers(), %{}, fn parser, acc ->
require Logger
Logger.error(inspect(parser))
Logger.error(inspect(parser.parse(html, acc)))
case parser.parse(html, acc) do
{:ok, data} -> {:halt, data}
{:error, _msg} -> {:cont, acc}
end
end)
end
defp check_parsed_data(%{title: title} = data)
when is_binary(title) and byte_size(title) > 0 do
{:ok, data}
end
defp check_parsed_data(data) do
{:error, "Found metadata was invalid or incomplete: #{inspect(data)}"}
end
defp clean_parsed_data(data) do
data
|> Enum.reject(fn {key, val} ->
with {:ok, _} <- Jason.encode(%{key => val}) do
false
else
_ -> true
end
end)
|> Map.new()
end
defp prevent_local_address(url) do
with %URI{host: host} when not is_nil(host) <- URI.parse(url) do
host = String.downcase(host)
if validate_hostname_not_localhost(host) && validate_hostname_only(host) &&
validate_ip(host) do
{:ok, url}
else
{:error, "Host violates local access rules"}
end
else
_ ->
{:error, "Could not detect any host"}
end
end
defp validate_hostname_not_localhost(hostname),
do:
hostname != "localhost" && !String.ends_with?(hostname, ".local") &&
!String.ends_with?(hostname, ".localhost")
defp validate_hostname_only(hostname),
do: hostname |> String.graphemes() |> Enum.count(&(&1 == "o")) > 0
defp validate_ip(hostname) do
with {:ok, address} <- hostname |> String.to_charlist() |> :inet.parse_address() do
!IpReserved.is_reserved?(address)
else
# Not a valid IP
{:error, _} -> true
end
end
@spec maybe_add_favicon(map()) :: map()
defp maybe_add_favicon(%{url: url} = data) do
with {:ok, favicon_url} <- Favicon.fetch(url) do
Logger.debug("Adding favicon #{favicon_url} to metadata")
Map.put(data, :favicon_url, favicon_url)
else
err ->
Logger.debug("Failed to add favicon to metadata")
Logger.debug(inspect(err))
data
end
end
@spec check_remote_picture_path(map()) :: map()
defp check_remote_picture_path(%{image_remote_url: image_remote_url, url: url} = data) do
Logger.debug("Checking image_remote_url #{image_remote_url}")
image_uri = URI.parse(image_remote_url)
uri = URI.parse(url)
image_remote_url =
cond do
is_nil(image_uri.host) -> "#{uri.scheme}://#{uri.host}#{image_remote_url}"
is_nil(image_uri.scheme) -> "#{uri.scheme}:#{image_remote_url}"
true -> image_remote_url
end
Map.put(data, :image_remote_url, image_remote_url)
end
defp check_remote_picture_path(data), do: data
end

View File

@ -0,0 +1,38 @@
# Portions of this file are derived from Pleroma:
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mobilizon.Service.RichMedia.Parsers.Fallback do
@spec parse(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
def parse(html, data) do
data =
data
|> maybe_put(html, :title)
|> maybe_put(html, :description)
if Enum.empty?(data) do
{:error, "Not even a title"}
else
{:ok, data}
end
end
defp maybe_put(meta, html, attr) do
case get_page(html, attr) do
"" -> meta
content -> Map.put_new(meta, attr, content)
end
end
defp get_page(html, :title) do
Floki.find(html, "html head title") |> List.first() |> Floki.text() |> String.trim()
end
defp get_page(html, :description) do
case Floki.find(html, "html head meta[name='description']") |> List.first() do
nil -> ""
elem -> Floki.attribute(elem, "content") |> List.first() |> String.trim()
end
end
end

View File

@ -0,0 +1,73 @@
# Portions of this file are derived from Pleroma:
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mobilizon.Service.RichMedia.Parsers.MetaTagsParser do
def parse(html, data, prefix, error_message, key_name, value_name \\ "content") do
meta_data =
html
|> get_elements(key_name, prefix)
|> Enum.reduce(data, fn el, acc ->
attributes = normalize_attributes(el, prefix, key_name, value_name)
Map.merge(acc, attributes)
end)
|> maybe_put_title(html)
|> maybe_put_description(html)
if Enum.empty?(meta_data) do
{:error, error_message}
else
{:ok, meta_data}
end
end
defp get_elements(html, key_name, prefix) do
html |> Floki.find("meta[#{key_name}^='#{prefix}:']")
end
defp normalize_attributes(html_node, prefix, key_name, value_name) do
{_tag, attributes, _children} = html_node
data =
Enum.into(attributes, %{}, fn {name, value} ->
{name, String.trim_leading(value, "#{prefix}:")}
end)
%{String.to_atom(data[key_name]) => data[value_name]}
end
defp maybe_put_title(%{title: _} = meta, _), do: meta
defp maybe_put_title(meta, html) when meta != %{} do
case get_page_title(html) do
"" -> meta
title -> Map.put_new(meta, :title, title)
end
end
defp maybe_put_title(meta, _), do: meta
defp maybe_put_description(%{description: _} = meta, _), do: meta
defp maybe_put_description(meta, html) when meta != %{} do
case get_page_description(html) do
"" -> meta
description -> Map.put_new(meta, :description, description)
end
end
defp maybe_put_description(meta, _), do: meta
defp get_page_title(html) do
Floki.find(html, "html head title") |> List.first() |> Floki.text()
end
defp get_page_description(html) do
case Floki.find(html, "html head meta[name='description']") |> List.first() do
nil -> ""
elem -> Floki.attribute(elem, "content")
end
end
end

View File

@ -0,0 +1,75 @@
# Portions of this file are derived from Pleroma:
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mobilizon.Service.RichMedia.Parsers.OEmbed do
alias Mobilizon.Service.Formatter.HTML
require Logger
def parse(html, _data) do
Logger.debug("Using OEmbed parser")
with elements = [_ | _] <- get_discovery_data(html),
{:ok, oembed_url} <- get_oembed_url(elements),
{:ok, oembed_data} <- get_oembed_data(oembed_url),
oembed_data <- filter_oembed_data(oembed_data) do
Logger.debug("Data found with OEmbed parser")
Logger.debug(inspect(oembed_data))
{:ok, oembed_data}
else
_e ->
{:error, "No OEmbed data found"}
end
end
defp get_discovery_data(html) do
html |> Floki.find("link[type='application/json+oembed']")
end
defp get_oembed_url(nodes) do
{"link", attributes, _children} = nodes |> hd()
{:ok, Enum.into(attributes, %{})["href"]}
end
defp get_oembed_data(url) do
with {:ok, %HTTPoison.Response{body: json}} <- HTTPoison.get(url),
{:ok, data} <- Jason.decode(json),
data <- data |> Map.new(fn {k, v} -> {String.to_atom(k), v} end) do
{:ok, data}
end
end
defp filter_oembed_data(data) do
case Map.get(data, :type) do
nil ->
{:error, "No type declared for OEmbed data"}
"link" ->
Map.put(data, :image_remote_url, Map.get(data, :thumbnail_url))
"photo" ->
if Map.get(data, :url, "") == "" do
{:error, "No URL for photo OEmbed data"}
else
data
|> Map.put(:image_remote_url, Map.get(data, :url))
|> Map.put(:width, Map.get(data, :width, 0))
|> Map.put(:height, Map.get(data, :height, 0))
end
"video" ->
{:ok, html} = data |> Map.get(:html, "") |> HTML.filter_tags_for_oembed()
data
|> Map.put(:html, html)
|> Map.put(:width, Map.get(data, :width, 0))
|> Map.put(:height, Map.get(data, :height, 0))
|> Map.put(:image_remote_url, Map.get(data, :thumbnail_url))
"rich" ->
{:error, "OEmbed data has rich type, which we don't support"}
end
end
end

View File

@ -0,0 +1,33 @@
# Portions of this file are derived from Pleroma:
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mobilizon.Service.RichMedia.Parsers.OGP do
require Logger
def parse(html, data) do
Logger.debug("Using OpenGraph card parser")
with {:ok, data} <-
Mobilizon.Service.RichMedia.Parsers.MetaTagsParser.parse(
html,
data,
"og",
"No OGP metadata found",
"property"
) do
data = transform_tags(data)
Logger.debug("Data found with OpenGraph card parser")
Logger.debug(inspect(data))
{:ok, data}
end
end
defp transform_tags(data) do
data
|> Map.put(:image_remote_url, Map.get(data, :image))
|> Map.put(:width, Map.get(data, :"image:width"))
|> Map.put(:height, Map.get(data, :"image:height"))
end
end

View File

@ -0,0 +1,31 @@
# Portions of this file are derived from Pleroma:
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Mobilizon.Service.RichMedia.Parsers.TwitterCard do
alias Mobilizon.Service.RichMedia.Parsers.MetaTagsParser
require Logger
@spec parse(String.t(), map()) :: {:ok, map()} | {:error, String.t()}
def parse(html, data) do
Logger.debug("Using Twitter card parser")
res =
data
|> parse_name_attrs(html)
|> parse_property_attrs(html)
Logger.debug("Data found with Twitter card parser")
Logger.debug(inspect(res))
res
end
defp parse_name_attrs(data, html) do
MetaTagsParser.parse(html, data, "twitter", %{}, "name")
end
defp parse_property_attrs({_, data}, html) do
MetaTagsParser.parse(html, data, "twitter", "No twitter card metadata found", "property")
end
end

View File

@ -3,12 +3,12 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
ActivityPub related cache.
"""
alias Mobilizon.{Actors, Collections, Conversations, Events, Todos, Tombstone}
alias Mobilizon.{Actors, Conversations, Events, Resources, Todos, Tombstone}
alias Mobilizon.Actors.Actor
alias Mobilizon.Collections.{Collection, Resource}
alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Federation.ActivityPub.Relay
alias Mobilizon.Resources.Resource
alias Mobilizon.Todos.{Todo, TodoList}
alias Mobilizon.Web.Endpoint
alias Mobilizon.Web.Router.Helpers, as: Routes
@ -71,23 +71,6 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
end)
end
@doc """
Gets a collection by its UUID, with all associations loaded.
"""
@spec get_collection_by_uuid_with_preload(String.t()) ::
{:commit, Collection.t()} | {:ignore, nil}
def get_collection_by_uuid_with_preload(uuid) do
Cachex.fetch(@cache, "collection_" <> uuid, fn "collection_" <> uuid ->
case Collections.get_collection(uuid) do
%Collection{} = collection ->
{:commit, collection}
nil ->
{:ignore, nil}
end
end)
end
@doc """
Gets a resource by its UUID, with all associations loaded.
"""
@ -95,7 +78,7 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
{:commit, Resource.t()} | {:ignore, nil}
def get_resource_by_uuid_with_preload(uuid) do
Cachex.fetch(@cache, "resource_" <> uuid, fn "resource_" <> uuid ->
case Collections.get_resource(uuid) do
case Resources.get_resource(uuid) do
%Resource{} = resource ->
{:commit, resource}

View File

@ -20,7 +20,6 @@ defmodule Mobilizon.Web.Cache do
defdelegate get_local_actor_by_name(name), to: ActivityPub
defdelegate get_public_event_by_uuid_with_preload(uuid), to: ActivityPub
defdelegate get_comment_by_uuid_with_preload(uuid), to: ActivityPub
defdelegate get_collection_by_uuid_with_preload(uuid), to: ActivityPub
defdelegate get_resource_by_uuid_with_preload(uuid), to: ActivityPub
defdelegate get_todo_list_by_uuid_with_preload(uuid), to: ActivityPub
defdelegate get_todo_by_uuid_with_preload(uuid), to: ActivityPub

View File

@ -13,6 +13,7 @@ defmodule Mobilizon.Web.PageController do
plug(:put_layout, false)
action_fallback(Mobilizon.Web.FallbackController)
@spec index(Plug.Conn.t(), any) :: Plug.Conn.t()
def index(conn, _params), do: render(conn, :index)
def actor(conn, %{"name" => name}) do
@ -30,26 +31,23 @@ defmodule Mobilizon.Web.PageController do
render_or_error(conn, &checks?/3, status, :comment, comment)
end
def collection(conn, %{"uuid" => uuid}) do
{status, comment} = Cache.get_collection_by_uuid_with_preload(uuid)
render_or_error(conn, &checks?/3, status, :collection, comment)
end
@spec resource(Plug.Conn.t(), map()) :: Plug.Conn.t() | {:error, :not_found}
def resource(conn, %{"uuid" => uuid}) do
{status, comment} = Cache.get_resource_by_uuid_with_preload(uuid)
render_or_error(conn, &checks?/3, status, :resource, comment)
{status, resource} = Cache.get_resource_by_uuid_with_preload(uuid)
render_or_error(conn, &checks?/3, status, :resource, resource)
end
def todo_list(conn, %{"uuid" => uuid}) do
{status, comment} = Cache.get_todo_list_by_uuid_with_preload(uuid)
render_or_error(conn, &checks?/3, status, :todo_list, comment)
{status, todo_list} = Cache.get_todo_list_by_uuid_with_preload(uuid)
render_or_error(conn, &checks?/3, status, :todo_list, todo_list)
end
def todo(conn, %{"uuid" => uuid}) do
{status, comment} = Cache.get_todo_by_uuid_with_preload(uuid)
render_or_error(conn, &checks?/3, status, :todo, comment)
{status, todo} = Cache.get_todo_by_uuid_with_preload(uuid)
render_or_error(conn, &checks?/3, status, :todo, todo)
end
@spec interact(Plug.Conn.t(), map()) :: Plug.Conn.t() | {:error, :not_found}
def interact(conn, %{"uri" => uri}) do
case ActivityPub.fetch_object_from_url(uri) do
{:ok, %Event{uuid: uuid}} -> redirect(conn, to: "/events/#{uuid}")

View File

@ -75,7 +75,6 @@ defmodule Mobilizon.Web.Router do
get("/events/:uuid", PageController, :event)
get("/comments/:uuid", PageController, :comment)
get("/resource/:uuid", PageController, :resource, as: "resource")
get("/collection/:uuid", PageController, :collection, as: "collection")
get("/todo-list/:uuid", PageController, :todo_list, as: "todo_list")
get("/todo/:uuid", PageController, :todo, as: "todo")
end

View File

@ -102,6 +102,9 @@ defmodule Mobilizon.Mixfile do
{:ex_optimizer, "~> 0.1"},
{:progress_bar, "~> 2.0"},
{:oban, "~> 1.2.0"},
{:floki, "~> 0.26.0"},
{:ip_reserved, "~> 0.1.0"},
{:fast_sanitize, "~> 0.1"},
# Dev and test dependencies
{:phoenix_live_reload, "~> 1.2", only: [:dev, :e2e]},
{:ex_machina, "~> 2.3", only: [:dev, :test]},

224
mix.lock
View File

@ -1,115 +1,121 @@
%{
"absinthe": {:hex, :absinthe, "1.4.16", "0933e4d9f12652b12115d5709c0293a1bf78a22578032e9ad0dad4efee6b9eb1", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
"absinthe_ecto": {:hex, :absinthe_ecto, "0.1.3", "420b68129e79fe4571a4838904ba03e282330d335da47729ad52ffd7b8c5fcb1", [:mix], [{:absinthe, "~> 1.3.0 or ~> 1.4.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm"},
"absinthe_phoenix": {:hex, :absinthe_phoenix, "1.4.4", "af3b7b44483121f756ea0ec75a536b74f67cdd62ec6a34b9e58df1fb4662389e", [:mix], [{:absinthe, "~> 1.4.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4.0", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm"},
"absinthe_plug": {:hex, :absinthe_plug, "1.4.7", "939b6b9e1c7abc6b399a5b49faa690a1fbb55b195c670aa35783b14b08ccec7a", [:mix], [{:absinthe, "~> 1.4.11", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.2 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"argon2_elixir": {:hex, :argon2_elixir, "2.3.0", "e251bdafd69308e8c1263e111600e6d68bd44f23d2cccbe43fcb1a417a76bc8e", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"},
"atomex": {:hex, :atomex, "0.3.0", "19b5d1a2aef8706dbd307385f7d5d9f6f273869226d317492c396c7bacf26402", [:mix], [{:xml_builder, "~> 2.0.0", [hex: :xml_builder, repo: "hexpm", optional: false]}], "hexpm"},
"absinthe": {:hex, :absinthe, "1.4.16", "0933e4d9f12652b12115d5709c0293a1bf78a22578032e9ad0dad4efee6b9eb1", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "076b8bd9552f4966ba1242f412f6c439b731169a36a0ddaaffcd3893828f5bf6"},
"absinthe_ecto": {:hex, :absinthe_ecto, "0.1.3", "420b68129e79fe4571a4838904ba03e282330d335da47729ad52ffd7b8c5fcb1", [:mix], [{:absinthe, "~> 1.3.0 or ~> 1.4.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "355b9db34abfab96ae1e025434b66e11002babcf4fe6b7144d26ff7548985f52"},
"absinthe_phoenix": {:hex, :absinthe_phoenix, "1.4.4", "af3b7b44483121f756ea0ec75a536b74f67cdd62ec6a34b9e58df1fb4662389e", [:mix], [{:absinthe, "~> 1.4.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.4.0", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "54118c32ca00257b3cd3e616b3f9cee99e493d2399528334cbb5457e470400d3"},
"absinthe_plug": {:hex, :absinthe_plug, "1.4.7", "939b6b9e1c7abc6b399a5b49faa690a1fbb55b195c670aa35783b14b08ccec7a", [:mix], [{:absinthe, "~> 1.4.11", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.2 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c6ecb0e56a963287ac252d0563e5b33b84b300ce8203d3d1410dddb5dc6d08e9"},
"argon2_elixir": {:hex, :argon2_elixir, "2.3.0", "e251bdafd69308e8c1263e111600e6d68bd44f23d2cccbe43fcb1a417a76bc8e", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "28ccb63bff213aecec1f7f3dde9648418b031f822499973281d8f494b9d5a3b3"},
"atomex": {:hex, :atomex, "0.3.0", "19b5d1a2aef8706dbd307385f7d5d9f6f273869226d317492c396c7bacf26402", [:mix], [{:xml_builder, "~> 2.0.0", [hex: :xml_builder, repo: "hexpm", optional: false]}], "hexpm", "025dbc3a3e99380894791a093019f535d0ef6cf1916f6ec1b778ac107fcfc3e4"},
"auto_linker": {:git, "https://git.pleroma.social/pleroma/auto_linker.git", "95e8188490e97505c56636c1379ffdf036c1fdde", [ref: "95e8188490e97505c56636c1379ffdf036c1fdde"]},
"bamboo": {:hex, :bamboo, "1.4.0", "7b9201c49a843e4802061cf45692405b2c00efcf1cebf8b7b64f015ead072392", [:mix], [{:hackney, ">= 1.13.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"bamboo_smtp": {:hex, :bamboo_smtp, "2.1.0", "4be58f3c51d9f7875dc169ae58a1d2f08e5b718bf3895f70d130548c0598f422", [:mix], [{:bamboo, "~> 1.2", [hex: :bamboo, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 0.15.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}], "hexpm"},
"bamboo": {:hex, :bamboo, "1.4.0", "7b9201c49a843e4802061cf45692405b2c00efcf1cebf8b7b64f015ead072392", [:mix], [{:hackney, ">= 1.13.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b9cad03bf38c7f37b6308876039355665b6ce09fefb46dc529cef4def912cffa"},
"bamboo_smtp": {:hex, :bamboo_smtp, "2.1.0", "4be58f3c51d9f7875dc169ae58a1d2f08e5b718bf3895f70d130548c0598f422", [:mix], [{:bamboo, "~> 1.2", [hex: :bamboo, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 0.15.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}], "hexpm", "0aad00ef93d0e0c83a0e1ca6998fea070c8a720a990fbda13ce834136215ee49"},
"base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [:rebar], [], "hexpm"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"},
"cachex": {:hex, :cachex, "3.2.0", "a596476c781b0646e6cb5cd9751af2e2974c3e0d5498a8cab71807618b74fe2f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm"},
"certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm"},
"cldr_utils": {:hex, :cldr_utils, "2.8.0", "a1355a658fdf7118a678002a5333562d464e1bbdc3c89a5e0c3d8088038e57f5", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm"},
"comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm"},
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
"cors_plug": {:hex, :cors_plug, "2.0.2", "2b46083af45e4bc79632bd951550509395935d3e7973275b2b743bd63cc942ce", [:mix], [{:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"},
"cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm"},
"credo": {:hex, :credo, "1.3.0", "37699fefdbe1b0480a5a6b73f259207e9cd7ad5e492277e22c2179bcb226a67b", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
"dataloader": {:hex, :dataloader, "1.0.7", "58351b335673cf40601429bfed6c11fece6ce7ad169b2ac0f0fe83e716587391", [:mix], [{:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"},
"db_connection": {:hex, :db_connection, "2.2.1", "caee17725495f5129cb7faebde001dc4406796f12a62b8949f4ac69315080566", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"},
"decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm"},
"dialyxir": {:hex, :dialyxir, "1.0.0-rc.7", "6287f8f2cb45df8584317a4be1075b8c9b8a69de8eeb82b4d9e6c761cf2664cd", [:mix], [{:erlex, ">= 0.2.5", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm"},
"earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm"},
"ecto": {:hex, :ecto, "3.3.4", "95b05c82ae91361475e5491c9f3ac47632f940b3f92ae3988ac1aad04989c5bb", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
"ecto_autoslug_field": {:hex, :ecto_autoslug_field, "2.0.1", "2177c1c253f6dd3efd4b56d1cb76104d0a6ef044c6b9a7a0ad6d32665c4111e5", [:mix], [{:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:slugger, ">= 0.2.0", [hex: :slugger, repo: "hexpm", optional: false]}], "hexpm"},
"ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm"},
"ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
"elixir_feed_parser": {:hex, :elixir_feed_parser, "2.1.0", "bb96fb6422158dc7ad59de62ef211cc69d264acbbe63941a64a5dce97bbbc2e6", [:mix], [{:timex, "~> 3.4", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm"},
"elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm"},
"eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm"},
"ex_cldr": {:hex, :ex_cldr, "2.13.0", "742f14a4afcfea61a190d603d8e555d2c91d71e4e8fc2520d5dc35616969e225", [:mix], [{:cldr_utils, "~> 2.3", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.13", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"},
"ex_cldr_calendars": {:hex, :ex_cldr_calendars, "1.7.1", "d2490358148c09b8915a20d959390ce10ea13cf6e7cfb1df823afbbc2ec71699", [:mix], [{:ex_cldr, "~> 2.12", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_lists, "~> 2.4", [hex: :ex_cldr_lists, repo: "hexpm", optional: true]}, {:ex_cldr_units, "~> 2.0", [hex: :ex_cldr_units, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
"ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.4.1", "a8e8330a6d0712b8bb34c5e3759311da1d53fa8bebef163d72615f0ea60c0738", [:mix], [{:ex_cldr, "~> 2.6", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
"ex_cldr_dates_times": {:hex, :ex_cldr_dates_times, "2.3.0", "5c9fe5b695ac3adf30e3e5640a1f9b0e316f5773409d30e605a7edfc86c55921", [:mix], [{:ex_cldr, "~> 2.8", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_calendars, "~> 1.2", [hex: :ex_cldr_calendars, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.6", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
"ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.12.1", "2548f2a3ef6812c6600962795fb27f8725fec1afb790486f07dacc7eda9bb4b8", [:mix], [{:cldr_utils, "~> 2.6", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.13", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.3", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
"ex_crypto": {:hex, :ex_crypto, "0.10.0", "af600a89b784b36613a989da6e998c1b200ff1214c3cfbaf8deca4aa2f0a1739", [:mix], [{:poison, ">= 2.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
"ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"},
"ex_ical": {:hex, :ex_ical, "0.2.0", "4b928b554614704016cc0c9ee226eb854da9327a1cc460457621ceacb1ac29a6", [:mix], [{:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm"},
"ex_machina": {:hex, :ex_machina, "2.4.0", "09a34c5d371bfb5f78399029194a8ff67aff340ebe8ba19040181af35315eabb", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm"},
"ex_optimizer": {:hex, :ex_optimizer, "0.1.0", "1d12f7ea289092a38a794b84bd2f42c1e0621cb307c0f3e6a7df620839af2937", [:mix], [{:file_info, "~> 0.0.4", [hex: :file_info, repo: "hexpm", optional: false]}], "hexpm"},
"ex_unit_notifier": {:hex, :ex_unit_notifier, "0.1.4", "36a2dcab829f506e01bf17816590680dd1474407926d43e64c1263e627c364b8", [:mix], [], "hexpm"},
"exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm"},
"excoveralls": {:hex, :excoveralls, "0.12.2", "a513defac45c59e310ac42fcf2b8ae96f1f85746410f30b1ff2b710a4b6cd44b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
"exgravatar": {:hex, :exgravatar, "2.0.2", "638412896170409da114f98947d3f8d4f38e851b0e329c1cc4cd324d5e2ea081", [:mix], [], "hexpm"},
"exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"},
"exvcr": {:hex, :exvcr, "0.11.1", "a5e5f57a67538e032e16cfea6cfb1232314fb146e3ceedf1cde4a11f12fb7a58", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"},
"file_info": {:hex, :file_info, "0.0.4", "2e0e77f211e833f38ead22cb29ce53761d457d80b3ffe0ffe0eb93880b0963b2", [:mix], [{:mimetype_parser, "~> 0.1.2", [hex: :mimetype_parser, repo: "hexpm", optional: false]}], "hexpm"},
"file_system": {:hex, :file_system, "0.2.8", "f632bd287927a1eed2b718f22af727c5aeaccc9a98d8c2bd7bff709e851dc986", [:mix], [], "hexpm"},
"gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm"},
"geo": {:hex, :geo, "3.3.3", "1119302b20d21515fbcec0a180b82653524067873ed333e7fa1f55e39959d702", [:mix], [], "hexpm"},
"geo_postgis": {:hex, :geo_postgis, "3.3.1", "45bc96b9121d0647341685dc9d44956d61338707482d655c803500676b0413a1", [:mix], [{:geo, "~> 3.3", [hex: :geo, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0 or ~> 4.0", [hex: :poison, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm"},
"geohax": {:hex, :geohax, "0.3.0", "c2e7d8cc6cdf4158120b50fcbe03a296da561d2089eb7ad68d84b6f5d3df5607", [:mix], [], "hexpm"},
"geolix": {:hex, :geolix, "1.0.0", "b225d930fb0418871ce7d89dabf293bd80eb5bd66db1887f80510c122f4ef271", [:mix], [{:poolboy, "~> 1.0", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm"},
"geolix_adapter_mmdb2": {:hex, :geolix_adapter_mmdb2, "0.3.0", "ff36c4c0df390854e0266280c20dfa57ca68d9a0c2ff42c22bf3af5725b5bdcb", [:mix], [{:geolix, "~> 1.0", [hex: :geolix, repo: "hexpm", optional: false]}, {:mmdb2_decoder, "~> 2.1", [hex: :mmdb2_decoder, repo: "hexpm", optional: false]}], "hexpm"},
"gettext": {:hex, :gettext, "0.17.4", "f13088e1ec10ce01665cf25f5ff779e7df3f2dc71b37084976cf89d1aa124d5c", [:mix], [], "hexpm"},
"guardian": {:hex, :guardian, "2.1.1", "1f02b349f6ba765647cc834036a8d76fa4bd65605342fe3a031df3c99d0d411a", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm"},
"guardian_db": {:hex, :guardian_db, "2.0.3", "18c847efbf7ec3c0dd44c7aecaeeb2777588bbb8d2073ffc36e71037108b3be6", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm"},
"guardian_phoenix": {:hex, :guardian_phoenix, "2.0.1", "89a817265af09a6ddf7cb1e77f17ffca90cea2db10ff888375ef34502b2731b1", [:mix], [{:guardian, "~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm"},
"hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm"},
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.0", "0310d27d7bafb662f30bff22ec732a72414799c83eaf44239781fd23b96216c0", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm"},
"http_sign": {:hex, :http_sign, "0.1.1", "b16edb83aa282892f3271f9a048c155e772bf36e15700ab93901484c55f8dd10", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
"cachex": {:hex, :cachex, "3.2.0", "a596476c781b0646e6cb5cd9751af2e2974c3e0d5498a8cab71807618b74fe2f", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:jumper, "~> 1.0", [hex: :jumper, repo: "hexpm", optional: false]}, {:sleeplocks, "~> 1.1", [hex: :sleeplocks, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm", "aef93694067a43697ae0531727e097754a9e992a1e7946296f5969d6dd9ac986"},
"certifi": {:hex, :certifi, "2.5.1", "867ce347f7c7d78563450a18a6a28a8090331e77fa02380b4a21962a65d36ee5", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "805abd97539caf89ec6d4732c91e62ba9da0cda51ac462380bbd28ee697a8c42"},
"cldr_utils": {:hex, :cldr_utils, "2.8.0", "a1355a658fdf7118a678002a5333562d464e1bbdc3c89a5e0c3d8088038e57f5", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "b37b2ae815bf62a01ba4deb90bd75c70fa0ed07f05b5a1b530d21a99b223844b"},
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
"comeonin": {:hex, :comeonin, "5.3.1", "7fe612b739c78c9c1a75186ef2d322ce4d25032d119823269d0aa1e2f1e20025", [:mix], [], "hexpm", "d6222483060c17f0977fad1b7401ef0c5863c985a64352755f366aee3799c245"},
"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm", "4a0850c9be22a43af9920a71ab17c051f5f7d45c209e40269a1938832510e4d9"},
"cors_plug": {:hex, :cors_plug, "2.0.2", "2b46083af45e4bc79632bd951550509395935d3e7973275b2b743bd63cc942ce", [:mix], [{:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f0d0e13f71c51fd4ef8b2c7e051388e4dfb267522a83a22392c856de7e46465f"},
"cowboy": {:hex, :cowboy, "2.7.0", "91ed100138a764355f43316b1d23d7ff6bdb0de4ea618cb5d8677c93a7a2f115", [:rebar3], [{:cowlib, "~> 2.8.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "04fd8c6a39edc6aaa9c26123009200fc61f92a3a94f3178c527b70b767c6e605"},
"cowlib": {:hex, :cowlib, "2.8.0", "fd0ff1787db84ac415b8211573e9a30a3ebe71b5cbff7f720089972b2319c8a4", [:rebar3], [], "hexpm", "79f954a7021b302186a950a32869dbc185523d99d3e44ce430cd1f3289f41ed4"},
"credo": {:hex, :credo, "1.3.0", "37699fefdbe1b0480a5a6b73f259207e9cd7ad5e492277e22c2179bcb226a67b", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8036b9226e4440d3ebce3931505e407b8d59fc95975f574c26337812e8de2a86"},
"dataloader": {:hex, :dataloader, "1.0.7", "58351b335673cf40601429bfed6c11fece6ce7ad169b2ac0f0fe83e716587391", [:mix], [{:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "12bf66478e4a5085d09dc96932d058c206ee8c219cc7691d12a40dc35c8cefaa"},
"db_connection": {:hex, :db_connection, "2.2.1", "caee17725495f5129cb7faebde001dc4406796f12a62b8949f4ac69315080566", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "2b02ece62d9f983fcd40954e443b7d9e6589664380e5546b2b9b523cd0fb59e1"},
"decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"},
"dialyxir": {:hex, :dialyxir, "1.0.0-rc.7", "6287f8f2cb45df8584317a4be1075b8c9b8a69de8eeb82b4d9e6c761cf2664cd", [:mix], [{:erlex, ">= 0.2.5", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "506294d6c543e4e5282d4852aead19ace8a35bedeb043f9256a06a6336827122"},
"earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"},
"ecto": {:hex, :ecto, "3.3.4", "95b05c82ae91361475e5491c9f3ac47632f940b3f92ae3988ac1aad04989c5bb", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "9b96cbb83a94713731461ea48521b178b0e3863d310a39a3948c807266eebd69"},
"ecto_autoslug_field": {:hex, :ecto_autoslug_field, "2.0.1", "2177c1c253f6dd3efd4b56d1cb76104d0a6ef044c6b9a7a0ad6d32665c4111e5", [:mix], [{:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:slugger, ">= 0.2.0", [hex: :slugger, repo: "hexpm", optional: false]}], "hexpm", "a3cc73211f2e75b89a03332183812ebe1ac08be2e25a1df5aa3d1422f92c45c3"},
"ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"},
"ecto_sql": {:hex, :ecto_sql, "3.3.4", "aa18af12eb875fbcda2f75e608b3bd534ebf020fc4f6448e4672fcdcbb081244", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.4 or ~> 3.3.3", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.3.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5eccbdbf92e3c6f213007a82d5dbba4cd9bb659d1a21331f89f408e4c0efd7a8"},
"elixir_feed_parser": {:hex, :elixir_feed_parser, "2.1.0", "bb96fb6422158dc7ad59de62ef211cc69d264acbbe63941a64a5dce97bbbc2e6", [:mix], [{:timex, "~> 3.4", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "2d3c62fe7b396ee3b73d7160bc8fadbd78bfe9597c98c7d79b3f1038d9cba28f"},
"elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"},
"ex_cldr": {:hex, :ex_cldr, "2.13.0", "742f14a4afcfea61a190d603d8e555d2c91d71e4e8fc2520d5dc35616969e225", [:mix], [{:cldr_utils, "~> 2.3", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.13", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "5e4cf3e945ee60156a3342e2a762f69036ffbe1f80520cc88592d68f12c5db55"},
"ex_cldr_calendars": {:hex, :ex_cldr_calendars, "1.7.1", "d2490358148c09b8915a20d959390ce10ea13cf6e7cfb1df823afbbc2ec71699", [:mix], [{:ex_cldr, "~> 2.12", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_lists, "~> 2.4", [hex: :ex_cldr_lists, repo: "hexpm", optional: true]}, {:ex_cldr_units, "~> 2.0", [hex: :ex_cldr_units, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "7d169e5899a32f65df1d7e5cdd8ac5b0480cd2fd4aea7fcf5e0e51c487ff166a"},
"ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.4.1", "a8e8330a6d0712b8bb34c5e3759311da1d53fa8bebef163d72615f0ea60c0738", [:mix], [{:ex_cldr, "~> 2.6", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "256a79113ce37689907e169d6caaeec2ca95ef728d389b8bc10ae429f0d1e854"},
"ex_cldr_dates_times": {:hex, :ex_cldr_dates_times, "2.3.0", "5c9fe5b695ac3adf30e3e5640a1f9b0e316f5773409d30e605a7edfc86c55921", [:mix], [{:ex_cldr, "~> 2.8", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_calendars, "~> 1.2", [hex: :ex_cldr_calendars, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.6", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "be23d316689c85800371cc38418c31b00dc06503996ade80a3e9e5f7645b7611"},
"ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.12.1", "2548f2a3ef6812c6600962795fb27f8725fec1afb790486f07dacc7eda9bb4b8", [:mix], [{:cldr_utils, "~> 2.6", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.13", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.3", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "475297a6c3eb8b7ffffce97c2411c0ab543b4ffdb9eb79ecf0c2f466e759ae3a"},
"ex_crypto": {:hex, :ex_crypto, "0.10.0", "af600a89b784b36613a989da6e998c1b200ff1214c3cfbaf8deca4aa2f0a1739", [:mix], [{:poison, ">= 2.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm", "ccc7472cfe8a0f4565f97dce7e9280119bf15a5ea51c6535e5b65f00660cde1c"},
"ex_doc": {:hex, :ex_doc, "0.21.3", "857ec876b35a587c5d9148a2512e952e24c24345552259464b98bfbb883c7b42", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "0db1ee8d1547ab4877c5b5dffc6604ef9454e189928d5ba8967d4a58a801f161"},
"ex_ical": {:hex, :ex_ical, "0.2.0", "4b928b554614704016cc0c9ee226eb854da9327a1cc460457621ceacb1ac29a6", [:mix], [{:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "db76473b2ae0259e6633c6c479a5a4d8603f09497f55c88f9ef4d53d2b75befb"},
"ex_machina": {:hex, :ex_machina, "2.4.0", "09a34c5d371bfb5f78399029194a8ff67aff340ebe8ba19040181af35315eabb", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "a20bc9ddc721b33ea913b93666c5d0bdca5cbad7a67540784ae277228832d72c"},
"ex_optimizer": {:hex, :ex_optimizer, "0.1.0", "1d12f7ea289092a38a794b84bd2f42c1e0621cb307c0f3e6a7df620839af2937", [:mix], [{:file_info, "~> 0.0.4", [hex: :file_info, repo: "hexpm", optional: false]}], "hexpm", "a409cb91472e08d4791a129effe4687982f85e2debcb4ccb1a3711a36bfdc428"},
"ex_unit_notifier": {:hex, :ex_unit_notifier, "0.1.4", "36a2dcab829f506e01bf17816590680dd1474407926d43e64c1263e627c364b8", [:mix], [], "hexpm", "fddf5054dd5fd2f809e837b749570baa5c9798e11d0163921baec49b7d5762f2"},
"exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm", "1222419f706e01bfa1095aec9acf6421367dcfab798a6f67c54cf784733cd6b5"},
"excoveralls": {:hex, :excoveralls, "0.12.2", "a513defac45c59e310ac42fcf2b8ae96f1f85746410f30b1ff2b710a4b6cd44b", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "151c476331d49b45601ffc45f43cb3a8beb396b02a34e3777fea0ad34ae57d89"},
"exgravatar": {:hex, :exgravatar, "2.0.2", "638412896170409da114f98947d3f8d4f38e851b0e329c1cc4cd324d5e2ea081", [:mix], [], "hexpm", "f3deb5baa6fcf354a965d794ee73a956d95f1f79f41bddf69800c713cfb014a1"},
"exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm", "32e95820a97cffea67830e91514a2ad53b888850442d6d395f53a1ac60c82e07"},
"exvcr": {:hex, :exvcr, "0.11.1", "a5e5f57a67538e032e16cfea6cfb1232314fb146e3ceedf1cde4a11f12fb7a58", [:mix], [{:exactor, "~> 2.2", [hex: :exactor, repo: "hexpm", optional: false]}, {:exjsx, "~> 4.0", [hex: :exjsx, repo: "hexpm", optional: false]}, {:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: true]}, {:httpotion, "~> 3.1", [hex: :httpotion, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:meck, "~> 0.8", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "984a4d52d9e01d5f0e28d45718565a41dffab3ac18e029ae45d42f16a2a58a1d"},
"fast_html": {:hex, :fast_html, "1.0.3", "2cc0d4b68496266a1530e0c852cafeaede0bd10cfdee26fda50dc696c203162f", [:make, :mix], [], "hexpm", "ab3d782b639d3c4655fbaec0f9d032c91f8cab8dd791ac7469c2381bc7c32f85"},
"fast_sanitize": {:hex, :fast_sanitize, "0.1.7", "2a7cd8734c88a2de6de55022104f8a3b87f1fdbe8bbf131d9049764b53d50d0d", [:mix], [{:fast_html, "~> 1.0", [hex: :fast_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.8", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "f39fe8ea08fbac17487c30bf09b7d9f3e12472e51fb07a88ffeb8fd17da8ab67"},
"file_info": {:hex, :file_info, "0.0.4", "2e0e77f211e833f38ead22cb29ce53761d457d80b3ffe0ffe0eb93880b0963b2", [:mix], [{:mimetype_parser, "~> 0.1.2", [hex: :mimetype_parser, repo: "hexpm", optional: false]}], "hexpm", "50e7ad01c2c8b9339010675fe4dc4a113b8d6ca7eddce24d1d74fd0e762781a5"},
"file_system": {:hex, :file_system, "0.2.8", "f632bd287927a1eed2b718f22af727c5aeaccc9a98d8c2bd7bff709e851dc986", [:mix], [], "hexpm", "97a3b6f8d63ef53bd0113070102db2ce05352ecf0d25390eb8d747c2bde98bca"},
"floki": {:hex, :floki, "0.26.0", "4df88977e2e357c6720e1b650f613444bfb48c5acfc6a0c646ab007d08ad13bf", [:mix], [{:html_entities, "~> 0.5.0", [hex: :html_entities, repo: "hexpm", optional: false]}], "hexpm", "e7b66ce7feef5518a9cd9fc7b52dd62a64028bd9cb6d6ad282a0f0fc90a4ae52"},
"gen_smtp": {:hex, :gen_smtp, "0.15.0", "9f51960c17769b26833b50df0b96123605a8024738b62db747fece14eb2fbfcc", [:rebar3], [], "hexpm", "29bd14a88030980849c7ed2447b8db6d6c9278a28b11a44cafe41b791205440f"},
"geo": {:hex, :geo, "3.3.3", "1119302b20d21515fbcec0a180b82653524067873ed333e7fa1f55e39959d702", [:mix], [], "hexpm", "8297ae0ac5ce47bb608b2bc8a63030460020ae537de9464a7a652f25baf6d2c1"},
"geo_postgis": {:hex, :geo_postgis, "3.3.1", "45bc96b9121d0647341685dc9d44956d61338707482d655c803500676b0413a1", [:mix], [{:geo, "~> 3.3", [hex: :geo, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0 or ~> 4.0", [hex: :poison, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "3c3957d8750e3effd565f068ee658ef0e881f9a07084a23f6c5ef8262d09b8e9"},
"geohax": {:hex, :geohax, "0.3.0", "c2e7d8cc6cdf4158120b50fcbe03a296da561d2089eb7ad68d84b6f5d3df5607", [:mix], [], "hexpm", "af9d4f4d5b031fbecac1d404a3077d941de91eea94fd1be8c97fd33c520da6a7"},
"geolix": {:hex, :geolix, "1.0.0", "b225d930fb0418871ce7d89dabf293bd80eb5bd66db1887f80510c122f4ef271", [:mix], [{:poolboy, "~> 1.0", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm", "871685cbe0780bebe47486353152f165a540dbaae394a04782d8bcc6ac0f5d32"},
"geolix_adapter_mmdb2": {:hex, :geolix_adapter_mmdb2, "0.3.0", "ff36c4c0df390854e0266280c20dfa57ca68d9a0c2ff42c22bf3af5725b5bdcb", [:mix], [{:geolix, "~> 1.0", [hex: :geolix, repo: "hexpm", optional: false]}, {:mmdb2_decoder, "~> 2.1", [hex: :mmdb2_decoder, repo: "hexpm", optional: false]}], "hexpm", "0ecd1d780da12a6964b0dedd194cbf41c06d12a1fd4ea02ef9c2b0d3c64dea39"},
"gettext": {:hex, :gettext, "0.17.4", "f13088e1ec10ce01665cf25f5ff779e7df3f2dc71b37084976cf89d1aa124d5c", [:mix], [], "hexpm", "3c75b5ea8288e2ee7ea503ff9e30dfe4d07ad3c054576a6e60040e79a801e14d"},
"guardian": {:hex, :guardian, "2.1.1", "1f02b349f6ba765647cc834036a8d76fa4bd65605342fe3a031df3c99d0d411a", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "189b87ba7ce6b40d6ba029138098b96ffc4ae78f229f5b39539b9141af8bf0f8"},
"guardian_db": {:hex, :guardian_db, "2.0.3", "18c847efbf7ec3c0dd44c7aecaeeb2777588bbb8d2073ffc36e71037108b3be6", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:guardian, "~> 1.0 or ~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "17306e09498bca379fb8eded2ac44d7690f738ca14b17080d06a948d034ea087"},
"guardian_phoenix": {:hex, :guardian_phoenix, "2.0.1", "89a817265af09a6ddf7cb1e77f17ffca90cea2db10ff888375ef34502b2731b1", [:mix], [{:guardian, "~> 2.0", [hex: :guardian, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "21f439246715192b231f228680465d1ed5fbdf01555a4a3b17165532f5f9a08c"},
"hackney": {:hex, :hackney, "1.15.2", "07e33c794f8f8964ee86cebec1a8ed88db5070e52e904b8f12209773c1036085", [:rebar3], [{:certifi, "2.5.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.5", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "e0100f8ef7d1124222c11ad362c857d3df7cb5f4204054f9f0f4a728666591fc"},
"html_entities": {:hex, :html_entities, "0.5.1", "1c9715058b42c35a2ab65edc5b36d0ea66dd083767bef6e3edb57870ef556549", [:mix], [], "hexpm", "30efab070904eb897ff05cd52fa61c1025d7f8ef3a9ca250bc4e6513d16c32de"},
"html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.0", "0310d27d7bafb662f30bff22ec732a72414799c83eaf44239781fd23b96216c0", [:mix], [{:mochiweb, "~> 2.15", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "c5d79626be0b6e50c19ecdfb783ee26e85bd3a77436b488379ce6dc104ec4593"},
"http_sign": {:hex, :http_sign, "0.1.1", "b16edb83aa282892f3271f9a048c155e772bf36e15700ab93901484c55f8dd10", [:mix], [{:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "2d4b1c2579d85534035f12c9e1260abdf6d03a9ad4f515b2ee53b50e68c8b787"},
"http_signatures": {:git, "https://git.pleroma.social/pleroma/http_signatures.git", "293d77bb6f4a67ac8bde1428735c3b42f22cbb30", [ref: "293d77bb6f4a67ac8bde1428735c3b42f22cbb30"]},
"httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
"httpoison": {:hex, :httpoison, "1.6.2", "ace7c8d3a361cebccbed19c283c349b3d26991eff73a1eaaa8abae2e3c8089b6", [:mix], [{:hackney, "~> 1.15 and >= 1.15.2", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "aa2c74bd271af34239a3948779612f87df2422c2fdcfdbcec28d9c105f0773fe"},
"icalendar": {:git, "https://github.com/tcitworld/icalendar.git", "bd08e872c125f70a87c3ac7d87ea2f22a5577059", []},
"idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"},
"jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
"jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm"},
"json_ld": {:hex, :json_ld, "0.3.0", "92f508ca831b9e4530e3e6c950976fdafcf26323e6817c325b3e1ee78affc4bd", [:mix], [{:jason, "~> 1.1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:rdf, "~> 0.5", [hex: :rdf, repo: "hexpm", optional: false]}], "hexpm"},
"jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"},
"jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm"},
"makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"},
"makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"},
"meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"},
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm"},
"mimetype_parser": {:hex, :mimetype_parser, "0.1.3", "628ac9fe56aa7edcedb534d68397dd66674ab82493c8ebe39acb9a19b666099d", [:mix], [], "hexpm"},
"mix_test_watch": {:hex, :mix_test_watch, "1.0.2", "34900184cbbbc6b6ed616ed3a8ea9b791f9fd2088419352a6d3200525637f785", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm"},
"mmdb2_decoder": {:hex, :mmdb2_decoder, "2.1.0", "91933e121e5370c89fdac37bba85a84116869a7267e1e93b4a8e1fa5c6ef89e7", [:mix], [], "hexpm"},
"mochiweb": {:hex, :mochiweb, "2.20.1", "e4dbd0ed716f076366ecf62ada5755a844e1d95c781e8c77df1d4114be868cdf", [:rebar3], [], "hexpm"},
"mock": {:hex, :mock, "0.3.4", "c5862eb3b8c64237f45f586cf00c9d892ba07bb48305a43319d428ce3c2897dd", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"},
"mogrify": {:hex, :mogrify, "0.7.3", "1494ee739f6e90de158dec4d4edee2d854d2f2d06a522e943f996ae176bca53d", [:mix], [], "hexpm"},
"nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm"},
"oban": {:hex, :oban, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"},
"phoenix": {:hex, :phoenix, "1.4.15", "5c39c330f46a33d752c6feceb25629ee8e62a158b997fea62bca59a59b28e3ea", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"phoenix_html": {:hex, :phoenix_html, "2.14.0", "d8c6bc28acc8e65f8ea0080ee05aa13d912c8758699283b8d3427b655aabe284", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.1", "274a4b07c4adbdd7785d45a8b0bb57634d0b4f45b18d2c508b26c0344bd59b8f", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"},
"plug": {:hex, :plug, "1.9.0", "8d7c4e26962283ff9f8f3347bd73838e2413fbc38b7bb5467d5924f68f3a5a4a", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"},
"plug_cowboy": {:hex, :plug_cowboy, "2.1.2", "8b0addb5908c5238fac38e442e81b6fcd32788eaa03246b4d55d147c47c5805e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm"},
"poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm"},
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"},
"postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
"progress_bar": {:hex, :progress_bar, "2.0.0", "447285f533b4b8717881fdb7160c7360c2f2ab57276f8904ce6d40482857e573", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"},
"ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"},
"rdf": {:hex, :rdf, "0.7.1", "2b707331902f31e95f0a86ca9705b385b84f84434e31f578da5aaf774293bab9", [:mix], [{:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"},
"rsa_ex": {:hex, :rsa_ex, "0.4.0", "e28dd7dc5236e156df434af0e4aa822384c8866c928e17b785d4edb7c253b558", [:mix], [], "hexpm"},
"sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm"},
"slugger": {:hex, :slugger, "0.3.0", "efc667ab99eee19a48913ccf3d038b1fb9f165fa4fbf093be898b8099e61b6ed", [:mix], [], "hexpm"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm"},
"telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm"},
"timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"},
"tzdata": {:hex, :tzdata, "1.0.3", "73470ad29dde46e350c60a66e6b360d3b99d2d18b74c4c349dbebbc27a09a3eb", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm"},
"unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm"},
"xml_builder": {:hex, :xml_builder, "2.0.0", "371ed27bb63bf0598dbaf3f0c466e5dc7d16cb4ecb68f06a67f953654062e21b", [:mix], [], "hexpm"},
"idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "4bdd305eb64e18b0273864920695cb18d7a2021f31a11b9c5fbcd9a253f936e2"},
"inet_cidr": {:hex, :inet_cidr, "1.0.4", "a05744ab7c221ca8e395c926c3919a821eb512e8f36547c062f62c4ca0cf3d6e", [:mix], [], "hexpm", "64a2d30189704ae41ca7dbdd587f5291db5d1dda1414e0774c29ffc81088c1bc"},
"ip_reserved": {:hex, :ip_reserved, "0.1.0", "5c3b6df25eb875618e489db47e00fb8dac53bc2b0dc2d546b713e6141210fe9f", [:mix], [{:inet_cidr, "~> 1.0.0", [hex: :inet_cidr, repo: "hexpm", optional: false]}], "hexpm", "88b0e96f40048f214b9e90e64eaebbf18acfec066008d7ef993b08282b2fe484"},
"jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"},
"jose": {:hex, :jose, "1.10.1", "16d8e460dae7203c6d1efa3f277e25b5af8b659febfc2f2eb4bacf87f128b80a", [:mix, :rebar3], [], "hexpm", "3c7ddc8a9394b92891db7c2771da94bf819834a1a4c92e30857b7d582e2f8257"},
"json_ld": {:hex, :json_ld, "0.3.0", "92f508ca831b9e4530e3e6c950976fdafcf26323e6817c325b3e1ee78affc4bd", [:mix], [{:jason, "~> 1.1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:rdf, "~> 0.5", [hex: :rdf, repo: "hexpm", optional: false]}], "hexpm", "fecfe1a013944df8922b6c642ec50ebcc41df35d2ab254787abd85c1f085a745"},
"jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm", "fc3499fed7a726995aa659143a248534adc754ebd16ccd437cd93b649a95091f"},
"jumper": {:hex, :jumper, "1.0.1", "3c00542ef1a83532b72269fab9f0f0c82bf23a35e27d278bfd9ed0865cecabff", [:mix], [], "hexpm", "318c59078ac220e966d27af3646026db9b5a5e6703cb2aa3e26bcfaba65b7433"},
"makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"},
"makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"},
"meck": {:hex, :meck, "0.8.13", "ffedb39f99b0b99703b8601c6f17c7f76313ee12de6b646e671e3188401f7866", [:rebar3], [], "hexpm", "d34f013c156db51ad57cc556891b9720e6a1c1df5fe2e15af999c84d6cebeb1a"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm", "6cbe761d6a0ca5a31a0931bf4c63204bceb64538e664a8ecf784a9a6f3b875f1"},
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"mimetype_parser": {:hex, :mimetype_parser, "0.1.3", "628ac9fe56aa7edcedb534d68397dd66674ab82493c8ebe39acb9a19b666099d", [:mix], [], "hexpm", "7d8f80c567807ce78cd93c938e7f4b0a20b1aaaaab914bf286f68457d9f7a852"},
"mix_test_watch": {:hex, :mix_test_watch, "1.0.2", "34900184cbbbc6b6ed616ed3a8ea9b791f9fd2088419352a6d3200525637f785", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "47ac558d8b06f684773972c6d04fcc15590abdb97aeb7666da19fcbfdc441a07"},
"mmdb2_decoder": {:hex, :mmdb2_decoder, "2.1.0", "91933e121e5370c89fdac37bba85a84116869a7267e1e93b4a8e1fa5c6ef89e7", [:mix], [], "hexpm", "de38bd0efda06750b88dc6c8324a0b33c1de5638eb48cb64150cf23d541b8202"},
"mochiweb": {:hex, :mochiweb, "2.20.1", "e4dbd0ed716f076366ecf62ada5755a844e1d95c781e8c77df1d4114be868cdf", [:rebar3], [], "hexpm", "d1aeee7870470d2fa9eae0b3d5ab6c33801aa2d82b10e9dade885c5c921b36aa"},
"mock": {:hex, :mock, "0.3.4", "c5862eb3b8c64237f45f586cf00c9d892ba07bb48305a43319d428ce3c2897dd", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "e6d886252f1a41f4ba06ecf2b4c8d38760b34b1c08a11c28f7397b2e03995964"},
"mogrify": {:hex, :mogrify, "0.7.3", "1494ee739f6e90de158dec4d4edee2d854d2f2d06a522e943f996ae176bca53d", [:mix], [], "hexpm", "b3e90a87171c23efa6b5910d735dd44f3fda15ba07a57cdb89cdb8ecaf83988d"},
"nimble_parsec": {:hex, :nimble_parsec, "0.5.3", "def21c10a9ed70ce22754fdeea0810dafd53c2db3219a0cd54cf5526377af1c6", [:mix], [], "hexpm", "589b5af56f4afca65217a1f3eb3fee7e79b09c40c742fddc1c312b3ac0b3399f"},
"oban": {:hex, :oban, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ba5f8b3f7d76967b3e23cf8014f6a13e4ccb33431e4808f036709a7f822362ee"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"},
"phoenix": {:hex, :phoenix, "1.4.15", "5c39c330f46a33d752c6feceb25629ee8e62a158b997fea62bca59a59b28e3ea", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fd649bf11a76e42366096dc25737f89e8d4adedcbba6e969ae97f349f3cde5e7"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c5e666a341ff104d0399d8f0e4ff094559b2fde13a5985d4cb5023b2c2ac558b"},
"phoenix_html": {:hex, :phoenix_html, "2.14.0", "d8c6bc28acc8e65f8ea0080ee05aa13d912c8758699283b8d3427b655aabe284", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "b0bb30eda478a06dbfbe96728061a93833db3861a49ccb516f839ecb08493fbb"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.1", "274a4b07c4adbdd7785d45a8b0bb57634d0b4f45b18d2c508b26c0344bd59b8f", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "41b4103a2fa282cfd747d377233baf213c648fdcc7928f432937676532490eee"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm", "1f13f9f0f3e769a667a6b6828d29dec37497a082d195cc52dbef401a9b69bf38"},
"plug": {:hex, :plug, "1.9.0", "8d7c4e26962283ff9f8f3347bd73838e2413fbc38b7bb5467d5924f68f3a5a4a", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "9902eda2c52ada2a096434682e99a2493f5d06a94d6ac6bcfff9805f952350f1"},
"plug_cowboy": {:hex, :plug_cowboy, "2.1.2", "8b0addb5908c5238fac38e442e81b6fcd32788eaa03246b4d55d147c47c5805e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "7d722581ce865a237e14da6d946f92704101740a256bd13ec91e63c0b122fc70"},
"plug_crypto": {:hex, :plug_crypto, "1.1.2", "bdd187572cc26dbd95b87136290425f2b580a116d3fb1f564216918c9730d227", [:mix], [], "hexpm", "6b8b608f895b6ffcfad49c37c7883e8df98ae19c6a28113b02aa1e9c5b22d6b5"},
"poison": {:hex, :poison, "4.0.1", "bcb755a16fac91cad79bfe9fc3585bb07b9331e50cfe3420a24bcc2d735709ae", [:mix], [], "hexpm", "ba8836feea4b394bb718a161fc59a288fe0109b5006d6bdf97b6badfcf6f0f25"},
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
"postgrex": {:hex, :postgrex, "0.15.3", "5806baa8a19a68c4d07c7a624ccdb9b57e89cbc573f1b98099e3741214746ae4", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "4737ce62a31747b4c63c12b20c62307e51bb4fcd730ca0c32c280991e0606c90"},
"progress_bar": {:hex, :progress_bar, "2.0.0", "447285f533b4b8717881fdb7160c7360c2f2ab57276f8904ce6d40482857e573", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "9d8b879f322fd5563e8e7ec39f1d02a9da3ffc36019f05287788744e88260fde"},
"ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"},
"rdf": {:hex, :rdf, "0.7.1", "2b707331902f31e95f0a86ca9705b385b84f84434e31f578da5aaf774293bab9", [:mix], [{:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "d42a891b1017169716bb2428df65ecc89dfe1efc6aeb9a91779522e1dbc50b1d"},
"rsa_ex": {:hex, :rsa_ex, "0.4.0", "e28dd7dc5236e156df434af0e4aa822384c8866c928e17b785d4edb7c253b558", [:mix], [], "hexpm", "40e1f08e8401da4be59a6dd0f4da30c42d5bb01703161f0208d839d97db27f4e"},
"sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"},
"slugger": {:hex, :slugger, "0.3.0", "efc667ab99eee19a48913ccf3d038b1fb9f165fa4fbf093be898b8099e61b6ed", [:mix], [], "hexpm", "20d0ded0e712605d1eae6c5b4889581c3460d92623a930ddda91e0e609b5afba"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.5", "6eaf7ad16cb568bb01753dbbd7a95ff8b91c7979482b95f38443fe2c8852a79b", [:make, :mix, :rebar3], [], "hexpm", "13104d7897e38ed7f044c4de953a6c28597d1c952075eb2e328bc6d6f2bfc496"},
"telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"},
"timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "f354efb2400dd7a80fd9eb6c8419068c4f632da4ac47f3d8822d6e33f08bc852"},
"tzdata": {:hex, :tzdata, "1.0.3", "73470ad29dde46e350c60a66e6b360d3b99d2d18b74c4c349dbebbc27a09a3eb", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a6e1ee7003c4d04ecbd21dd3ec690d4c6662db5d3bbdd7262d53cdf5e7c746c1"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], [], "hexpm", "1d1848c40487cdb0b30e8ed975e34e025860c02e419cb615d255849f3427439d"},
"unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"},
"xml_builder": {:hex, :xml_builder, "2.0.0", "371ed27bb63bf0598dbaf3f0c466e5dc7d16cb4ecb68f06a67f953654062e21b", [:mix], [], "hexpm", "baeb5c8d42204bac2b856ffd50e8cda42d63b622984538d18d92733e4e790fbd"},
}

View File

@ -4,7 +4,7 @@ defmodule Mobilizon.Repo.Migrations.FixEventVisibility do
def up do
Mobilizon.Events.EventVisibility.create_type()
Mobilizon.Events.EventStatus.create_type()
Mobilizon.Events.CommentVisibility.create_type()
Mobilizon.Conversations.CommentVisibility.create_type()
alter table(:events) do
remove(:public)
@ -34,6 +34,6 @@ defmodule Mobilizon.Repo.Migrations.FixEventVisibility do
Mobilizon.Events.EventVisibility.drop_type()
Mobilizon.Events.EventStatus.drop_type()
Mobilizon.Events.CommentVisibility.drop_type()
Mobilizon.Conversations.CommentVisibility.drop_type()
end
end

View File

@ -1,30 +0,0 @@
defmodule Mobilizon.Repo.Migrations.CreateCollections do
use Ecto.Migration
def change do
create table(:collections, primary_key: false) do
add(:id, :uuid, primary_key: true)
add(:title, :string)
add(:url, :string)
add(:summary, :string)
add(:public, :boolean, default: false, null: false)
add(:actor_id, references(:actors, on_delete: :delete_all), null: false)
timestamps()
end
create table(:resource, primary_key: false) do
add(:id, :uuid, primary_key: true)
add(:title, :string)
add(:url, :string)
add(:summary, :string)
add(:resource_url, :string)
add(:collection_id, references(:collections, on_delete: :delete_all, type: :uuid),
null: false
)
timestamps()
end
end
end

View File

@ -0,0 +1,23 @@
defmodule Mobilizon.Repo.Migrations.CreateResources do
use Ecto.Migration
def change do
create table(:resource, primary_key: false) do
add(:id, :uuid, primary_key: true)
add(:title, :string, null: false)
add(:url, :string, null: false)
add(:type, :integer, null: false)
add(:summary, :text)
add(:resource_url, :string)
add(:metadata, :map)
add(:path, :string, null: false)
add(:parent_id, references(:resource, on_delete: :delete_all, type: :uuid), null: true)
add(:actor_id, references(:actors, on_delete: :delete_all), null: false)
add(:creator_id, references(:actors, on_delete: :nilify_all), null: true)
timestamps()
end
end
end

View File

@ -1,5 +1,5 @@
# source: http://localhost:4000/api
# timestamp: Fri Apr 10 2020 15:48:40 GMT+0200 (GMT+02:00)
# timestamp: Tue May 05 2020 17:26:14 GMT+0200 (heure dété dEurope centrale)
schema {
query: RootQueryType
@ -258,18 +258,6 @@ type Application implements Actor {
url: String
}
"""A collection"""
type Collection {
"""The collection's ID"""
id: ID
"""The collection's resources"""
resources: PaginatedResourceList
"""The collection's title"""
title: String
}
"""A comment"""
type Comment implements ActionLogObject {
actor: Person
@ -323,6 +311,7 @@ type Config {
name: String
registrationsOpen: Boolean
registrationsWhitelist: Boolean
resourceProviders: [ResourceProvider]
"""The instance's terms"""
terms(locale: String = "en"): Terms
@ -725,9 +714,6 @@ type Group implements Actor {
"""The actor's banner picture"""
banner: Picture
"""A paginated list of the collections this group has"""
collections: PaginatedCollectionList
"""A list of the conversations for this group"""
conversations: PaginatedConversationList
@ -770,6 +756,9 @@ type Group implements Actor {
"""The actor's preferred username"""
preferredUsername: String
"""A paginated list of the resources this group has"""
resources(limit: Int = 10, page: Int = 1): PaginatedResourceList
"""The actor's summary"""
summary: String
@ -866,6 +855,13 @@ enum MemberRoleEnum {
REJECTED
}
"""
The `Naive DateTime` scalar type represents a naive date and time without
timezone. The DateTime appears in a JSON response as an ISO8601 formatted
string.
"""
scalar NaiveDateTime
"""
Describes how an actor is opened to follows
@ -881,14 +877,6 @@ enum Openness {
OPEN
}
type PaginatedCollectionList {
"""A list of collections"""
elements: [Collection]
"""The total number of collections in the list"""
total: Int
}
type PaginatedCommentList {
"""A list of comments"""
elements: [Comment]
@ -1233,22 +1221,104 @@ enum ReportStatus {
"""A resource"""
type Resource {
"""The resource's owner"""
actor: Actor
"""Children resources in folder"""
children: PaginatedResourceList
"""The resource's creator"""
creator: Actor
"""The resource's ID"""
id: ID
"""The resource's creation date"""
insertedAt: NaiveDateTime
"""The resource's metadata"""
metadata: ResourceMetadata
"""The resource's parent"""
parent: Resource
"""The resource's path"""
path: String
"""The resource's URL"""
resourceUrl: String
"""The resource's summary"""
summary: String
"""The resource's title"""
title: String
"""The resource's type (if it's a folder)"""
type: String
"""The resource's last update date"""
updatedAt: NaiveDateTime
"""The resource's URL"""
url: String
}
type ResourceMetadata {
authorName: String
authorUrl: String
"""The resource's metadata description"""
description: String
faviconUrl: String
height: Int
html: String
"""The resource's metadata image"""
imageRemoteUrl: String
providerName: String
providerUrl: String
"""The resource's metadata title"""
title: String
"""The type of the resource"""
type: String
width: Int
}
type ResourceProvider {
endpoint: String
software: String
type: String
}
type RootMutationType {
"""Confirm a participation"""
confirmParticipation(confirmationToken: String!): Participant
"""Create an user"""
createUser(email: String!, locale: String, password: String!): User
"""Register a first profile on registration"""
registerPerson(
"""
The avatar for the profile, either as an object or directly the ID of an existing Picture
"""
avatar: PictureInput
"""
The banner for the profile, either as an object or directly the ID of an existing Picture
"""
banner: PictureInput
"""The email from the user previously created"""
email: String!
"""The displayed name for the new profile"""
name: String = ""
preferredUsername: String!
"""The summary for the new profile"""
summary: String = ""
): Person
"""Join a group"""
joinGroup(actorId: ID!, groupId: ID!): Member
@ -1256,41 +1326,33 @@ type RootMutationType {
"""Create a Feed Token"""
createFeedToken(actorId: ID): FeedToken
"""Update a todo"""
updateTodo(assignedToId: ID, dueDate: DateTime, id: ID!, status: Boolean, title: String, todoListId: ID): Todo
deleteComment(actorId: ID!, commentId: ID!): Comment
"""Get a preview for a resource link"""
previewResourceLink(resourceUrl: String!): ResourceMetadata
"""Change an user email"""
changeEmail(email: String!, password: String!): User
"""Create a note on a report"""
createReportNote(content: String, moderatorId: ID!, reportId: ID!): ReportNote
"""Create a comment"""
createComment(actorId: ID!, eventId: ID, inReplyToCommentId: ID, text: String!): Comment
"""Delete a feed token"""
deleteFeedToken(token: String!): DeletedFeedToken
"""Upload a picture"""
uploadPicture(actorId: ID!, alt: String, file: Upload!, name: String!): Picture
"""Validate an user after registration"""
validateUser(token: String!): Login
"""Update a comment"""
updateComment(commentId: ID!, text: String!): Comment
"""Create a conversation"""
createConversation(actorId: ID!, creatorId: ID!, text: String!, title: String!): Conversation
inviteMember(actorId: ID!, groupId: ID!, targetActorUsername: String!): Member
"""Leave an event"""
leaveEvent(actorId: ID!, eventId: ID!, token: String): DeletedParticipant
deleteReportNote(moderatorId: ID!, noteId: ID!): DeletedObject
"""Create an user"""
createUser(email: String!, locale: String, password: String!): User
"""Login an user"""
login(email: String!, password: String!): Login
"""Send a link through email to reset user password"""
sendResetPassword(email: String!, locale: String): String
"""Change default actor for user"""
changeDefaultActor(preferredUsername: String!): User
"""Resend registration confirmation token"""
resendConfirmationEmail(email: String!, locale: String): String
updateConversation(conversationId: ID!, title: String!): Conversation
"""Join an event"""
joinEvent(actorId: ID!, email: String, eventId: ID!, message: String): Participant
"""Create a todo"""
createTodo(assignedToId: ID, dueDate: DateTime, status: Boolean, title: String!, todoListId: ID!): Todo
"""Change an user password"""
changePassword(newPassword: String!, oldPassword: String!): User
"""Create a new person for user"""
createPerson(
@ -1311,47 +1373,20 @@ type RootMutationType {
"""The summary for the new profile"""
summary: String = ""
): Person
replyToConversation(conversationId: ID!, text: String!): Conversation
"""Add a relay subscription"""
addRelay(address: String!): Follower
"""Join an event"""
joinEvent(actorId: ID!, email: String, eventId: ID!, message: String): Participant
"""Create a group"""
createGroup(
"""
The avatar for the group, either as an object or directly the ID of an existing Picture
"""
avatar: PictureInput
"""
The banner for the group, either as an object or directly the ID of an existing Picture
"""
banner: PictureInput
"""The identity that creates the group"""
creatorActorId: ID!
"""The displayed name for the group"""
name: String
"""The name for the group"""
preferredUsername: String!
"""The summary for the group"""
summary: String = ""
): Group
"""Resend registration confirmation token"""
resendConfirmationEmail(email: String!, locale: String): String
"""Create a todo list"""
createTodoList(groupId: ID!, title: String!): TodoList
"""Change default actor for user"""
changeDefaultActor(preferredUsername: String!): User
"""Delete an event"""
deleteEvent(actorId: ID!, eventId: ID!): DeletedObject
"""Create a conversation"""
createConversation(actorId: ID!, creatorId: ID!, text: String!, title: String!): Conversation
inviteMember(actorId: ID!, groupId: ID!, targetActorUsername: String!): Member
"""Change an user email"""
changeEmail(email: String!, password: String!): User
updateConversation(conversationId: ID!, title: String!): Conversation
"""Create an event"""
createEvent(
@ -1381,31 +1416,39 @@ type RootMutationType {
visibility: EventVisibility = PUBLIC
): Event
"""Register a first profile on registration"""
registerPerson(
"""
The avatar for the profile, either as an object or directly the ID of an existing Picture
"""
avatar: PictureInput
"""Delete a feed token"""
deleteFeedToken(token: String!): DeletedFeedToken
"""
The banner for the profile, either as an object or directly the ID of an existing Picture
"""
banner: PictureInput
"""Delete a resource"""
deleteResource(id: ID!): DeletedObject
"""The email from the user previously created"""
email: String!
"""Reset user password"""
resetPassword(locale: String = "en", password: String!, token: String!): Login
deleteReportNote(moderatorId: ID!, noteId: ID!): DeletedObject
"""The displayed name for the new profile"""
name: String = ""
preferredUsername: String!
"""Delete an identity"""
deletePerson(id: ID!): Person
"""The summary for the new profile"""
summary: String = ""
): Person
"""Upload a picture"""
uploadPicture(actorId: ID!, alt: String, file: Upload!, name: String!): Picture
"""Delete a todo"""
deleteTodo(id: ID!): DeletedObject
"""Refresh a token"""
refreshToken(refreshToken: String!): RefreshedToken
"""Delete a group"""
deleteGroup(actorId: ID!, groupId: ID!): DeletedObject
"""Update a resource"""
updateResource(actorId: ID, id: ID!, parentId: ID, path: String, resourceUrl: String, summary: String, title: String): Resource
"""Create a comment"""
createComment(actorId: ID!, eventId: ID, inReplyToCommentId: ID, text: String!): Comment
"""Create a resource"""
createResource(actorId: ID!, parentId: ID, path: String! = "/", resourceUrl: String, summary: String, title: String, type: String): Resource
"""Update a report"""
updateReportStatus(moderatorId: ID!, reportId: ID!, status: ReportStatus!): Report
"""Update an event"""
updateEvent(
@ -1435,14 +1478,37 @@ type RootMutationType {
visibility: EventVisibility = PUBLIC
): Event
"""Create a note on a report"""
createReportNote(content: String, moderatorId: ID!, reportId: ID!): ReportNote
"""Confirm a participation"""
confirmParticipation(confirmationToken: String!): Participant
deleteConversation(conversationId: ID!): Conversation
"""Create a todo"""
createTodo(assignedToId: ID, dueDate: DateTime, status: Boolean, title: String!, todoListId: ID!): Todo
"""Reject a relay subscription"""
rejectRelay(address: String!): Follower
"""Delete an account"""
deleteAccount(password: String!): DeletedObject
"""Accept a participation"""
updateParticipation(id: ID!, moderatorActorId: ID!, role: ParticipantRoleEnum!): Participant
"""Delete a todo"""
deleteTodo(id: ID!): DeletedObject
deleteComment(actorId: ID!, commentId: ID!): Comment
"""Validate an user email"""
validateEmail(token: String!): User
"""Send a link through email to reset user password"""
sendResetPassword(email: String!, locale: String): String
"""Update a comment"""
updateComment(commentId: ID!, text: String!): Comment
"""Accept a relay subscription"""
acceptRelay(address: String!): Follower
"""Create a report"""
createReport(commentsIds: [ID] = [""], content: String, eventId: ID, forward: Boolean = false, reportedId: ID!, reporterId: ID!): Report
"""Delete a relay subscription"""
removeRelay(address: String!): Follower
"""Update an identity"""
updatePerson(
@ -1464,53 +1530,46 @@ type RootMutationType {
summary: String
): Person
"""Delete a relay subscription"""
removeRelay(address: String!): Follower
"""Accept a participation"""
updateParticipation(id: ID!, moderatorActorId: ID!, role: ParticipantRoleEnum!): Participant
"""Login an user"""
login(email: String!, password: String!): Login
"""Delete a group"""
deleteGroup(actorId: ID!, groupId: ID!): DeletedObject
"""Delete an event"""
deleteEvent(actorId: ID!, eventId: ID!): DeletedObject
replyToConversation(conversationId: ID!, text: String!): Conversation
"""Reset user password"""
resetPassword(locale: String = "en", password: String!, token: String!): Login
"""Validate an user after registration"""
validateUser(token: String!): Login
"""Leave an event"""
leaveGroup(actorId: ID!, groupId: ID!): DeletedMember
"""Accept a relay subscription"""
acceptRelay(address: String!): Follower
"""Delete an account"""
deleteAccount(password: String!): DeletedObject
"""Reject a relay subscription"""
rejectRelay(address: String!): Follower
"""Create a group"""
createGroup(
"""
The avatar for the group, either as an object or directly the ID of an existing Picture
"""
avatar: PictureInput
"""
The banner for the group, either as an object or directly the ID of an existing Picture
"""
banner: PictureInput
"""The identity that creates the group"""
creatorActorId: ID!
"""The displayed name for the group"""
name: String
"""The name for the group"""
preferredUsername: String!
"""The summary for the group"""
summary: String = ""
): Group
"""Add a relay subscription"""
addRelay(address: String!): Follower
saveAdminSettings(instanceDescription: String, instanceName: String, instanceTerms: String, instanceTermsType: InstanceTermsType, instanceTermsUrl: String, registrationsOpen: Boolean): AdminSettings
"""Validate an user email"""
validateEmail(token: String!): User
"""Delete an identity"""
deletePerson(id: ID!): Person
deleteConversation(conversationId: ID!): Conversation
"""Update a report"""
updateReportStatus(moderatorId: ID!, reportId: ID!, status: ReportStatus!): Report
"""Change an user password"""
changePassword(newPassword: String!, oldPassword: String!): User
"""Refresh a token"""
refreshToken(refreshToken: String!): RefreshedToken
"""Create a report"""
createReport(commentsIds: [ID] = [""], content: String, eventId: ID, forward: Boolean = false, reportedId: ID!, reporterId: ID!): Report
"""Update a todo"""
updateTodo(assignedToId: ID, dueDate: DateTime, id: ID!, status: Boolean, title: String, todoListId: ID): Todo
}
"""
@ -1567,6 +1626,9 @@ type RootQueryType {
"""Get all reports"""
reports(limit: Int = 10, page: Int = 1, status: ReportStatus = OPEN): [Report]
"""Get a resource"""
resource(id: ID, path: String, username: String): Resource
"""Reverse geocode coordinates"""
reverseGeocode(latitude: Float!, locale: String = "en", longitude: Float!, zoom: Int = 15): [Address]
@ -1677,7 +1739,7 @@ type TodoList {
"""The todo list's title"""
title: String
"""The collection's todos"""
"""The todo-list's todos"""
todos: PaginatedTodoList
}

View File

@ -0,0 +1,584 @@
defmodule Mobilizon.GraphQL.Resolvers.ResourceTest do
use Mobilizon.Web.ConnCase
import Mobilizon.Factory
alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Resources.Resource
alias Mobilizon.Users.User
alias Mobilizon.GraphQL.AbsintheHelpers
@metadata_fragment """
fragment ResourceMetadataBasicFields on ResourceMetadata {
imageRemoteUrl,
height,
width,
type,
faviconUrl
}
"""
@get_group_resources """
query($name: String!) {
group(preferredUsername: $name) {
id,
url,
name,
domain,
summary,
preferredUsername,
resources(page: 1, limit: 3) {
elements {
id,
title,
resourceUrl,
summary,
updatedAt,
type,
path,
metadata {
...ResourceMetadataBasicFields
}
},
total
},
}
}
#{@metadata_fragment}
"""
@get_resource """
query GetResource($id: ID, $path: String, $username: String) {
resource(id: $id, path: $path, username: $username) {
id,
title,
summary,
url,
path,
metadata {
...ResourceMetadataBasicFields
authorName,
authorUrl,
providerName,
providerUrl,
html
},
parent {
id
},
actor {
id,
preferredUsername
},
children {
total,
elements {
id,
title,
summary,
url,
type,
path,
resourceUrl,
metadata {
...ResourceMetadataBasicFields
}
}
}
}
}
#{@metadata_fragment}
"""
@create_resource """
mutation CreateResource($title: String!, $parentId: ID, $summary: String, $actorId: ID!, $resourceUrl: String, $type: String) {
createResource(title: $title, parentId: $parentId, summary: $summary, actorId: $actorId, resourceUrl: $resourceUrl, type: $type) {
id,
title,
summary,
url,
resourceUrl,
updatedAt,
path,
type,
metadata {
...ResourceMetadataBasicFields
authorName,
authorUrl,
providerName,
providerUrl,
html
}
}
}
#{@metadata_fragment}
"""
@update_resource """
mutation UpdateResource($id: ID!, $title: String, $summary: String, $parentId: ID, $resourceUrl: String) {
updateResource(id: $id, title: $title, parentId: $parentId, summary: $summary, resourceUrl: $resourceUrl) {
id,
title,
summary,
url,
path,
resourceUrl,
type
}
}
"""
@resource_url "https://framasoft.org/fr/full"
@resource_title "my resource"
@updated_resource_title "my updated resource"
@folder_title "my folder"
setup do
%User{} = user = insert(:user)
%Actor{} = actor = insert(:actor, user: user)
%Actor{} = group = insert(:group)
%Member{} = insert(:member, parent: group, actor: actor, role: :member)
resource_in_root = %Resource{} = insert(:resource, actor: group)
folder_in_root =
%Resource{id: parent_id, path: parent_path} =
insert(:resource, type: :folder, resource_url: nil, actor: group)
resource_in_folder =
%Resource{} =
insert(:resource,
resource_url: nil,
actor: group,
parent_id: parent_id,
path: "#{parent_path}/titre",
title: "titre"
)
{:ok,
user: user,
group: group,
root_resources: [folder_in_root, resource_in_root],
resource_in_folder: resource_in_folder}
end
describe "Resolver: Get group's resources" do
test "find_resources_for_group/3", %{
conn: conn,
user: user,
group: group,
root_resources: root_resources,
resource_in_folder: resource_in_folder
} do
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @get_group_resources,
variables: %{
name: group.preferred_username
}
)
assert is_nil(res["errors"])
assert res["data"]["group"]["resources"]["total"] == 3
assert res["data"]["group"]["resources"]["elements"]
|> Enum.map(&{&1["path"], &1["type"]})
|> MapSet.new() ==
(root_resources ++ [resource_in_folder])
|> Enum.map(&{&1.path, Atom.to_string(&1.type)})
|> MapSet.new()
end
test "find_resources_for_group/3 when not member of group", %{
conn: conn,
group: group
} do
%User{} = user = insert(:user)
%Actor{} = actor = insert(:actor, user: user)
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @get_group_resources,
variables: %{
name: group.preferred_username
}
)
assert is_nil(res["errors"])
assert res["data"]["group"]["resources"]["total"] == 0
assert res["data"]["group"]["resources"]["elements"] == []
end
test "find_resources_for_group/3 when not connected", %{
conn: conn,
group: group
} do
res =
conn
|> AbsintheHelpers.graphql_query(
query: @get_group_resources,
variables: %{
name: group.preferred_username
}
)
assert is_nil(res["errors"])
assert res["data"]["group"]["resources"]["total"] == 0
assert res["data"]["group"]["resources"]["elements"] == []
end
end
describe "Resolver: Get a specific resource" do
test "get_resource/3 for the root path", %{
conn: conn,
user: user,
group: group,
root_resources: root_resources
} do
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @get_resource,
variables: %{
path: "/",
username: group.preferred_username
}
)
assert is_nil(res["errors"])
assert res["data"]["resource"]["path"] == "/"
assert String.starts_with?(res["data"]["resource"]["id"], "root_")
assert res["data"]["resource"]["children"]["elements"]
|> Enum.map(& &1["id"])
|> MapSet.new() == root_resources |> Enum.map(& &1.id) |> MapSet.new()
end
test "get_resource/3 for a folder path", %{
conn: conn,
user: user,
group: group,
root_resources: [root_folder, _],
resource_in_folder: resource_in_folder
} do
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @get_resource,
variables: %{
path: root_folder.path,
username: group.preferred_username
}
)
assert is_nil(res["errors"])
assert res["data"]["resource"]["path"] == root_folder.path
assert is_nil(res["data"]["resource"]["parent"]["id"])
assert res["data"]["resource"]["children"]["total"] == 1
assert res["data"]["resource"]["children"]["elements"]
|> Enum.map(& &1["id"])
|> MapSet.new() == [resource_in_folder] |> Enum.map(& &1.id) |> MapSet.new()
end
test "get_resource/3 for a non-existing path", %{
conn: conn,
user: user,
group: group
} do
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @get_resource,
variables: %{
path: "/non existing",
username: group.preferred_username
}
)
assert hd(res["errors"])["message"] == "No such resource"
end
test "get_resource/3 for a non-existing group", %{
conn: conn,
user: user
} do
%Actor{preferred_username: group_name} = group = insert(:group)
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @get_resource,
variables: %{
path: "/non existing",
username: group_name
}
)
assert hd(res["errors"])["message"] == "Actor is not member of group"
end
test "get_resource/3 when not connected", %{
conn: conn,
group: group,
resource_in_folder: resource_in_folder
} do
res =
conn
|> AbsintheHelpers.graphql_query(
query: @get_resource,
variables: %{
path: resource_in_folder.path,
username: group.preferred_username
}
)
assert hd(res["errors"])["message"] == "You need to be logged-in to access resources"
end
end
describe "Resolver: Create a resource" do
test "create_resource/3 creates a resource for a group", %{
conn: conn,
user: user,
group: group
} do
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @create_resource,
variables: %{
title: @resource_title,
parentId: nil,
actorId: group.id,
resourceUrl: @resource_url
}
)
assert is_nil(res["errors"])
assert res["data"]["createResource"]["metadata"]["faviconUrl"] ==
"https://framasoft.org/icons/favicon.png"
assert res["data"]["createResource"]["metadata"]["imageRemoteUrl"] ==
"https://framasoft.org/img/opengraph/full.jpg"
assert res["data"]["createResource"]["path"] == "/#{@resource_title}"
assert res["data"]["createResource"]["resourceUrl"] == @resource_url
assert res["data"]["createResource"]["title"] == @resource_title
assert res["data"]["createResource"]["type"] == "link"
end
test "create_resource/3 creates a folder", %{conn: conn, user: user, group: group} do
%User{} = user = insert(:user)
%Actor{} = actor = insert(:actor, user: user)
%Actor{} = group = insert(:group)
%Member{} = insert(:member, parent: group, actor: actor, role: :member)
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @create_resource,
variables: %{
title: @folder_title,
parentId: nil,
actorId: group.id,
type: "folder"
}
)
assert is_nil(res["errors"])
assert res["data"]["createResource"]["path"] == "/#{@folder_title}"
assert res["data"]["createResource"]["title"] == @folder_title
assert res["data"]["createResource"]["type"] == "folder"
end
test "create_resource/3 creates a resource in a folder", %{
conn: conn,
user: user,
group: group
} do
%Resource{id: parent_id, path: parent_path} =
insert(:resource, type: :folder, resource_url: nil, actor: group)
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @create_resource,
variables: %{
title: @resource_title,
parentId: parent_id,
actorId: group.id,
resourceUrl: @resource_url
}
)
assert is_nil(res["errors"])
assert res["data"]["createResource"]["metadata"]["faviconUrl"] ==
"https://framasoft.org/icons/favicon.png"
assert res["data"]["createResource"]["metadata"]["imageRemoteUrl"] ==
"https://framasoft.org/img/opengraph/full.jpg"
assert res["data"]["createResource"]["path"] == "#{parent_path}/#{@resource_title}"
assert res["data"]["createResource"]["resourceUrl"] == @resource_url
assert res["data"]["createResource"]["title"] == @resource_title
assert res["data"]["createResource"]["type"] == "link"
end
test "create_resource/3 doesn't create a resource in a folder if no group is defined", %{
conn: conn,
user: user,
group: group
} do
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @create_resource,
variables: %{
title: @resource_title,
parentId: nil,
resourceUrl: @resource_url
}
)
assert Enum.map(res["errors"], & &1["message"]) == [
"In argument \"actorId\": Expected type \"ID!\", found null.",
"Variable \"actorId\": Expected non-null, found null."
]
end
test "create_resource/3 doesn't create a resource if the actor is not a member of the group",
%{
conn: conn,
group: group
} do
%User{} = user = insert(:user)
%Actor{} = actor = insert(:actor, user: user)
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @create_resource,
variables: %{
title: @resource_title,
parentId: nil,
actorId: group.id,
resourceUrl: @resource_url
}
)
assert Enum.map(res["errors"], & &1["message"]) == [
"Actor id is not member of group"
]
end
test "create_resource/3 doesn't create a resource if the referenced parent folder is not owned by the group",
%{
conn: conn,
user: user,
group: group
} do
%Actor{} = group2 = insert(:group)
%Resource{id: parent_id, path: parent_path} =
insert(:resource, type: :folder, resource_url: nil, actor: group2)
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @create_resource,
variables: %{
title: @resource_title,
parentId: parent_id,
actorId: group.id,
resourceUrl: @resource_url
}
)
assert Enum.map(res["errors"], & &1["message"]) == [
"Parent resource doesn't match this group"
]
end
end
describe "Resolver: Update a resource" do
test "update_resource/3 renames a resource for a group", %{
conn: conn,
user: user,
group: group
} do
%Resource{id: resource_id} =
resource = insert(:resource, resource_url: @resource_url, actor: group)
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @update_resource,
variables: %{
id: resource_id,
title: @updated_resource_title
}
)
assert is_nil(res["errors"])
assert res["data"]["updateResource"]["path"] == "/#{@updated_resource_title}"
assert res["data"]["updateResource"]["resourceUrl"] == @resource_url
assert res["data"]["updateResource"]["title"] == @updated_resource_title
assert res["data"]["updateResource"]["type"] == "link"
end
test "update_resource/3 moves and renames a resource for a group", %{
conn: conn,
user: user,
group: group,
root_resources: [root_folder, _],
} do
%Resource{id: resource_id} =
resource = insert(:resource, resource_url: @resource_url, actor: group)
res =
conn
|> auth_conn(user)
|> AbsintheHelpers.graphql_query(
query: @update_resource,
variables: %{
id: resource_id,
title: @updated_resource_title,
parentId: root_folder.id
}
)
assert is_nil(res["errors"])
assert res["data"]["updateResource"]["path"] == "#{root_folder.path}/#{@updated_resource_title}"
assert res["data"]["updateResource"]["resourceUrl"] == @resource_url
assert res["data"]["updateResource"]["title"] == @updated_resource_title
assert res["data"]["updateResource"]["type"] == "link"
end
end
end

View File

@ -4,6 +4,7 @@ defmodule Mobilizon.GraphQL.AbsintheHelpers do
"""
use Phoenix.ConnTest
alias Plug.Conn
@endpoint Mobilizon.Web.Endpoint
@ -23,6 +24,7 @@ defmodule Mobilizon.GraphQL.AbsintheHelpers do
}
end
@spec graphql_query(Conn.t(), Keyword.t()) :: map | no_return
def graphql_query(conn, options) do
conn
|> post("/api", build_query(options[:query], Keyword.get(options, :variables, %{})))

View File

@ -266,7 +266,7 @@ defmodule Mobilizon.Factory do
title: sequence("todo list"),
actor: build(:group),
id: uuid,
url: Routes.page_url(Endpoint, :todo_list, uuid)
url: Routes.todo_list_url(Endpoint, :todo_list, uuid)
}
end
@ -280,8 +280,25 @@ defmodule Mobilizon.Factory do
status: false,
due_date: Timex.shift(DateTime.utc_now(), hours: 2),
assigned_to: build(:actor),
url: Routes.page_url(Endpoint, :todo, uuid),
url: Routes.todo_url(Endpoint, :todo, uuid),
creator: build(:actor)
}
end
def resource_factory do
uuid = Ecto.UUID.generate()
title = sequence("my resource")
%Mobilizon.Resources.Resource{
id: uuid,
title: title,
type: :link,
resource_url: "https://somewebsite.com/path",
actor: build(:group),
creator: build(:actor),
parent: nil,
url: Routes.resource_url(Endpoint, :resource, uuid),
path: "/#{title}"
}
end
end