Refactor media upload

Use Upload Media logic from Pleroma

Backend changes for picture upload

Move AS <-> Model conversion to separate module

Front changes

Downgrade apollo-client: https://github.com/Akryum/vue-apollo/issues/577

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2019-05-22 14:12:11 +02:00
parent 9724bc8e9f
commit f90089e1bf
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
113 changed files with 4718 additions and 1328 deletions

View file

@ -100,7 +100,7 @@
# #
{Credo.Check.Refactor.CondStatements, []}, {Credo.Check.Refactor.CondStatements, []},
{Credo.Check.Refactor.CyclomaticComplexity, []}, {Credo.Check.Refactor.CyclomaticComplexity, []},
{Credo.Check.Refactor.FunctionArity, []}, {Credo.Check.Refactor.FunctionArity, [max_arity: 9]},
{Credo.Check.Refactor.LongQuoteBlocks, []}, {Credo.Check.Refactor.LongQuoteBlocks, []},
{Credo.Check.Refactor.MatchInCondition, []}, {Credo.Check.Refactor.MatchInCondition, []},
{Credo.Check.Refactor.NegatedConditionsInUnless, []}, {Credo.Check.Refactor.NegatedConditionsInUnless, []},

2
.gitignore vendored
View file

@ -28,6 +28,8 @@ priv/data/*
!priv/data/.gitkeep !priv/data/.gitkeep
.vscode/ .vscode/
cover/ cover/
test/fixtures/image_tmp.jpg
test/uploads/
uploads/* uploads/*
!uploads/.gitkeep !uploads/.gitkeep
.idea .idea

View file

@ -4,5 +4,5 @@ projects:
extensions: extensions:
endpoints: endpoints:
dev: dev:
url: 'http://localhost:4001/api' url: 'http://localhost:4000/api'
introspect: true introspect: true

View file

@ -1,7 +1,7 @@
FROM bitwalker/alpine-elixir:latest FROM bitwalker/alpine-elixir:latest
RUN apk add inotify-tools postgresql-client yarn RUN apk add inotify-tools postgresql-client yarn
RUN apk add --no-cache make gcc libc-dev argon2 RUN apk add --no-cache make gcc libc-dev argon2 imagemagick
RUN mix local.hex --force && mix local.rebar --force RUN mix local.hex --force && mix local.rebar --force

View file

@ -14,7 +14,11 @@ config :mobilizon, :instance,
description: System.get_env("MOBILIZON_INSTANCE_DESCRIPTION") || "This is a Mobilizon instance", description: System.get_env("MOBILIZON_INSTANCE_DESCRIPTION") || "This is a Mobilizon instance",
version: "1.0.0-dev", version: "1.0.0-dev",
registrations_open: System.get_env("MOBILIZON_INSTANCE_REGISTRATIONS_OPEN") || false, registrations_open: System.get_env("MOBILIZON_INSTANCE_REGISTRATIONS_OPEN") || false,
repository: Mix.Project.config()[:source_url] repository: Mix.Project.config()[:source_url],
remote_limit: 100_000,
upload_limit: 16_000_000,
avatar_upload_limit: 2_000_000,
banner_upload_limit: 4_000_000
config :mime, :types, %{ config :mime, :types, %{
"application/activity+json" => ["activity-json"], "application/activity+json" => ["activity-json"],
@ -31,6 +35,34 @@ config :mobilizon, MobilizonWeb.Endpoint,
email_from: "noreply@localhost", email_from: "noreply@localhost",
email_to: "noreply@localhost" email_to: "noreply@localhost"
# Upload configuration
config :mobilizon, MobilizonWeb.Upload,
uploader: MobilizonWeb.Uploaders.Local,
filters: [MobilizonWeb.Upload.Filter.Dedupe],
link_name: true,
proxy_remote: false,
proxy_opts: [
redirect_on_failure: false,
max_body_length: 25 * 1_048_576,
http: [
follow_redirect: true,
pool: :upload
]
]
config :mobilizon, MobilizonWeb.Uploaders.Local, uploads: "uploads"
config :mobilizon, :media_proxy,
enabled: false,
proxy_opts: [
redirect_on_failure: false,
max_body_length: 25 * 1_048_576,
http: [
follow_redirect: true,
pool: :media
]
]
# Configures Elixir's Logger # Configures Elixir's Logger
config :logger, :console, config :logger, :console,
format: "$time $metadata[$level] $message\n", format: "$time $metadata[$level] $message\n",
@ -62,9 +94,6 @@ config :geolix,
} }
] ]
config :arc,
storage: Arc.Storage.Local
config :phoenix, :format_encoders, json: Jason, "activity-json": Jason config :phoenix, :format_encoders, json: Jason, "activity-json": Jason
config :mobilizon, Mobilizon.Service.Geospatial.Nominatim, config :mobilizon, Mobilizon.Service.Geospatial.Nominatim,

View file

@ -8,11 +8,10 @@ use Mix.Config
# with brunch.io to recompile .js and .css sources. # with brunch.io to recompile .js and .css sources.
config :mobilizon, MobilizonWeb.Endpoint, config :mobilizon, MobilizonWeb.Endpoint,
http: [ http: [
port: System.get_env("MOBILIZON_INSTANCE_PORT") || 4001 port: System.get_env("MOBILIZON_INSTANCE_PORT") || 4000
], ],
url: [ url: [
host: System.get_env("MOBILIZON_INSTANCE_HOST") || "mobilizon.local", host: System.get_env("MOBILIZON_INSTANCE_HOST") || "mobilizon.local"
port: System.get_env("MOBILIZON_INSTANCE_PORT") || 4001
], ],
debug_errors: true, debug_errors: true,
code_reloader: true, code_reloader: true,

View file

@ -1,28 +1,10 @@
use Mix.Config use Mix.Config
# For production, we often load configuration from external
# sources, such as your system environment. For this reason,
# you won't find the :http configuration below, but set inside
# MobilizonWeb.Endpoint.init/2 when load_from_system_env is
# true. Any dynamic configuration should be done there.
#
# Don't forget to configure the url host to something meaningful,
# Phoenix uses this information when generating URLs.
#
# Finally, we also include the path to a cache manifest
# containing the digested version of static files. This
# manifest is generated by the mix phx.digest task
# which you typically run after static files are built.
config :mobilizon, MobilizonWeb.Endpoint, config :mobilizon, MobilizonWeb.Endpoint,
load_from_system_env: true, http: [:inet6, port: System.get_env("MOBILIZON_INSTANCE_PORT") || 4000],
url: [ url: [
host: System.get_env("MOBILIZON_INSTANCE_HOST") || "mobilizon.me", host: System.get_env("MOBILIZON_INSTANCE_HOST") || "mobilizon.me",
scheme: "https", port: 80
port: 443
],
http: [
ip: {127, 0, 0, 1},
port: System.get_env("MOBILIZON_INSTANCE_PORT") || 4000
], ],
secret_key_base: secret_key_base:
System.get_env("MOBILIZON_SECRET") || "ThisShouldBeAVeryStrongStringPleaseReplaceMe", System.get_env("MOBILIZON_SECRET") || "ThisShouldBeAVeryStrongStringPleaseReplaceMe",

View file

@ -33,6 +33,10 @@ config :mobilizon, Mobilizon.Repo,
config :mobilizon, Mobilizon.Mailer, adapter: Bamboo.TestAdapter config :mobilizon, Mobilizon.Mailer, adapter: Bamboo.TestAdapter
config :mobilizon, MobilizonWeb.Upload, filters: [], link_name: false
config :mobilizon, MobilizonWeb.Uploaders.Local, uploads: "test/uploads"
config :exvcr, config :exvcr,
vcr_cassette_library_dir: "test/fixtures/vcr_cassettes" vcr_cassette_library_dir: "test/fixtures/vcr_cassettes"

View file

@ -14,7 +14,7 @@
"dependencies": { "dependencies": {
"apollo-absinthe-upload-link": "^1.5.0", "apollo-absinthe-upload-link": "^1.5.0",
"apollo-cache-inmemory": "^1.5.1", "apollo-cache-inmemory": "^1.5.1",
"apollo-client": "^2.5.1", "apollo-client": "2.5.1",
"apollo-link": "^1.2.11", "apollo-link": "^1.2.11",
"apollo-link-http": "^1.5.14", "apollo-link-http": "^1.5.14",
"apollo-link-state": "^0.4.2", "apollo-link-state": "^0.4.2",

View file

@ -91,6 +91,7 @@ export default class App extends Vue {
@import "~buefy/src/scss/components/form"; @import "~buefy/src/scss/components/form";
@import "~buefy/src/scss/components/modal"; @import "~buefy/src/scss/components/modal";
@import "~buefy/src/scss/components/tag"; @import "~buefy/src/scss/components/tag";
@import "~buefy/src/scss/components/upload";
@import "~buefy/src/scss/utils/_all"; @import "~buefy/src/scss/utils/_all";
.router-enter-active, .router-enter-active,

View file

@ -8,8 +8,8 @@
<li v-for="identity in identities" :key="identity.id"> <li v-for="identity in identities" :key="identity.id">
<div class="media identity" v-bind:class="{ 'is-current-identity': isCurrentIdentity(identity) }"> <div class="media identity" v-bind:class="{ 'is-current-identity': isCurrentIdentity(identity) }">
<div class="media-left"> <div class="media-left">
<figure class="image is-48x48"> <figure class="image is-48x48" v-if="identity.avatar">
<img class="is-rounded" :src="identity.avatarUrl"> <img class="is-rounded" :src="identity.avatar.url">
</figure> </figure>
</div> </div>

View file

@ -2,7 +2,7 @@
<router-link class="card" :to="{ name: 'Event', params: { uuid: event.uuid } }"> <router-link class="card" :to="{ name: 'Event', params: { uuid: event.uuid } }">
<div class="card-image" v-if="!event.image"> <div class="card-image" v-if="!event.image">
<figure class="image is-16by9"> <figure class="image is-16by9">
<div class="tag-container"> <div class="tag-container" v-if="event.tags">
<b-tag v-for="tag in event.tags.slice(0, 3)" :key="tag.slug" type="is-secondary">{{ tag.title }}</b-tag> <b-tag v-for="tag in event.tags.slice(0, 3)" :key="tag.slug" type="is-secondary">{{ tag.title }}</b-tag>
</div> </div>
<img src="https://picsum.photos/g/400/225/?random"> <img src="https://picsum.photos/g/400/225/?random">

View file

@ -1,6 +1,6 @@
<template> <template>
<div class="card"> <div class="card">
<div class="card-image" v-if="!group.bannerUrl"> <div class="card-image" v-if="!group.banner">
<figure class="image is-4by3"> <figure class="image is-4by3">
<img src="https://picsum.photos/g/400/200/"> <img src="https://picsum.photos/g/400/200/">
</figure> </figure>

View file

@ -40,8 +40,8 @@
v-if="currentUser.isLoggedIn && loggedPerson" v-if="currentUser.isLoggedIn && loggedPerson"
:to="{ name: 'MyAccount' }" :to="{ name: 'MyAccount' }"
> >
<figure class="image is-24x24"> <figure class="image is-24x24" v-if="loggedPerson.avatar">
<img :src="loggedPerson.avatarUrl"> <img :src="loggedPerson.avatar.url">
</figure> </figure>
<span>{{ loggedPerson.preferredUsername }}</span> <span>{{ loggedPerson.preferredUsername }}</span>
</router-link> </router-link>

View file

@ -0,0 +1,29 @@
<template>
<b-field class="file">
<b-upload v-model="pictureFile" @input="onFileChanged">
<a class="button is-primary">
<b-icon icon="upload"></b-icon>
<span>Click to upload</span>
</a>
</b-upload>
<span class="file-name" v-if="pictureFile">
{{ pictureFile.name }}
</span>
</b-field>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import { IPictureUpload } from '@/types/picture.model';
@Component
export default class PictureUpload extends Vue {
picture!: IPictureUpload;
pictureFile: File|null = null;
onFileChanged(file: File) {
this.picture = { file, name: file.name, alt: '' };
this.$emit('change', this.picture);
}
}
</script>

View file

@ -10,8 +10,12 @@ query($name:String!) {
summary, summary,
preferredUsername, preferredUsername,
suspended, suspended,
avatarUrl, avatar {
bannerUrl, url
},
banner {
url
},
feedTokens { feedTokens {
token token
}, },
@ -28,7 +32,9 @@ export const LOGGED_PERSON = gql`
query { query {
loggedPerson { loggedPerson {
id, id,
avatarUrl, avatar {
url
},
preferredUsername, preferredUsername,
} }
}`; }`;
@ -37,7 +43,9 @@ export const LOGGED_PERSON_WITH_GOING_TO_EVENTS = gql`
query { query {
loggedPerson { loggedPerson {
id, id,
avatarUrl, avatar {
url
},
preferredUsername, preferredUsername,
goingToEvents { goingToEvents {
uuid, uuid,
@ -56,7 +64,9 @@ query {
export const IDENTITIES = gql` export const IDENTITIES = gql`
query { query {
identities { identities {
avatarUrl, avatar {
url
},
preferredUsername, preferredUsername,
name name
} }
@ -72,7 +82,9 @@ mutation CreatePerson($preferredUsername: String!) {
preferredUsername, preferredUsername,
name, name,
summary, summary,
avatarUrl avatar {
url
},
} }
} }
`; `;
@ -91,7 +103,9 @@ mutation ($preferredUsername: String!, $name: String!, $summary: String!, $email
preferredUsername, preferredUsername,
name, name,
summary, summary,
avatarUrl, avatar {
url
},
} }
} }
`; `;
@ -106,8 +120,12 @@ query($name:String!) {
summary, summary,
preferredUsername, preferredUsername,
suspended, suspended,
avatarUrl, avatar {
bannerUrl, url
},
banner {
url
}
organizedEvents { organizedEvents {
uuid, uuid,
title, title,

View file

@ -4,7 +4,9 @@ const participantQuery = `
role, role,
actor { actor {
preferredUsername, preferredUsername,
avatarUrl, avatar {
url
},
name, name,
id id
} }
@ -24,8 +26,10 @@ export const FETCH_EVENT = gql`
endsOn, endsOn,
status, status,
visibility, visibility,
thumbnail, picture {
largeImage, id
url
},
publishAt, publishAt,
category, category,
# online_address, # online_address,
@ -35,19 +39,23 @@ export const FETCH_EVENT = gql`
floor, floor,
street, street,
locality, locality,
postal_code, postalCode,
region, region,
country, country,
geom geom
} }
organizerActor { organizerActor {
avatarUrl, avatar {
url
},
preferredUsername, preferredUsername,
domain, domain,
name, name,
}, },
# attributedTo { # attributedTo {
# # avatarUrl, # avatar {
# url,
# }
# preferredUsername, # preferredUsername,
# name, # name,
# }, # },
@ -66,7 +74,9 @@ export const FETCH_EVENT = gql`
description description
}, },
organizerActor { organizerActor {
avatarUrl, avatar {
url,
},
preferredUsername, preferredUsername,
domain, domain,
name, name,
@ -89,8 +99,10 @@ export const FETCH_EVENTS = gql`
endsOn, endsOn,
status, status,
visibility, visibility,
thumbnail, picture {
largeImage, id
url
},
publishAt, publishAt,
# online_address, # online_address,
# phone_address, # phone_address,
@ -99,12 +111,16 @@ export const FETCH_EVENTS = gql`
locality locality
} }
organizerActor { organizerActor {
avatarUrl, avatar {
url
},
preferredUsername, preferredUsername,
name, name,
}, },
attributedTo { attributedTo {
avatarUrl, avatar {
url
},
preferredUsername, preferredUsername,
name, name,
}, },
@ -124,20 +140,31 @@ export const CREATE_EVENT = gql`
mutation CreateEvent( mutation CreateEvent(
$title: String!, $title: String!,
$description: String!, $description: String!,
$organizerActorId: String!, $organizerActorId: ID!,
$category: String!, $category: String!,
$beginsOn: DateTime! $beginsOn: DateTime!,
$picture_file: Upload,
$picture_name: String,
) { ) {
createEvent( createEvent(
title: $title, title: $title,
description: $description, description: $description,
beginsOn: $beginsOn, beginsOn: $beginsOn,
organizerActorId: $organizerActorId, organizerActorId: $organizerActorId,
category: $category category: $category,
picture: {
picture: {
file: $picture_file,
name: $picture_name,
}
}
) { ) {
id, id,
uuid, uuid,
title title,
picture {
url
}
} }
} }
`; `;

View file

@ -4,14 +4,16 @@ export const LOGGED_PERSON = gql`
query { query {
loggedPerson { loggedPerson {
id, id,
avatarUrl, avatar {
url
},
preferredUsername, preferredUsername,
} }
}`; }`;
export const CREATE_FEED_TOKEN_ACTOR = gql` export const CREATE_FEED_TOKEN_ACTOR = gql`
mutation createFeedToken($actor_id: Int!) { mutation createFeedToken($actor_id: Int!) {
createFeedToken(actor_id: $actor_id) { createFeedToken(actorId: $actor_id) {
token, token,
actor { actor {
id id

View file

@ -23,7 +23,9 @@ query SearchGroups($searchText: String!) {
searchGroups(search: $searchText) { searchGroups(search: $searchText) {
total, total,
elements { elements {
avatarUrl, avatar {
url
},
domain, domain,
preferredUsername, preferredUsername,
name, name,

View file

@ -1,3 +1,5 @@
import { IPicture } from '@/types/picture.model';
export interface IActor { export interface IActor {
id?: string; id?: string;
url: string; url: string;
@ -6,13 +8,13 @@ export interface IActor {
summary: string; summary: string;
preferredUsername: string; preferredUsername: string;
suspended: boolean; suspended: boolean;
avatarUrl: string; avatar: IPicture | null;
bannerUrl: string; banner: IPicture | null;
} }
export class Actor implements IActor { export class Actor implements IActor {
avatarUrl: string = ''; avatar: IPicture | null = null;
bannerUrl: string = ''; banner: IPicture | null = null;
domain: string | null = null; domain: string | null = null;
name: string = ''; name: string = '';
preferredUsername: string = ''; preferredUsername: string = '';

View file

@ -1,5 +1,7 @@
import { Actor, IActor } from './actor'; import { Actor, IActor } from './actor';
import { IAddress } from '@/types/address.model'; import { IAddress } from '@/types/address.model';
import { ITag } from '@/types/tag.model';
import { IAbstractPicture, IPicture } from '@/types/picture.model';
export enum EventStatus { export enum EventStatus {
TENTATIVE, TENTATIVE,
@ -62,8 +64,7 @@ export interface IEvent {
joinOptions: EventJoinOptions; joinOptions: EventJoinOptions;
thumbnail: string; picture: IAbstractPicture|null;
largeImage: string;
organizerActor: IActor; organizerActor: IActor;
attributedTo: IActor; attributedTo: IActor;
@ -84,12 +85,10 @@ export class EventModel implements IEvent {
description: string = ''; description: string = '';
endsOn: Date = new Date(); endsOn: Date = new Date();
joinOptions: EventJoinOptions = EventJoinOptions.FREE; joinOptions: EventJoinOptions = EventJoinOptions.FREE;
largeImage: string = '';
local: boolean = true; local: boolean = true;
participants: IParticipant[] = []; participants: IParticipant[] = [];
publishAt: Date = new Date(); publishAt: Date = new Date();
status: EventStatus = EventStatus.CONFIRMED; status: EventStatus = EventStatus.CONFIRMED;
thumbnail: string = '';
title: string = ''; title: string = '';
url: string = ''; url: string = '';
uuid: string = ''; uuid: string = '';
@ -99,4 +98,5 @@ export class EventModel implements IEvent {
relatedEvents: IEvent[] = []; relatedEvents: IEvent[] = [];
onlineAddress: string = ''; onlineAddress: string = '';
phoneAddress: string = ''; phoneAddress: string = '';
picture: IAbstractPicture|null = null;
} }

View file

@ -0,0 +1,16 @@
export interface IAbstractPicture {
name;
alt;
}
export interface IPicture {
url;
name;
alt;
}
export interface IPictureUpload {
file: File;
name: String;
alt: String|null;
}

View file

@ -2,8 +2,8 @@
<section class="container"> <section class="container">
<div v-if="person"> <div v-if="person">
<div class="header"> <div class="header">
<figure v-if="person.bannerUrl" class="image is-3by1"> <figure v-if="person.banner" class="image is-3by1">
<img :src="person.bannerUrl" alt="banner"> <img :src="person.banner.url" alt="banner">
</figure> </figure>
</div> </div>

View file

@ -1,16 +1,16 @@
<template> <template>
<section class="container"> <section class="container">
<div v-if="person"> <div v-if="person">
<div class="card-image" v-if="person.bannerUrl"> <div class="card-image" v-if="person.banner">
<figure class="image"> <figure class="image">
<img :src="person.bannerUrl"> <img :src="person.banner.url">
</figure> </figure>
</div> </div>
<div class="card-content"> <div class="card-content">
<div class="media"> <div class="media">
<div class="media-left"> <div class="media-left">
<figure class="image is-48x48"> <figure class="image is-48x48" v-if="person.avatar">
<img :src="person.avatarUrl"> <img :src="person.avatar.url">
</figure> </figure>
</div> </div>
<div class="media-content"> <div class="media-content">
@ -18,7 +18,6 @@
<p class="subtitle">@{{ person.preferredUsername }}</p> <p class="subtitle">@{{ person.preferredUsername }}</p>
</div> </div>
</div> </div>
<div class="content"> <div class="content">
<vue-simple-markdown :source="person.summary"></vue-simple-markdown> <vue-simple-markdown :source="person.summary"></vue-simple-markdown>
</div> </div>
@ -58,7 +57,7 @@
</a> </a>
</b-dropdown-item> </b-dropdown-item>
</b-dropdown> </b-dropdown>
<a class="button" v-else-if="loggedPerson" @click="createToken"> <a class="button" v-if="loggedPerson.id === person.id" @click="createToken">
<translate>Create token</translate> <translate>Create token</translate>
</a> </a>
</div> </div>

View file

@ -81,7 +81,7 @@ export default class Register extends Vue {
@Prop({ type: String, required: true }) email!: string; @Prop({ type: String, required: true }) email!: string;
@Prop({ type: Boolean, required: false, default: false }) userAlreadyActivated!: boolean; @Prop({ type: Boolean, required: false, default: false }) userAlreadyActivated!: boolean;
host: string = MOBILIZON_INSTANCE_HOST; host?: string = MOBILIZON_INSTANCE_HOST;
person: IPerson = { person: IPerson = {
preferredUsername: '', preferredUsername: '',
@ -90,8 +90,8 @@ export default class Register extends Vue {
id: '', id: '',
url: '', url: '',
suspended: false, suspended: false,
avatarUrl: '', avatar: null,
bannerUrl: '', banner: null,
domain: null, domain: null,
feedTokens: [], feedTokens: [],
goingToEvents: [], goingToEvents: [],

View file

@ -1,5 +1,5 @@
<template> <template>
<section> <section class="container">
<h1 class="title"> <h1 class="title">
<translate>Create a new event</translate> <translate>Create a new event</translate>
</h1> </h1>
@ -22,6 +22,8 @@
</b-select> </b-select>
</b-field> </b-field>
<picture-upload @change="handlePictureUploadChange" />
<button class="button is-primary"> <button class="button is-primary">
<translate>Create my event</translate> <translate>Create my event</translate>
</button> </button>
@ -41,8 +43,11 @@ import {
} from '@/types/event.model'; } from '@/types/event.model';
import { LOGGED_PERSON } from '@/graphql/actor'; import { LOGGED_PERSON } from '@/graphql/actor';
import { IPerson, Person } from '@/types/actor'; import { IPerson, Person } from '@/types/actor';
import PictureUpload from '@/components/PictureUpload.vue';
import { IPictureUpload } from '@/types/picture.model';
@Component({ @Component({
components: { PictureUpload },
apollo: { apollo: {
loggedPerson: { loggedPerson: {
query: LOGGED_PERSON, query: LOGGED_PERSON,
@ -55,6 +60,8 @@ export default class CreateEvent extends Vue {
loggedPerson: IPerson = new Person(); loggedPerson: IPerson = new Person();
categories: string[] = Object.keys(Category); categories: string[] = Object.keys(Category);
event: IEvent = new EventModel(); event: IEvent = new EventModel();
pictureFile?: File;
pictureName?: String;
createEvent(e: Event) { createEvent(e: Event) {
e.preventDefault(); e.preventDefault();
@ -62,15 +69,18 @@ export default class CreateEvent extends Vue {
this.event.attributedTo = this.loggedPerson; this.event.attributedTo = this.loggedPerson;
if (this.event.uuid === '') { if (this.event.uuid === '') {
console.log('event', this.event);
this.$apollo this.$apollo
.mutate({ .mutate({
mutation: CREATE_EVENT, mutation: CREATE_EVENT,
variables: { variables: {
title: this.event.title, title: this.event.title,
description: this.event.description, description: this.event.description,
beginsOn: this.event.beginsOn, beginsOn: this.event.beginsOn.toISOString(),
category: this.event.category, category: this.event.category,
organizerActorId: this.event.organizerActor.id, organizerActorId: this.event.organizerActor.id,
picture_file: this.pictureFile,
picture_name: this.pictureName,
}, },
}) })
.then(data => { .then(data => {
@ -100,6 +110,12 @@ export default class CreateEvent extends Vue {
} }
} }
handlePictureUploadChange(picture: IPictureUpload) {
console.log('picture upload change', picture);
this.pictureFile = picture.file;
this.pictureName = picture.name;
}
// getAddressData(addressData) { // getAddressData(addressData) {
// if (addressData !== null) { // if (addressData !== null) {
// this.event.address = { // this.event.address = {

View file

@ -3,7 +3,10 @@
<b-loading :active.sync="$apollo.loading"></b-loading> <b-loading :active.sync="$apollo.loading"></b-loading>
<div v-if="event"> <div v-if="event">
<div class="header-picture container"> <div class="header-picture container">
<figure class="image is-3by1"> <figure class="image is-3by1" v-if="event.picture">
<img :src="event.picture.url">
</figure>
<figure class="image is-3by1" v-else>
<img src="https://picsum.photos/600/200/"> <img src="https://picsum.photos/600/200/">
</figure> </figure>
</div> </div>
@ -95,10 +98,10 @@
<translate <translate
:translate-params="{name: event.organizerActor.name ? event.organizerActor.name : event.organizerActor.preferredUsername}" :translate-params="{name: event.organizerActor.name ? event.organizerActor.name : event.organizerActor.preferredUsername}"
v-if="event.organizerActor">By %{ name }</translate> v-if="event.organizerActor">By %{ name }</translate>
<figure v-if="event.organizerActor.avatarUrl" class="image is-48x48"> <figure v-if="event.organizerActor.avatar" class="image is-48x48">
<img <img
class="is-rounded" class="is-rounded"
:src="event.organizerActor.avatarUrl" :src="event.organizerActor.avatar.url"
:alt="$gettextInterpolate('%{actor}\'s avatar', {actor: event.organizerActor.preferredUsername})" /> :alt="$gettextInterpolate('%{actor}\'s avatar', {actor: event.organizerActor.preferredUsername})" />
</figure> </figure>
</router-link> </router-link>
@ -185,8 +188,8 @@
<!-- >--> <!-- >-->
<!-- <div>--> <!-- <div>-->
<!-- <figure>--> <!-- <figure>-->
<!-- <img v-if="!participant.actor.avatarUrl" src="https://picsum.photos/125/125/">--> <!-- <img v-if="!participant.actor.avatar.url" src="https://picsum.photos/125/125/">-->
<!-- <img v-else :src="participant.actor.avatarUrl">--> <!-- <img v-else :src="participant.actor.avatar.url">-->
<!-- </figure>--> <!-- </figure>-->
<!-- <span>{{ participant.actor.preferredUsername }}</span>--> <!-- <span>{{ participant.actor.preferredUsername }}</span>-->
<!-- </div>--> <!-- </div>-->

View file

@ -1,16 +1,16 @@
<template> <template>
<section class="container"> <section class="container">
<div v-if="group"> <div v-if="group">
<div class="card-image" v-if="group.bannerUrl"> <div class="card-image" v-if="group.banner.url">
<figure class="image"> <figure class="image">
<img :src="group.bannerUrl"> <img :src="group.banner.url">
</figure> </figure>
</div> </div>
<div class="box"> <div class="box">
<div class="media"> <div class="media">
<div class="media-left"> <div class="media-left">
<figure class="image is-48x48"> <figure class="image is-48x48">
<img :src="group.avatarUrl"> <img :src="group.avatar.url">
</figure> </figure>
</div> </div>
<div class="media-content"> <div class="media-content">

File diff suppressed because it is too large Load diff

View file

@ -33,6 +33,7 @@ defmodule Mobilizon.Actors.Actor do
alias Mobilizon.Users.User alias Mobilizon.Users.User
alias Mobilizon.Actors.{Actor, Follower, Member} alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Events.{Event, FeedToken} alias Mobilizon.Events.{Event, FeedToken}
alias Mobilizon.Media.File
alias MobilizonWeb.Router.Helpers, as: Routes alias MobilizonWeb.Router.Helpers, as: Routes
alias MobilizonWeb.Endpoint alias MobilizonWeb.Endpoint
@ -62,8 +63,6 @@ defmodule Mobilizon.Actors.Actor do
field(:openness, Mobilizon.Actors.ActorOpennessEnum, default: :moderated) field(:openness, Mobilizon.Actors.ActorOpennessEnum, default: :moderated)
field(:visibility, Mobilizon.Actors.ActorVisibilityEnum, default: :private) field(:visibility, Mobilizon.Actors.ActorVisibilityEnum, default: :private)
field(:suspended, :boolean, default: false) field(:suspended, :boolean, default: false)
field(:avatar_url, :string)
field(:banner_url, :string)
# field(:openness, Mobilizon.Actors.ActorOpennessEnum, default: :moderated) # field(:openness, Mobilizon.Actors.ActorOpennessEnum, default: :moderated)
has_many(:followers, Follower, foreign_key: :target_actor_id) has_many(:followers, Follower, foreign_key: :target_actor_id)
has_many(:followings, Follower, foreign_key: :actor_id) has_many(:followings, Follower, foreign_key: :actor_id)
@ -71,6 +70,8 @@ defmodule Mobilizon.Actors.Actor do
many_to_many(:memberships, Actor, join_through: Member) many_to_many(:memberships, Actor, join_through: Member)
belongs_to(:user, User) belongs_to(:user, User)
has_many(:feed_tokens, FeedToken, foreign_key: :actor_id) has_many(:feed_tokens, FeedToken, foreign_key: :actor_id)
embeds_one(:avatar, File)
embeds_one(:banner, File)
timestamps() timestamps()
end end
@ -93,11 +94,11 @@ defmodule Mobilizon.Actors.Actor do
:keys, :keys,
:manually_approves_followers, :manually_approves_followers,
:suspended, :suspended,
:avatar_url,
:banner_url,
:user_id :user_id
]) ])
|> build_urls() |> build_urls()
|> cast_embed(:avatar)
|> cast_embed(:banner)
|> unique_username_validator() |> unique_username_validator()
|> validate_required([:preferred_username, :keys, :suspended, :url]) |> validate_required([:preferred_username, :keys, :suspended, :url])
|> unique_constraint(:preferred_username, name: :actors_preferred_username_domain_type_index) |> unique_constraint(:preferred_username, name: :actors_preferred_username_domain_type_index)
@ -119,10 +120,11 @@ defmodule Mobilizon.Actors.Actor do
:suspended, :suspended,
:url, :url,
:type, :type,
:avatar_url,
:user_id :user_id
]) ])
|> build_urls() |> build_urls()
|> cast_embed(:avatar)
|> cast_embed(:banner)
# Needed because following constraint can't work for domain null values (local) # Needed because following constraint can't work for domain null values (local)
|> unique_username_validator() |> unique_username_validator()
|> unique_constraint(:preferred_username, name: :actors_preferred_username_domain_type_index) |> unique_constraint(:preferred_username, name: :actors_preferred_username_domain_type_index)
@ -152,9 +154,7 @@ defmodule Mobilizon.Actors.Actor do
:summary, :summary,
:preferred_username, :preferred_username,
:keys, :keys,
:manually_approves_followers, :manually_approves_followers
:avatar_url,
:banner_url
]) ])
|> validate_required([ |> validate_required([
:url, :url,
@ -165,6 +165,8 @@ defmodule Mobilizon.Actors.Actor do
:preferred_username, :preferred_username,
:keys :keys
]) ])
|> cast_embed(:avatar)
|> cast_embed(:banner)
# Needed because following constraint can't work for domain null values (local) # Needed because following constraint can't work for domain null values (local)
|> unique_username_validator() |> unique_username_validator()
|> unique_constraint(:preferred_username, name: :actors_preferred_username_domain_type_index) |> unique_constraint(:preferred_username, name: :actors_preferred_username_domain_type_index)
@ -193,10 +195,10 @@ defmodule Mobilizon.Actors.Actor do
:name, :name,
:domain, :domain,
:summary, :summary,
:preferred_username, :preferred_username
:avatar_url,
:banner_url
]) ])
|> cast_embed(:avatar)
|> cast_embed(:banner)
|> build_urls(:Group) |> build_urls(:Group)
|> put_change(:domain, nil) |> put_change(:domain, nil)
|> put_change(:keys, Actors.create_keys()) |> put_change(:keys, Actors.create_keys())

View file

@ -11,7 +11,7 @@ defmodule Mobilizon.Actors do
alias Mobilizon.Actors.{Actor, Bot, Member, Follower} alias Mobilizon.Actors.{Actor, Bot, Member, Follower}
alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub
# import Exgravatar require Logger
@doc false @doc false
def data() do def data() do
@ -57,9 +57,12 @@ defmodule Mobilizon.Actors do
end end
# Get actor by ID and preload organized events, followers and followings # Get actor by ID and preload organized events, followers and followings
@spec get_actor_with_everything(integer()) :: Ecto.Query @spec get_actor_with_everything(integer()) :: Ecto.Query.t()
defp do_get_actor_with_everything(id) do defp do_get_actor_with_everything(id) do
from(a in Actor, where: a.id == ^id, preload: [:organized_events, :followers, :followings]) from(a in Actor,
where: a.id == ^id,
preload: [:organized_events, :followers, :followings]
)
end end
@doc """ @doc """
@ -239,24 +242,29 @@ defmodule Mobilizon.Actors do
""" """
@spec insert_or_update_actor(map(), boolean()) :: {:ok, Actor.t()} @spec insert_or_update_actor(map(), boolean()) :: {:ok, Actor.t()}
def insert_or_update_actor(data, preload \\ false) do def insert_or_update_actor(data, preload \\ false) do
cs = Actor.remote_actor_creation(data) cs =
data
|> Actor.remote_actor_creation()
{:ok, actor} = with {:ok, actor} <-
Repo.insert( Repo.insert(
cs, cs,
on_conflict: [ on_conflict: [
set: [ set: [
keys: data.keys, keys: data.keys,
avatar_url: data.avatar_url, name: data.name,
banner_url: data.banner_url, summary: data.summary
name: data.name, ]
summary: data.summary ],
] conflict_target: [:url]
], ) do
conflict_target: [:url] actor = if preload, do: Repo.preload(actor, [:followers]), else: actor
) {:ok, actor}
else
if preload, do: {:ok, Repo.preload(actor, [:followers])}, else: {:ok, actor} err ->
Logger.error(inspect(err))
{:error, err}
end
end end
# def increase_event_count(%Actor{} = actor) do # def increase_event_count(%Actor{} = actor) do
@ -291,7 +299,8 @@ defmodule Mobilizon.Actors do
{:error, :actor_not_found} {:error, :actor_not_found}
actor -> actor ->
if preload, do: {:ok, Repo.preload(actor, [:followers])}, else: {:ok, actor} actor = if preload, do: Repo.preload(actor, [:followers]), else: actor
{:ok, actor}
end end
end end
@ -371,7 +380,11 @@ defmodule Mobilizon.Actors do
""" """
@spec get_local_actor_by_name(String.t()) :: Actor.t() | nil @spec get_local_actor_by_name(String.t()) :: Actor.t() | nil
def get_local_actor_by_name(name) do def get_local_actor_by_name(name) do
Repo.one(from(a in Actor, where: a.preferred_username == ^name and is_nil(a.domain))) Repo.one(
from(a in Actor,
where: a.preferred_username == ^name and is_nil(a.domain)
)
)
end end
@doc """ @doc """
@ -435,6 +448,7 @@ defmodule Mobilizon.Actors do
{:ok, actor} {:ok, actor}
_ -> _ ->
Logger.error("Could not fetch by AP id")
{:error, "Could not fetch by AP id"} {:error, "Could not fetch by AP id"}
end end
end end

View file

@ -6,6 +6,9 @@ defmodule Mobilizon.Application do
import Cachex.Spec import Cachex.Spec
alias Mobilizon.Service.Export.{Feed, ICalendar} alias Mobilizon.Service.Export.{Feed, ICalendar}
@name Mix.Project.config()[:name]
@version Mix.Project.config()[:version]
# See https://hexdocs.pm/elixir/Application.html # See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications # for more information on OTP Applications
def start(_type, _args) do def start(_type, _args) do
@ -82,4 +85,13 @@ defmodule Mobilizon.Application do
MobilizonWeb.Endpoint.config_change(changed, removed) MobilizonWeb.Endpoint.config_change(changed, removed)
:ok :ok
end end
def named_version, do: @name <> " " <> @version
def user_agent do
info =
"#{MobilizonWeb.Endpoint.url()} <#{Mobilizon.CommonConfig.get([:instance, :email], "")}>"
named_version() <> "; " <> info
end
end end

View file

@ -22,4 +22,45 @@ defmodule Mobilizon.CommonConfig do
defp instance_config(), do: Application.get_env(:mobilizon, :instance) defp instance_config(), do: Application.get_env(:mobilizon, :instance)
defp to_bool(v), do: v == true or v == "true" or v == "True" defp to_bool(v), do: v == true or v == "true" or v == "True"
def get(key), do: get(key, nil)
def get([key], default), do: get(key, default)
def get([parent_key | keys], default) do
case :mobilizon
|> Application.get_env(parent_key)
|> get_in(keys) do
nil -> default
any -> any
end
end
def get(key, default) do
Application.get_env(:mobilizon, key, default)
end
def get!(key) do
value = get(key, nil)
if value == nil do
raise("Missing configuration value: #{inspect(key)}")
else
value
end
end
def put([key], value), do: put(key, value)
def put([parent_key | keys], value) do
parent =
Application.get_env(:mobilizon, parent_key)
|> put_in(keys, value)
Application.put_env(:mobilizon, parent_key, parent)
end
def put(key, value) do
Application.put_env(:mobilizon, key, value)
end
end end

View file

@ -35,6 +35,7 @@ defmodule Mobilizon.Events.Event do
import Ecto.Changeset import Ecto.Changeset
alias Mobilizon.Events.{Event, Participant, Tag, Session, Track} alias Mobilizon.Events.{Event, Participant, Tag, Session, Track}
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Media.Picture
alias Mobilizon.Addresses.Address alias Mobilizon.Addresses.Address
schema "events" do schema "events" do
@ -48,8 +49,6 @@ defmodule Mobilizon.Events.Event do
field(:status, Mobilizon.Events.EventStatusEnum, default: :confirmed) field(:status, Mobilizon.Events.EventStatusEnum, default: :confirmed)
field(:visibility, Mobilizon.Events.EventVisibilityEnum, default: :public) field(:visibility, Mobilizon.Events.EventVisibilityEnum, default: :public)
field(:join_options, Mobilizon.Events.JoinOptionsEnum, default: :free) field(:join_options, Mobilizon.Events.JoinOptionsEnum, default: :free)
field(:thumbnail, :string)
field(:large_image, :string)
field(:publish_at, :utc_datetime) field(:publish_at, :utc_datetime)
field(:uuid, Ecto.UUID, default: Ecto.UUID.generate()) field(:uuid, Ecto.UUID, default: Ecto.UUID.generate())
field(:online_address, :string) field(:online_address, :string)
@ -62,6 +61,7 @@ defmodule Mobilizon.Events.Event do
has_many(:tracks, Track) has_many(:tracks, Track)
has_many(:sessions, Session) has_many(:sessions, Session)
belongs_to(:physical_address, Address) belongs_to(:physical_address, Address)
belongs_to(:picture, Picture)
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@ -80,12 +80,11 @@ defmodule Mobilizon.Events.Event do
:category, :category,
:status, :status,
:visibility, :visibility,
:thumbnail,
:large_image,
:publish_at, :publish_at,
:online_address, :online_address,
:phone_address, :phone_address,
:uuid :uuid,
:picture_id
]) ])
|> cast_assoc(:tags) |> cast_assoc(:tags)
|> cast_assoc(:physical_address) |> cast_assoc(:physical_address)

View file

@ -178,7 +178,8 @@ defmodule Mobilizon.Events do
:tracks, :tracks,
:tags, :tags,
:participants, :participants,
:physical_address :physical_address,
:picture
] ]
) )
|> Repo.one() |> Repo.one()
@ -692,7 +693,7 @@ defmodule Mobilizon.Events do
on: p.actor_id == a.id, on: p.actor_id == a.id,
on: p.event_id == e.id, on: p.event_id == e.id,
where: a.id == ^id and p.role != ^:not_approved, where: a.id == ^id and p.role != ^:not_approved,
preload: [:tags] preload: [:picture, :tags]
) )
|> paginate(page, limit) |> paginate(page, limit)
) )
@ -1239,11 +1240,7 @@ defmodule Mobilizon.Events do
""" """
def get_feed_token(token) do def get_feed_token(token) do
from( from(ftk in FeedToken, where: ftk.token == ^token, preload: [:actor, :user])
tk in FeedToken,
where: tk.token == ^token,
preload: [:actor, :user]
)
|> Repo.one() |> Repo.one()
end end

115
lib/mobilizon/media.ex Normal file
View file

@ -0,0 +1,115 @@
defmodule Mobilizon.Media do
@moduledoc """
The Media context.
"""
import Ecto.Query, warn: false
alias Mobilizon.Repo
alias Mobilizon.Media.Picture
@doc false
def data() do
Dataloader.Ecto.new(Mobilizon.Repo, query: &query/2)
end
@doc false
def query(queryable, _params) do
queryable
end
@doc """
Gets a single picture.
Raises `Ecto.NoResultsError` if the Picture does not exist.
## Examples
iex> get_picture!(123)
%Picture{}
iex> get_picture!(456)
** (Ecto.NoResultsError)
"""
def get_picture!(id), do: Repo.get!(Picture, id)
def get_picture(id), do: Repo.get(Picture, id)
@doc """
Get a picture by it's URL
"""
@spec get_picture_by_url(String.t()) :: Picture.t() | nil
def get_picture_by_url(url) do
from(
p in Picture,
where: fragment("? @> ?", p.file, ~s|{"url": "#{url}"}|)
)
|> Repo.one()
end
@doc """
Creates a picture.
## Examples
iex> create_picture(%{field: value})
{:ok, %Picture{}}
iex> create_picture(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_picture(attrs \\ %{}) do
%Picture{}
|> Picture.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a picture.
## Examples
iex> update_picture(picture, %{field: new_value})
{:ok, %Picture{}}
iex> update_picture(picture, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_picture(%Picture{} = picture, attrs) do
picture
|> Picture.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a Picture.
## Examples
iex> delete_picture(picture)
{:ok, %Picture{}}
iex> delete_picture(picture)
{:error, %Ecto.Changeset{}}
"""
def delete_picture(%Picture{} = picture) do
Repo.delete(picture)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking picture changes.
## Examples
iex> change_picture(picture)
%Ecto.Changeset{source: %Picture{}}
"""
def change_picture(%Picture{} = picture) do
Picture.changeset(picture, %{})
end
end

View file

@ -0,0 +1,22 @@
defmodule Mobilizon.Media.File do
@moduledoc """
Represents a file entity
"""
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field(:name, :string)
field(:url, :string)
field(:content_type, :string)
timestamps()
end
@doc false
def changeset(picture, attrs) do
picture
|> cast(attrs, [:name, :url, :content_type])
|> validate_required([:name, :url])
end
end

View file

@ -0,0 +1,21 @@
defmodule Mobilizon.Media.Picture do
@moduledoc """
Represents a picture entity
"""
use Ecto.Schema
import Ecto.Changeset
alias Mobilizon.Media.File
schema "pictures" do
embeds_one(:file, File, on_replace: :update)
timestamps()
end
@doc false
def changeset(picture, attrs) do
picture
|> cast(attrs, [])
|> cast_embed(:file)
end
end

121
lib/mobilizon/mime.ex Normal file
View file

@ -0,0 +1,121 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/mime.ex
defmodule Mobilizon.MIME do
@moduledoc """
Returns the mime-type of a binary and optionally a normalized file-name.
"""
@default "application/octet-stream"
@read_bytes 35
@spec file_mime_type(String.t()) ::
{:ok, content_type :: String.t(), filename :: String.t()} | {:error, any()} | :error
def file_mime_type(path, filename) do
with {:ok, content_type} <- file_mime_type(path),
filename <- fix_extension(filename, content_type) do
{:ok, content_type, filename}
end
end
@spec file_mime_type(String.t()) :: {:ok, String.t()} | {:error, any()} | :error
def file_mime_type(filename) do
File.open(filename, [:read], fn f ->
check_mime_type(IO.binread(f, @read_bytes))
end)
end
def bin_mime_type(binary, filename) do
with {:ok, content_type} <- bin_mime_type(binary),
filename <- fix_extension(filename, content_type) do
{:ok, content_type, filename}
end
end
@spec bin_mime_type(binary()) :: {:ok, String.t()} | :error
def bin_mime_type(<<head::binary-size(@read_bytes), _::binary>>) do
{:ok, check_mime_type(head)}
end
def bin_mime_type(_), do: :error
def mime_type(<<_::binary>>), do: {:ok, @default}
defp fix_extension(filename, content_type) do
parts = String.split(filename, ".")
new_filename =
if length(parts) > 1 do
Enum.drop(parts, -1) |> Enum.join(".")
else
Enum.join(parts)
end
cond do
content_type == "application/octet-stream" ->
filename
ext = List.first(MIME.extensions(content_type)) ->
new_filename <> "." <> ext
true ->
Enum.join([new_filename, String.split(content_type, "/") |> List.last()], ".")
end
end
defp check_mime_type(<<0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, _::binary>>) do
"image/png"
end
defp check_mime_type(<<0x47, 0x49, 0x46, 0x38, _, 0x61, _::binary>>) do
"image/gif"
end
defp check_mime_type(<<0xFF, 0xD8, 0xFF, _::binary>>) do
"image/jpeg"
end
defp check_mime_type(<<0x1A, 0x45, 0xDF, 0xA3, _::binary>>) do
"video/webm"
end
defp check_mime_type(<<0x00, 0x00, 0x00, _, 0x66, 0x74, 0x79, 0x70, _::binary>>) do
"video/mp4"
end
defp check_mime_type(<<0x49, 0x44, 0x33, _::binary>>) do
"audio/mpeg"
end
defp check_mime_type(<<255, 251, _, 68, 0, 0, 0, 0, _::binary>>) do
"audio/mpeg"
end
defp check_mime_type(
<<0x4F, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, _::size(160), 0x80, 0x74, 0x68, 0x65,
0x6F, 0x72, 0x61, _::binary>>
) do
"video/ogg"
end
defp check_mime_type(<<0x4F, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, _::binary>>) do
"audio/ogg"
end
defp check_mime_type(<<"RIFF", _::binary-size(4), "WAVE", _::binary>>) do
"audio/wav"
end
defp check_mime_type(<<"RIFF", _::binary-size(4), "WEBP", _::binary>>) do
"image/webp"
end
defp check_mime_type(<<"RIFF", _::binary-size(4), "AVI.", _::binary>>) do
"video/avi"
end
defp check_mime_type(_) do
@default
end
end

View file

@ -26,8 +26,9 @@ defmodule MobilizonWeb.API.Events do
title <- String.trim(title), title <- String.trim(title),
mentions <- Formatter.parse_mentions(description), mentions <- Formatter.parse_mentions(description),
visibility <- Map.get(args, :visibility, "public"), visibility <- Map.get(args, :visibility, "public"),
{to, cc} <- to_for_actor_and_mentions(actor, mentions, nil, visibility), {to, cc} <- to_for_actor_and_mentions(actor, mentions, nil, Atom.to_string(visibility)),
tags <- Formatter.parse_tags(description), tags <- Formatter.parse_tags(description),
picture <- Map.get(args, :picture, nil),
content_html <- content_html <-
make_content_html( make_content_html(
description, description,
@ -41,6 +42,7 @@ defmodule MobilizonWeb.API.Events do
to, to,
title, title,
content_html, content_html,
picture,
tags, tags,
cc, cc,
%{begins_on: begins_on}, %{begins_on: begins_on},

View file

@ -5,11 +5,9 @@
defmodule MobilizonWeb.ActivityPubController do defmodule MobilizonWeb.ActivityPubController do
use MobilizonWeb, :controller use MobilizonWeb, :controller
alias Mobilizon.{Actors, Actors.Actor, Events} alias Mobilizon.{Actors, Actors.Actor}
alias Mobilizon.Events.{Event, Comment} alias MobilizonWeb.ActivityPub.ActorView
alias MobilizonWeb.ActivityPub.{ObjectView, ActorView}
alias Mobilizon.Service.ActivityPub alias Mobilizon.Service.ActivityPub
alias Mobilizon.Service.ActivityPub.Utils
alias Mobilizon.Service.Federator alias Mobilizon.Service.Federator
require Logger require Logger

View file

@ -0,0 +1,45 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/web/media_proxy/controller.ex
defmodule MobilizonWeb.MediaProxyController do
use MobilizonWeb, :controller
alias MobilizonWeb.ReverseProxy
alias MobilizonWeb.MediaProxy
@default_proxy_opts [max_body_length: 25 * 1_048_576, http: [follow_redirect: true]]
def remote(conn, %{"sig" => sig64, "url" => url64} = params) do
with config <- Mobilizon.CommonConfig.get([:media_proxy], []),
true <- Keyword.get(config, :enabled, false),
{:ok, url} <- MediaProxy.decode_url(sig64, url64),
:ok <- filename_matches(Map.has_key?(params, "filename"), conn.request_path, url) do
ReverseProxy.call(conn, url, Keyword.get(config, :proxy_opts, @default_proxy_opts))
else
false ->
send_resp(conn, 404, Plug.Conn.Status.reason_phrase(404))
{:error, :invalid_signature} ->
send_resp(conn, 403, Plug.Conn.Status.reason_phrase(403))
{:wrong_filename, filename} ->
redirect(conn, external: MediaProxy.build_url(sig64, url64, filename))
end
end
def filename_matches(has_filename, path, url) do
filename =
url
|> MediaProxy.filename()
|> URI.decode()
path = URI.decode(path)
if has_filename && filename && Path.basename(path) != filename do
{:wrong_filename, filename}
else
:ok
end
end
end

View file

@ -4,9 +4,7 @@ defmodule MobilizonWeb.PageController do
""" """
use MobilizonWeb, :controller use MobilizonWeb, :controller
alias Mobilizon.Actors alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Events alias Mobilizon.Events
alias Mobilizon.Events.{Event, Comment}
action_fallback(MobilizonWeb.FallbackController) action_fallback(MobilizonWeb.FallbackController)

View file

@ -9,12 +9,7 @@ defmodule MobilizonWeb.Endpoint do
# You should set gzip to true if you are running phoenix.digest # You should set gzip to true if you are running phoenix.digest
# when deploying your static files in production. # when deploying your static files in production.
plug( plug(MobilizonWeb.Plugs.UploadedMedia)
Plug.Static,
at: "/uploads",
from: "./uploads",
gzip: false
)
plug( plug(
Plug.Static, Plug.Static,
@ -38,7 +33,7 @@ defmodule MobilizonWeb.Endpoint do
plug( plug(
Plug.Parsers, Plug.Parsers,
parsers: [:urlencoded, :multipart, :json], parsers: [:urlencoded, :multipart, :json, Absinthe.Plug.Parser],
pass: ["*/*"], pass: ["*/*"],
json_decoder: Jason json_decoder: Jason
) )
@ -57,22 +52,4 @@ defmodule MobilizonWeb.Endpoint do
) )
plug(MobilizonWeb.Router) plug(MobilizonWeb.Router)
@doc """
Callback invoked for dynamically configuring the endpoint.
It receives the endpoint configuration and checks if
configuration should be loaded from the system environment.
"""
def init(_key, config) do
if config[:load_from_system_env] do
port =
System.get_env("MOBILIZON_INSTANCE_PORT") ||
raise "expected the MOBILIZON_INSTANCE_PORT environment variable to be set"
{:ok, Keyword.put(config, :http, [:inet6, port: port])}
else
{:ok, config}
end
end
end end

View file

@ -0,0 +1,86 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/web/media_proxy/media_proxy.ex
defmodule MobilizonWeb.MediaProxy do
@moduledoc """
Handles proxifying media files
"""
@base64_opts [padding: false]
def url(nil), do: nil
def url(""), do: nil
def url("/" <> _ = url), do: url
def url(url) do
config = Application.get_env(:mobilizon, :media_proxy, [])
if !Keyword.get(config, :enabled, false) or
String.starts_with?(url, MobilizonWeb.Endpoint.url()) do
url
else
encode_url(url)
end
end
def encode_url(url) do
secret = Application.get_env(:mobilizon, MobilizonWeb.Endpoint)[:secret_key_base]
# Must preserve `%2F` for compatibility with S3
# https://git.pleroma.social/pleroma/pleroma/issues/580
replacement = get_replacement(url, ":2F:")
# The URL is url-decoded and encoded again to ensure it is correctly encoded and not twice.
base64 =
url
|> String.replace("%2F", replacement)
|> URI.decode()
|> URI.encode()
|> String.replace(replacement, "%2F")
|> Base.url_encode64(@base64_opts)
sig = :crypto.hmac(:sha, secret, base64)
sig64 = sig |> Base.url_encode64(@base64_opts)
build_url(sig64, base64, filename(url))
end
def decode_url(sig, url) do
secret = Application.get_env(:mobilizon, MobilizonWeb.Endpoint)[:secret_key_base]
sig = Base.url_decode64!(sig, @base64_opts)
local_sig = :crypto.hmac(:sha, secret, url)
if local_sig == sig do
{:ok, Base.url_decode64!(url, @base64_opts)}
else
{:error, :invalid_signature}
end
end
def filename(url_or_path) do
if path = URI.parse(url_or_path).path, do: Path.basename(path)
end
def build_url(sig_base64, url_base64, filename \\ nil) do
[
Mobilizon.CommonConfig.get([:media_proxy, :base_url], MobilizonWeb.Endpoint.url()),
"proxy",
sig_base64,
url_base64,
filename
]
|> Enum.filter(fn value -> value end)
|> Path.join()
end
defp get_replacement(url, replacement) do
if String.contains?(url, replacement) do
get_replacement(url, replacement <> replacement)
else
replacement
end
end
end

View file

@ -0,0 +1,95 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/plugs/uploaded_media.ex
defmodule MobilizonWeb.Plugs.UploadedMedia do
@moduledoc """
Serves uploaded media files
"""
import Plug.Conn
require Logger
@behaviour Plug
# no slashes
@path "media"
def init(_opts) do
static_plug_opts =
[]
|> Keyword.put(:from, "__unconfigured_media_plug")
|> Keyword.put(:at, "/__unconfigured_media_plug")
|> Plug.Static.init()
%{static_plug_opts: static_plug_opts}
end
def call(%{request_path: <<"/", @path, "/", file::binary>>} = conn, opts) do
conn =
case fetch_query_params(conn) do
%{query_params: %{"name" => name}} = conn ->
name = String.replace(name, "\"", "\\\"")
conn
|> put_resp_header("content-disposition", "filename=\"#{name}\"")
conn ->
conn
end
config = Mobilizon.CommonConfig.get([MobilizonWeb.Upload])
with uploader <- Keyword.fetch!(config, :uploader),
proxy_remote = Keyword.get(config, :proxy_remote, false),
{:ok, get_method} <- uploader.get_file(file) do
get_media(conn, get_method, proxy_remote, opts)
else
_ ->
conn
|> send_resp(500, "Failed")
|> halt()
end
end
def call(conn, _opts), do: conn
defp get_media(conn, {:static_dir, directory}, _, opts) do
static_opts =
Map.get(opts, :static_plug_opts)
|> Map.put(:at, [@path])
|> Map.put(:from, directory)
conn = Plug.Static.call(conn, static_opts)
if conn.halted do
conn
else
conn
|> send_resp(404, "Not found")
|> halt()
end
end
defp get_media(conn, {:url, url}, true, _) do
conn
|> MobilizonWeb.ReverseProxy.call(
url,
Mobilizon.CommonConfig.get([Mobilizon.Upload, :proxy_opts], [])
)
end
defp get_media(conn, {:url, url}, _, _) do
conn
|> Phoenix.Controller.redirect(external: url)
|> halt()
end
defp get_media(conn, unknown, _, _) do
Logger.error("#{__MODULE__}: Unknown get startegy: #{inspect(unknown)}")
conn
|> send_resp(500, "Internal Error")
|> halt()
end
end

View file

@ -5,6 +5,7 @@ defmodule MobilizonWeb.Resolvers.Event do
alias Mobilizon.Activity alias Mobilizon.Activity
alias Mobilizon.Events alias Mobilizon.Events
alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Media.Picture
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Users.User alias Mobilizon.Users.User
@ -185,16 +186,27 @@ defmodule MobilizonWeb.Resolvers.Event do
@doc """ @doc """
Create an event Create an event
""" """
def create_event(_parent, args, %{context: %{current_user: _user}}) do def create_event(_parent, args, %{context: %{current_user: _user}} = _resolution) do
with {:ok, %Activity{data: %{"object" => %{"type" => "Event"} = object}}} <- with {:ok, args} <- save_attached_picture(args),
{:ok, %Activity{data: %{"object" => %{"type" => "Event"} = object}}} <-
MobilizonWeb.API.Events.create_event(args) do MobilizonWeb.API.Events.create_event(args) do
{:ok, res = %{
%Event{ title: object["name"],
title: object["name"], description: object["content"],
description: object["content"], uuid: object["uuid"],
uuid: object["uuid"], url: object["id"]
url: object["id"] }
}}
res =
if Map.has_key?(object, "attachment"),
do:
Map.put(res, :picture, %{
name: object["attachment"] |> hd() |> Map.get("name"),
url: object["attachment"] |> hd() |> Map.get("url") |> hd() |> Map.get("href")
}),
else: res
{:ok, res}
end end
end end
@ -202,6 +214,22 @@ defmodule MobilizonWeb.Resolvers.Event do
{:error, "You need to be logged-in to create events"} {:error, "You need to be logged-in to create events"}
end end
# If we have an attached picture, just transmit it. It will be handled by
# Mobilizon.Service.ActivityPub.Utils.make_picture_data/1
@spec save_attached_picture(map()) :: {:ok, map()}
defp save_attached_picture(%{picture: %{picture: %Plug.Upload{} = _picture}} = args), do: args
# Otherwise if we use a previously uploaded picture we need to fetch it from database
@spec save_attached_picture(map()) :: {:ok, map()}
defp save_attached_picture(%{picture: %{picture_id: picture_id}} = args) do
with %Picture{} = picture <- Mobilizon.Media.get_picture(picture_id) do
{:ok, Map.put(args, :picture, picture)}
end
end
@spec save_attached_picture(map()) :: {:ok, map()}
defp save_attached_picture(args), do: {:ok, args}
@doc """ @doc """
Delete an event Delete an event
""" """

View file

@ -47,12 +47,17 @@ defmodule MobilizonWeb.Resolvers.Person do
@doc """ @doc """
This function is used to create more identities from an existing user This function is used to create more identities from an existing user
""" """
def create_person(_parent, %{preferred_username: _preferred_username} = args, %{ def create_person(
context: %{current_user: user} _parent,
}) do %{preferred_username: _preferred_username} = args,
%{
context: %{current_user: user}
} = _resolution
) do
args = Map.put(args, :user_id, user.id) args = Map.put(args, :user_id, user.id)
with {:ok, %Actor{} = new_person} <- Actors.new_person(args) do with args <- save_attached_pictures(args),
{:ok, %Actor{} = new_person} <- Actors.new_person(args) do
{:ok, new_person} {:ok, new_person}
end end
end end
@ -64,6 +69,21 @@ defmodule MobilizonWeb.Resolvers.Person do
{:error, "You need to be logged-in to create a new identity"} {:error, "You need to be logged-in to create a new identity"}
end end
defp save_attached_pictures(args) do
Enum.reduce([:avatar, :banner], args, fn key, args ->
if Map.has_key?(args, key) do
pic = args[key][:picture]
with {:ok, %{"name" => name, "url" => [%{"href" => url, "mediaType" => content_type}]}} <-
MobilizonWeb.Upload.store(pic.file, type: key, description: pic.alt) do
Map.put(args, key, %{"name" => name, "url" => url, "mediaType" => content_type})
end
else
args
end
end)
end
@doc """ @doc """
This function is used to register a person afterwards the user has been created (but not activated) This function is used to register a person afterwards the user has been created (but not activated)
""" """
@ -71,6 +91,7 @@ defmodule MobilizonWeb.Resolvers.Person do
with {:ok, %User{} = user} <- Users.get_user_by_email(args.email), with {:ok, %User{} = user} <- Users.get_user_by_email(args.email),
{:no_actor, nil} <- {:no_actor, Users.get_actor_for_user(user)}, {:no_actor, nil} <- {:no_actor, Users.get_actor_for_user(user)},
args <- Map.put(args, :user_id, user.id), args <- Map.put(args, :user_id, user.id),
args <- save_attached_pictures(args),
{:ok, %Actor{} = new_person} <- Actors.new_person(args) do {:ok, %Actor{} = new_person} <- Actors.new_person(args) do
{:ok, new_person} {:ok, new_person}
else else

View file

@ -0,0 +1,64 @@
defmodule MobilizonWeb.Resolvers.Picture do
@moduledoc """
Handles the picture-related GraphQL calls
"""
alias Mobilizon.Media
alias Mobilizon.Media.Picture
@doc """
Get picture for an event's pic
"""
def picture(%{picture_id: picture_id} = _parent, _args, _resolution) do
with {:ok, picture} <- do_fetch_picture(picture_id) do
{:ok, picture}
end
end
@doc """
Get picture for an event that has an attached
See MobilizonWeb.Resolvers.Event.create_event/3
"""
def picture(%{picture: picture} = _parent, _args, _resolution) do
{:ok, picture}
end
def picture(_parent, %{id: picture_id}, _resolution), do: do_fetch_picture(picture_id)
def picture(_parent, _args, _resolution) do
{:ok, nil}
end
@spec do_fetch_picture(nil) :: {:error, nil}
defp do_fetch_picture(nil), do: {:error, nil}
@spec do_fetch_picture(String.t()) :: {:ok, Picture.t()} | {:error, :not_found}
defp do_fetch_picture(picture_id) do
with %Picture{id: id, file: file} = _pic <- Media.get_picture(picture_id) do
{:ok, %{name: file.name, url: file.url, id: id}}
else
_err ->
{:error, "Picture with ID #{picture_id} was not found"}
end
end
@spec upload_picture(map(), map(), map()) :: {:ok, Picture.t()} | {:error, any()}
def upload_picture(_parent, %{file: %Plug.Upload{} = file} = args, %{
context: %{
current_user: _user
}
}) do
with {:ok, %{"url" => [%{"href" => url}]}} <- MobilizonWeb.Upload.store(file),
args <- Map.put(args, :url, url),
{:ok, picture = %Picture{}} <- Media.create_picture(%{"file" => args}) do
{:ok, %{name: picture.file.name, url: picture.file.url, id: picture.id}}
else
err ->
{:error, err}
end
end
def upload_picture(_parent, _args, _resolution) do
{:error, "You need to login to upload a picture"}
end
end

View file

@ -0,0 +1,382 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/reverse_proxy.ex
defmodule MobilizonWeb.ReverseProxy do
@keep_req_headers ~w(accept user-agent accept-encoding cache-control if-modified-since) ++
~w(if-unmodified-since if-none-match if-range range)
@resp_cache_headers ~w(etag date last-modified cache-control)
@keep_resp_headers @resp_cache_headers ++
~w(content-type content-disposition content-encoding content-range) ++
~w(accept-ranges vary)
@default_cache_control_header "public, max-age=1209600"
@valid_resp_codes [200, 206, 304]
@max_read_duration :timer.seconds(30)
@max_body_length :infinity
@methods ~w(GET HEAD)
@moduledoc """
A reverse proxy.
MobilizonWeb.ReverseProxy.call(conn, url, options)
It is not meant to be added into a plug pipeline, but to be called from another plug or controller.
Supports `#{inspect(@methods)}` HTTP methods, and only allows `#{inspect(@valid_resp_codes)}` status codes.
Responses are chunked to the client while downloading from the upstream.
Some request / responses headers are preserved:
* request: `#{inspect(@keep_req_headers)}`
* response: `#{inspect(@keep_resp_headers)}`
If no caching headers (`#{inspect(@resp_cache_headers)}`) are returned by upstream, `cache-control` will be
set to `#{inspect(@default_cache_control_header)}`.
Options:
* `redirect_on_failure` (default `false`). Redirects the client to the real remote URL if there's any HTTP
errors. Any error during body processing will not be redirected as the response is chunked. This may expose
remote URL, clients IPs, .
* `max_body_length` (default `#{inspect(@max_body_length)}`): limits the content length to be approximately the
specified length. It is validated with the `content-length` header and also verified when proxying.
* `max_read_duration` (default `#{inspect(@max_read_duration)}` ms): the total time the connection is allowed to
read from the remote upstream.
* `inline_content_types`:
* `true` will not alter `content-disposition` (up to the upstream),
* `false` will add `content-disposition: attachment` to any request,
* a list of whitelisted content types
* `keep_user_agent` will forward the client's user-agent to the upstream. This may be useful if the upstream is
doing content transformation (encoding, ) depending on the request.
* `req_headers`, `resp_headers` additional headers.
* `http`: options for [hackney](https://github.com/benoitc/hackney).
"""
@hackney Application.get_env(:mobilizon, :hackney, :hackney)
@httpoison Application.get_env(:mobilizon, :httpoison, HTTPoison)
@default_hackney_options []
@inline_content_types [
"image/gif",
"image/jpeg",
"image/jpg",
"image/png",
"image/svg+xml",
"audio/mpeg",
"audio/mp3",
"video/webm",
"video/mp4",
"video/quicktime"
]
require Logger
import Plug.Conn
@type option() ::
{:keep_user_agent, boolean}
| {:max_read_duration, :timer.time() | :infinity}
| {:max_body_length, non_neg_integer() | :infinity}
| {:http, []}
| {:req_headers, [{String.t(), String.t()}]}
| {:resp_headers, [{String.t(), String.t()}]}
| {:inline_content_types, boolean() | [String.t()]}
| {:redirect_on_failure, boolean()}
@spec call(Plug.Conn.t(), url :: String.t(), [option()]) :: Plug.Conn.t()
def call(_conn, _url, _opts \\ [])
def call(conn = %{method: method}, url, opts) when method in @methods do
hackney_opts =
@default_hackney_options
|> Keyword.merge(Keyword.get(opts, :http, []))
|> @httpoison.process_request_options()
req_headers = build_req_headers(conn.req_headers, opts)
opts =
if filename = MobilizonWeb.MediaProxy.filename(url) do
Keyword.put_new(opts, :attachment_name, filename)
else
opts
end
with {:ok, code, headers, client} <- request(method, url, req_headers, hackney_opts),
:ok <- header_length_constraint(headers, Keyword.get(opts, :max_body_length)) do
response(conn, client, url, code, headers, opts)
else
{:ok, code, headers} ->
head_response(conn, url, code, headers, opts)
|> halt()
{:error, {:invalid_http_response, code}} ->
Logger.error("#{__MODULE__}: request to #{inspect(url)} failed with HTTP status #{code}")
conn
|> error_or_redirect(
url,
code,
"Request failed: " <> Plug.Conn.Status.reason_phrase(code),
opts
)
|> halt()
{:error, error} ->
Logger.error("#{__MODULE__}: request to #{inspect(url)} failed: #{inspect(error)}")
conn
|> error_or_redirect(url, 500, "Request failed", opts)
|> halt()
end
end
def call(conn, _, _) do
conn
|> send_resp(400, Plug.Conn.Status.reason_phrase(400))
|> halt()
end
defp request(method, url, headers, hackney_opts) do
Logger.debug("#{__MODULE__} #{method} #{url} #{inspect(headers)}")
method = method |> String.downcase() |> String.to_existing_atom()
case @hackney.request(method, url, headers, "", hackney_opts) do
{:ok, code, headers, client} when code in @valid_resp_codes ->
{:ok, code, downcase_headers(headers), client}
{:ok, code, headers} when code in @valid_resp_codes ->
{:ok, code, downcase_headers(headers)}
{:ok, code, _, _} ->
{:error, {:invalid_http_response, code}}
{:error, error} ->
{:error, error}
end
end
defp response(conn, client, url, status, headers, opts) do
result =
conn
|> put_resp_headers(build_resp_headers(headers, opts))
|> send_chunked(status)
|> chunk_reply(client, opts)
case result do
{:ok, conn} ->
halt(conn)
{:error, :closed, conn} ->
:hackney.close(client)
halt(conn)
{:error, error, conn} ->
Logger.warn(
"#{__MODULE__} request to #{url} failed while reading/chunking: #{inspect(error)}"
)
:hackney.close(client)
halt(conn)
end
end
defp chunk_reply(conn, client, opts) do
chunk_reply(conn, client, opts, 0, 0)
end
defp chunk_reply(conn, client, opts, sent_so_far, duration) do
with {:ok, duration} <-
check_read_duration(
duration,
Keyword.get(opts, :max_read_duration, @max_read_duration)
),
{:ok, data} <- @hackney.stream_body(client),
{:ok, duration} <- increase_read_duration(duration),
sent_so_far = sent_so_far + byte_size(data),
:ok <- body_size_constraint(sent_so_far, Keyword.get(opts, :max_body_size)),
{:ok, conn} <- chunk(conn, data) do
chunk_reply(conn, client, opts, sent_so_far, duration)
else
:done -> {:ok, conn}
{:error, error} -> {:error, error, conn}
end
end
defp head_response(conn, _url, code, headers, opts) do
conn
|> put_resp_headers(build_resp_headers(headers, opts))
|> send_resp(code, "")
end
defp error_or_redirect(conn, url, code, body, opts) do
if Keyword.get(opts, :redirect_on_failure, false) do
conn
|> Phoenix.Controller.redirect(external: url)
|> halt()
else
conn
|> send_resp(code, body)
|> halt
end
end
defp downcase_headers(headers) do
Enum.map(headers, fn {k, v} ->
{String.downcase(k), v}
end)
end
defp get_content_type(headers) do
{_, content_type} =
List.keyfind(headers, "content-type", 0, {"content-type", "application/octet-stream"})
[content_type | _] = String.split(content_type, ";")
content_type
end
defp put_resp_headers(conn, headers) do
Enum.reduce(headers, conn, fn {k, v}, conn ->
put_resp_header(conn, k, v)
end)
end
defp build_req_headers(headers, opts) do
headers
|> downcase_headers()
|> Enum.filter(fn {k, _} -> k in @keep_req_headers end)
|> (fn headers ->
headers = headers ++ Keyword.get(opts, :req_headers, [])
if Keyword.get(opts, :keep_user_agent, false) do
List.keystore(
headers,
"user-agent",
0,
{"user-agent", Mobilizon.Application.user_agent()}
)
else
headers
end
end).()
end
defp build_resp_headers(headers, opts) do
headers
|> Enum.filter(fn {k, _} -> k in @keep_resp_headers end)
|> build_resp_cache_headers(opts)
|> build_resp_content_disposition_header(opts)
|> (fn headers -> headers ++ Keyword.get(opts, :resp_headers, []) end).()
end
defp build_resp_cache_headers(headers, _opts) do
has_cache? = Enum.any?(headers, fn {k, _} -> k in @resp_cache_headers end)
has_cache_control? = List.keymember?(headers, "cache-control", 0)
cond do
has_cache? && has_cache_control? ->
headers
has_cache? ->
# There's caching header present but no cache-control -- we need to explicitely override it
# to public as Plug defaults to "max-age=0, private, must-revalidate"
List.keystore(headers, "cache-control", 0, {"cache-control", "public"})
true ->
List.keystore(
headers,
"cache-control",
0,
{"cache-control", @default_cache_control_header}
)
end
end
defp build_resp_content_disposition_header(headers, opts) do
opt = Keyword.get(opts, :inline_content_types, @inline_content_types)
content_type = get_content_type(headers)
attachment? =
cond do
is_list(opt) && !Enum.member?(opt, content_type) -> true
opt == false -> true
true -> false
end
if attachment? do
name =
try do
{{"content-disposition", content_disposition_string}, _} =
List.keytake(headers, "content-disposition", 0)
[name | _] =
Regex.run(
~r/filename="((?:[^"\\]|\\.)*)"/u,
content_disposition_string || "",
capture: :all_but_first
)
name
rescue
MatchError -> Keyword.get(opts, :attachment_name, "attachment")
end
disposition = "attachment; filename=\"#{name}\""
List.keystore(headers, "content-disposition", 0, {"content-disposition", disposition})
else
headers
end
end
defp header_length_constraint(headers, limit) when is_integer(limit) and limit > 0 do
with {_, size} <- List.keyfind(headers, "content-length", 0),
{size, _} <- Integer.parse(size),
true <- size <= limit do
:ok
else
false ->
{:error, :body_too_large}
_ ->
:ok
end
end
defp header_length_constraint(_, _), do: :ok
defp body_size_constraint(size, limit) when is_integer(limit) and limit > 0 and size >= limit do
{:error, :body_too_large}
end
defp body_size_constraint(_, _), do: :ok
defp check_read_duration(duration, max)
when is_integer(duration) and is_integer(max) and max > 0 do
if duration > max do
{:error, :read_duration_exceeded}
else
{:ok, {duration, :erlang.system_time(:millisecond)}}
end
end
defp check_read_duration(_, _), do: {:ok, :no_duration_limit, :no_duration_limit}
defp increase_read_duration({previous_duration, started})
when is_integer(previous_duration) and is_integer(started) do
duration = :erlang.system_time(:millisecond) - started
{:ok, previous_duration + duration}
end
defp increase_read_duration(_) do
{:ok, :no_duration_limit, :no_duration_limit}
end
end

View file

@ -5,7 +5,7 @@ defmodule MobilizonWeb.Router do
use MobilizonWeb, :router use MobilizonWeb, :router
pipeline :graphql do pipeline :graphql do
plug(:accepts, ["json"]) # plug(:accepts, ["json"])
plug(MobilizonWeb.AuthPipeline) plug(MobilizonWeb.AuthPipeline)
end end
@ -102,7 +102,6 @@ defmodule MobilizonWeb.Router do
scope "/", MobilizonWeb do scope "/", MobilizonWeb do
pipe_through(:browser) pipe_through(:browser)
forward("/uploads", UploadPlug)
get("/*path", PageController, :index) get("/*path", PageController, :index)
end end
end end

View file

@ -4,7 +4,7 @@ defmodule MobilizonWeb.Schema do
""" """
use Absinthe.Schema use Absinthe.Schema
alias Mobilizon.{Actors, Events, Users, Addresses} alias Mobilizon.{Actors, Events, Users, Addresses, Media}
alias Mobilizon.Actors.{Actor, Follower, Member} alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Events.{Event, Comment, Participant} alias Mobilizon.Events.{Event, Comment, Participant}
@ -14,6 +14,7 @@ defmodule MobilizonWeb.Schema do
import_types(Absinthe.Plug.Types) import_types(Absinthe.Plug.Types)
import_types(MobilizonWeb.Schema.UserType) import_types(MobilizonWeb.Schema.UserType)
import_types(MobilizonWeb.Schema.PictureType)
import_types(MobilizonWeb.Schema.ActorInterface) import_types(MobilizonWeb.Schema.ActorInterface)
import_types(MobilizonWeb.Schema.Actors.PersonType) import_types(MobilizonWeb.Schema.Actors.PersonType)
import_types(MobilizonWeb.Schema.Actors.GroupType) import_types(MobilizonWeb.Schema.Actors.GroupType)
@ -32,12 +33,6 @@ defmodule MobilizonWeb.Schema do
field(:user, non_null(:user), description: "The user associated to this session") field(:user, non_null(:user), description: "The user associated to this session")
end end
@desc "A picture"
object :picture do
field(:url, :string, description: "The URL for this picture")
field(:url_thumbnail, :string, description: "The URL for this picture's thumbnail")
end
@desc """ @desc """
Represents a notification for an user Represents a notification for an user
""" """
@ -91,6 +86,7 @@ defmodule MobilizonWeb.Schema do
|> Dataloader.add_source(Users, Users.data()) |> Dataloader.add_source(Users, Users.data())
|> Dataloader.add_source(Events, Events.data()) |> Dataloader.add_source(Events, Events.data())
|> Dataloader.add_source(Addresses, Addresses.data()) |> Dataloader.add_source(Addresses, Addresses.data())
|> Dataloader.add_source(Media, Media.data())
Map.put(ctx, :loader, loader) Map.put(ctx, :loader, loader)
end end
@ -112,6 +108,7 @@ defmodule MobilizonWeb.Schema do
import_fields(:tag_queries) import_fields(:tag_queries)
import_fields(:address_queries) import_fields(:address_queries)
import_fields(:config_queries) import_fields(:config_queries)
import_fields(:picture_queries)
end end
@desc """ @desc """
@ -126,11 +123,6 @@ defmodule MobilizonWeb.Schema do
import_fields(:participant_mutations) import_fields(:participant_mutations)
import_fields(:member_mutations) import_fields(:member_mutations)
import_fields(:feed_token_mutations) import_fields(:feed_token_mutations)
import_fields(:picture_mutations)
# @desc "Upload a picture"
# field :upload_picture, :picture do
# arg(:file, non_null(:upload))
# resolve(&Resolvers.Upload.upload_picture/3)
# end
end end
end end

View file

@ -5,10 +5,11 @@ defmodule MobilizonWeb.Schema.ActorInterface do
use Absinthe.Schema.Notation use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1] import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Events alias Mobilizon.{Events}
import_types(MobilizonWeb.Schema.Actors.FollowerType) import_types(MobilizonWeb.Schema.Actors.FollowerType)
import_types(MobilizonWeb.Schema.EventType) import_types(MobilizonWeb.Schema.EventType)
# import_types(MobilizonWeb.Schema.PictureType)
@desc "An ActivityPub actor" @desc "An ActivityPub actor"
interface :actor do interface :actor do
@ -27,8 +28,9 @@ defmodule MobilizonWeb.Schema.ActorInterface do
) )
field(:suspended, :boolean, description: "If the actor is suspended") field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar_url, :string, description: "The actor's avatar url")
field(:banner_url, :string, description: "The actor's banner url") field(:avatar, :picture, description: "The actor's avatar picture")
field(:banner, :picture, description: "The actor's banner picture")
# These one should have a privacy setting # These one should have a privacy setting
field(:following, list_of(:follower), description: "List of followings") field(:following, list_of(:follower), description: "List of followings")

View file

@ -5,7 +5,7 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
use Absinthe.Schema.Notation use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1] import Absinthe.Resolution.Helpers, only: [dataloader: 1]
import_types(MobilizonWeb.Schema.Actors.MemberType) import_types(MobilizonWeb.Schema.Actors.MemberType)
alias MobilizonWeb.Resolvers alias MobilizonWeb.Resolvers.{Member, Group}
alias Mobilizon.Events alias Mobilizon.Events
@desc """ @desc """
@ -29,8 +29,9 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
) )
field(:suspended, :boolean, description: "If the actor is suspended") field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar_url, :string, description: "The actor's avatar url")
field(:banner_url, :string, description: "The actor's banner url") field(:avatar, :picture, description: "The actor's avatar picture")
field(:banner, :picture, description: "The actor's banner picture")
# These one should have a privacy setting # These one should have a privacy setting
field(:following, list_of(:follower), description: "List of followings") field(:following, list_of(:follower), description: "List of followings")
@ -51,7 +52,7 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
) )
field(:members, non_null(list_of(:member)), field(:members, non_null(list_of(:member)),
resolve: &Resolvers.Member.find_members_for_group/3, resolve: &Member.find_members_for_group/3,
description: "List of group members" description: "List of group members"
) )
end end
@ -80,13 +81,13 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
field :groups, list_of(:group) do field :groups, list_of(:group) do
arg(:page, :integer, default_value: 1) arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10) arg(:limit, :integer, default_value: 10)
resolve(&Resolvers.Group.list_groups/3) resolve(&Group.list_groups/3)
end end
@desc "Get a group by it's preferred username" @desc "Get a group by it's preferred username"
field :group, :group do field :group, :group do
arg(:preferred_username, non_null(:string)) arg(:preferred_username, non_null(:string))
resolve(&Resolvers.Group.find_group/3) resolve(&Group.find_group/3)
end end
end end
@ -101,7 +102,17 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
description: "The actor's username which will be the admin (otherwise user's default one)" description: "The actor's username which will be the admin (otherwise user's default one)"
) )
resolve(&Resolvers.Group.create_group/3) arg(:avatar, :picture_input,
description:
"The avatar for the group, either as an object or directly the ID of an existing Picture"
)
arg(:banner, :picture_input,
description:
"The banner for the group, either as an object or directly the ID of an existing Picture"
)
resolve(&Group.create_group/3)
end end
@desc "Delete a group" @desc "Delete a group"
@ -109,7 +120,7 @@ defmodule MobilizonWeb.Schema.Actors.GroupType do
arg(:group_id, non_null(:integer)) arg(:group_id, non_null(:integer))
arg(:actor_id, non_null(:integer)) arg(:actor_id, non_null(:integer))
resolve(&Resolvers.Group.delete_group/3) resolve(&Group.delete_group/3)
end end
end end
end end

View file

@ -5,7 +5,7 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do
use Absinthe.Schema.Notation use Absinthe.Schema.Notation
import Absinthe.Resolution.Helpers, only: [dataloader: 1] import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Events alias Mobilizon.Events
alias MobilizonWeb.Resolvers alias MobilizonWeb.Resolvers.Person
import MobilizonWeb.Schema.Utils import MobilizonWeb.Schema.Utils
import_types(MobilizonWeb.Schema.Events.FeedTokenType) import_types(MobilizonWeb.Schema.Events.FeedTokenType)
@ -34,8 +34,9 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do
) )
field(:suspended, :boolean, description: "If the actor is suspended") field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar_url, :string, description: "The actor's avatar url")
field(:banner_url, :string, description: "The actor's banner url") field(:avatar, :picture, description: "The actor's avatar picture")
field(:banner, :picture, description: "The actor's banner picture")
# These one should have a privacy setting # These one should have a privacy setting
field(:following, list_of(:follower), description: "List of followings") field(:following, list_of(:follower), description: "List of followings")
@ -56,25 +57,25 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do
@desc "The list of events this person goes to" @desc "The list of events this person goes to"
field :going_to_events, list_of(:event) do field :going_to_events, list_of(:event) do
resolve(&Resolvers.Person.person_going_to_events/3) resolve(&Person.person_going_to_events/3)
end end
end end
object :person_queries do object :person_queries do
@desc "Get the current actor for the logged-in user" @desc "Get the current actor for the logged-in user"
field :logged_person, :person do field :logged_person, :person do
resolve(&Resolvers.Person.get_current_person/3) resolve(&Person.get_current_person/3)
end end
@desc "Get a person by it's preferred username" @desc "Get a person by it's preferred username"
field :person, :person do field :person, :person do
arg(:preferred_username, non_null(:string)) arg(:preferred_username, non_null(:string))
resolve(&Resolvers.Person.find_person/3) resolve(&Person.find_person/3)
end end
@desc "Get the persons for an user" @desc "Get the persons for an user"
field :identities, list_of(:person) do field :identities, list_of(:person) do
resolve(&Resolvers.Person.identities/3) resolve(&Person.identities/3)
end end
end end
@ -87,7 +88,17 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do
arg(:summary, :string, description: "The summary for the new profile", default_value: "") arg(:summary, :string, description: "The summary for the new profile", default_value: "")
resolve(handle_errors(&Resolvers.Person.create_person/3)) arg(:avatar, :picture_input,
description:
"The avatar for the profile, either as an object or directly the ID of an existing Picture"
)
arg(:banner, :picture_input,
description:
"The banner for the profile, either as an object or directly the ID of an existing Picture"
)
resolve(handle_errors(&Person.create_person/3))
end end
@desc "Register a first profile on registration" @desc "Register a first profile on registration"
@ -99,7 +110,17 @@ defmodule MobilizonWeb.Schema.Actors.PersonType do
arg(:summary, :string, description: "The summary for the new profile", default_value: "") arg(:summary, :string, description: "The summary for the new profile", default_value: "")
arg(:email, non_null(:string), description: "The email from the user previously created") arg(:email, non_null(:string), description: "The email from the user previously created")
resolve(handle_errors(&Resolvers.Person.register_person/3)) arg(:avatar, :picture_input,
description:
"The avatar for the profile, either as an object or directly the ID of an existing Picture"
)
arg(:banner, :picture_input,
description:
"The banner for the profile, either as an object or directly the ID of an existing Picture"
)
resolve(handle_errors(&Person.register_person/3))
end end
end end
end end

View file

@ -8,7 +8,7 @@ defmodule MobilizonWeb.Schema.EventType do
import_types(MobilizonWeb.Schema.AddressType) import_types(MobilizonWeb.Schema.AddressType)
import_types(MobilizonWeb.Schema.Events.ParticipantType) import_types(MobilizonWeb.Schema.Events.ParticipantType)
import_types(MobilizonWeb.Schema.TagType) import_types(MobilizonWeb.Schema.TagType)
alias MobilizonWeb.Resolvers alias MobilizonWeb.Resolvers.{Picture, Event, Tag}
@desc "An event" @desc "An event"
object :event do object :event do
@ -23,10 +23,12 @@ defmodule MobilizonWeb.Schema.EventType do
field(:ends_on, :datetime, description: "Datetime for when the event ends") field(:ends_on, :datetime, description: "Datetime for when the event ends")
field(:status, :event_status, description: "Status of the event") field(:status, :event_status, description: "Status of the event")
field(:visibility, :event_visibility, description: "The event's visibility") field(:visibility, :event_visibility, description: "The event's visibility")
# TODO replace me with picture object
field(:thumbnail, :string, description: "A thumbnail picture for the event") field(:picture, :picture,
# TODO replace me with banner description: "The event's picture",
field(:large_image, :string, description: "A large picture for the event") resolve: &Picture.picture/3
)
field(:publish_at, :datetime, description: "When the event was published") field(:publish_at, :datetime, description: "When the event was published")
field(:physical_address, :address, field(:physical_address, :address,
@ -45,19 +47,19 @@ defmodule MobilizonWeb.Schema.EventType do
field(:attributed_to, :actor, description: "Who the event is attributed to (often a group)") field(:attributed_to, :actor, description: "Who the event is attributed to (often a group)")
field(:tags, list_of(:tag), field(:tags, list_of(:tag),
resolve: &MobilizonWeb.Resolvers.Tag.list_tags_for_event/3, resolve: &Tag.list_tags_for_event/3,
description: "The event's tags" description: "The event's tags"
) )
field(:category, :string, description: "The event's category") field(:category, :string, description: "The event's category")
field(:participants, list_of(:participant), field(:participants, list_of(:participant),
resolve: &MobilizonWeb.Resolvers.Event.list_participants_for_event/3, resolve: &Event.list_participants_for_event/3,
description: "The event's participants" description: "The event's participants"
) )
field(:related_events, list_of(:event), field(:related_events, list_of(:event),
resolve: &MobilizonWeb.Resolvers.Event.list_related_events/3, resolve: &Event.list_related_events/3,
description: "Events related to this one" description: "Events related to this one"
) )
@ -93,13 +95,13 @@ defmodule MobilizonWeb.Schema.EventType do
field :events, list_of(:event) do field :events, list_of(:event) do
arg(:page, :integer, default_value: 1) arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10) arg(:limit, :integer, default_value: 10)
resolve(&Resolvers.Event.list_events/3) resolve(&Event.list_events/3)
end end
@desc "Get an event by uuid" @desc "Get an event by uuid"
field :event, :event do field :event, :event do
arg(:uuid, non_null(:uuid)) arg(:uuid, non_null(:uuid))
resolve(&Resolvers.Event.find_event/3) resolve(&Event.find_event/3)
end end
end end
@ -113,15 +115,20 @@ defmodule MobilizonWeb.Schema.EventType do
arg(:state, :integer) arg(:state, :integer)
arg(:status, :integer) arg(:status, :integer)
arg(:public, :boolean) arg(:public, :boolean)
arg(:thumbnail, :string) arg(:visibility, :event_visibility, default_value: :private)
arg(:large_image, :string)
arg(:picture, :picture_input,
description:
"The picture for the event, either as an object or directly the ID of an existing Picture"
)
arg(:publish_at, :datetime) arg(:publish_at, :datetime)
arg(:online_address, :string) arg(:online_address, :string)
arg(:phone_address, :string) arg(:phone_address, :string)
arg(:organizer_actor_id, non_null(:id)) arg(:organizer_actor_id, non_null(:id))
arg(:category, non_null(:string)) arg(:category, non_null(:string))
resolve(&Resolvers.Event.create_event/3) resolve(&Event.create_event/3)
end end
@desc "Delete an event" @desc "Delete an event"
@ -129,7 +136,7 @@ defmodule MobilizonWeb.Schema.EventType do
arg(:event_id, non_null(:integer)) arg(:event_id, non_null(:integer))
arg(:actor_id, non_null(:integer)) arg(:actor_id, non_null(:integer))
resolve(&Resolvers.Event.delete_event/3) resolve(&Event.delete_event/3)
end end
end end
end end

View file

@ -0,0 +1,48 @@
defmodule MobilizonWeb.Schema.PictureType do
@moduledoc """
Schema representation for Pictures
"""
use Absinthe.Schema.Notation
alias MobilizonWeb.Resolvers.Picture
@desc "A picture"
object :picture do
field(:id, :id, description: "The picture's ID")
field(:alt, :string, description: "The picture's alternative text")
field(:name, :string, description: "The picture's name")
field(:url, :string, description: "The picture's full URL")
end
@desc "An attached picture or a link to a picture"
input_object :picture_input do
# Either a full picture object
field(:picture, :picture_input_object)
# Or directly the ID of an existing picture
field(:picture_id, :string)
end
@desc "An attached picture"
input_object :picture_input_object do
field(:name, non_null(:string))
field(:alt, :string)
field(:file, non_null(:upload))
end
object :picture_queries do
@desc "Get a picture"
field :picture, :picture do
arg(:id, non_null(:string))
resolve(&Picture.picture/3)
end
end
object :picture_mutations do
@desc "Upload a picture"
field :upload_picture, :picture do
arg(:name, non_null(:string))
arg(:alt, :string)
arg(:file, non_null(:upload))
resolve(&Picture.upload_picture/3)
end
end
end

160
lib/mobilizon_web/upload.ex Normal file
View file

@ -0,0 +1,160 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/upload.ex
defmodule MobilizonWeb.Upload do
@moduledoc """
Manage user uploads
Options:
* `:type`: presets for activity type (defaults to Document) and size limits from app configuration
* `:description`: upload alternative text
* `:base_url`: override base url
* `:uploader`: override uploader
* `:filters`: override filters
* `:size_limit`: override size limit
* `:activity_type`: override activity type
The `%MobilizonWeb.Upload{}` struct: all documented fields are meant to be overwritten in filters:
* `:id` - the upload id.
* `:name` - the upload file name.
* `:path` - the upload path: set at first to `id/name` but can be changed. Keep in mind that the path
is once created permanent and changing it (especially in uploaders) is probably a bad idea!
* `:tempfile` - path to the temporary file. Prefer in-place changes on the file rather than changing the
path as the temporary file is also tracked by `Plug.Upload{}` and automatically deleted once the request is over.
Related behaviors:
* `MobilizonWeb.Uploaders.Uploader`
* `MobilizonWeb.Upload.Filter`
"""
alias Ecto.UUID
require Logger
@type source ::
Plug.Upload.t()
| (data_uri_string :: String.t())
| {:from_local, name :: String.t(), id :: String.t(), path :: String.t()}
@type option ::
{:type, :avatar | :banner | :background}
| {:description, String.t()}
| {:activity_type, String.t()}
| {:size_limit, nil | non_neg_integer()}
| {:uploader, module()}
| {:filters, [module()]}
@type t :: %__MODULE__{
id: String.t(),
name: String.t(),
tempfile: String.t(),
content_type: String.t(),
path: String.t()
}
defstruct [:id, :name, :tempfile, :content_type, :path]
@spec store(source, options :: [option()]) :: {:ok, Map.t()} | {:error, any()}
def store(upload, opts \\ []) do
opts = get_opts(opts)
with {:ok, upload} <- prepare_upload(upload, opts),
upload = %__MODULE__{upload | path: upload.path || "#{upload.id}/#{upload.name}"},
{:ok, upload} <- MobilizonWeb.Upload.Filter.filter(opts.filters, upload),
{:ok, url_spec} <- MobilizonWeb.Uploaders.Uploader.put_file(opts.uploader, upload) do
{:ok,
%{
"type" => opts.activity_type,
"url" => [
%{
"type" => "Link",
"mediaType" => upload.content_type,
"href" => url_from_spec(upload, opts.base_url, url_spec)
}
],
"name" => Map.get(opts, :description) || upload.name
}}
else
{:error, error} ->
Logger.error(
"#{__MODULE__} store (using #{inspect(opts.uploader)}) failed: #{inspect(error)}"
)
{:error, error}
end
end
def char_unescaped?(char) do
URI.char_unreserved?(char) or char == ?/
end
defp get_opts(opts) do
{size_limit, activity_type} =
case Keyword.get(opts, :type) do
:banner ->
{Mobilizon.CommonConfig.get!([:instance, :banner_upload_limit]), "Image"}
:avatar ->
{Mobilizon.CommonConfig.get!([:instance, :avatar_upload_limit]), "Image"}
_ ->
{Mobilizon.CommonConfig.get!([:instance, :upload_limit]), "Document"}
end
%{
activity_type: Keyword.get(opts, :activity_type, activity_type),
size_limit: Keyword.get(opts, :size_limit, size_limit),
uploader: Keyword.get(opts, :uploader, Mobilizon.CommonConfig.get([__MODULE__, :uploader])),
filters: Keyword.get(opts, :filters, Mobilizon.CommonConfig.get([__MODULE__, :filters])),
description: Keyword.get(opts, :description),
base_url:
Keyword.get(
opts,
:base_url,
Mobilizon.CommonConfig.get([__MODULE__, :base_url], MobilizonWeb.Endpoint.url())
)
}
end
defp prepare_upload(%Plug.Upload{} = file, opts) do
with :ok <- check_file_size(file.path, opts.size_limit),
{:ok, content_type, name} <- Mobilizon.MIME.file_mime_type(file.path, file.filename) do
{:ok,
%__MODULE__{
id: UUID.generate(),
name: name,
tempfile: file.path,
content_type: content_type
}}
end
end
defp check_file_size(path, size_limit) when is_integer(size_limit) and size_limit > 0 do
with {:ok, %{size: size}} <- File.stat(path),
true <- size <= size_limit do
:ok
else
false -> {:error, :file_too_large}
error -> error
end
end
defp check_file_size(_, _), do: :ok
defp url_from_spec(%__MODULE__{name: name}, base_url, {:file, path}) do
path =
URI.encode(path, &char_unescaped?/1) <>
if Mobilizon.CommonConfig.get([__MODULE__, :link_name], false) do
"?name=#{URI.encode(name, &char_unescaped?/1)}"
else
""
end
[base_url, "media", path]
|> Path.join()
end
defp url_from_spec(_upload, _base_url, {:url, url}), do: url
end

View file

@ -0,0 +1,42 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/upload/filter.ex
defmodule MobilizonWeb.Upload.Filter do
@moduledoc """
Upload Filter behaviour
This behaviour allows to run filtering actions just before a file is uploaded. This allows to:
* morph in place the temporary file
* change any field of a `Mobilizon.Upload` struct
* cancel/stop the upload
"""
require Logger
@callback filter(MobilizonWeb.Upload.t()) ::
:ok | {:ok, MobilizonWeb.Upload.t()} | {:error, any()}
@spec filter([module()], MobilizonWeb.Upload.t()) ::
{:ok, MobilizonWeb.Upload.t()} | {:error, any()}
def filter([], upload) do
{:ok, upload}
end
def filter([filter | rest], upload) do
case filter.filter(upload) do
:ok ->
filter(rest, upload)
{:ok, upload} ->
filter(rest, upload)
error ->
Logger.error("#{__MODULE__}: Filter #{filter} failed: #{inspect(error)}")
error
end
end
end

View file

@ -0,0 +1,28 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/upload/filter/anonymize_filename.ex
defmodule MobilizonWeb.Upload.Filter.AnonymizeFilename do
@moduledoc """
Replaces the original filename with a pre-defined text or randomly generated string.
Should be used after `MobilizonWeb.Upload.Filter.Dedupe`.
"""
@behaviour MobilizonWeb.Upload.Filter
def filter(upload) do
extension = List.last(String.split(upload.name, "."))
name = Mobilizon.CommonConfig.get([__MODULE__, :text], random(extension))
{:ok, %MobilizonWeb.Upload{upload | name: name}}
end
defp random(extension) do
string =
10
|> :crypto.strong_rand_bytes()
|> Base.url_encode64(padding: false)
string <> "." <> extension
end
end

View file

@ -0,0 +1,19 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/upload/filter/dedupe.ex
defmodule MobilizonWeb.Upload.Filter.Dedupe do
@moduledoc """
Names the file after its hash to avoid dedupes
"""
@behaviour MobilizonWeb.Upload.Filter
alias MobilizonWeb.Upload
def filter(%Upload{name: name} = upload) do
extension = String.split(name, ".") |> List.last()
shasum = :crypto.hash(:sha256, File.read!(upload.tempfile)) |> Base.encode16(case: :lower)
filename = shasum <> "." <> extension
{:ok, %Upload{upload | id: shasum, path: filename}}
end
end

View file

@ -0,0 +1,45 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/upload/filter/mogrify.ex
defmodule MobilizonWeb.Upload.Filter.Mogrify do
@moduledoc """
Handle mogrify transformations
"""
@behaviour MobilizonWeb.Upload.Filter
@type conversion :: action :: String.t() | {action :: String.t(), opts :: String.t()}
@type conversions :: conversion() | [conversion()]
def filter(%MobilizonWeb.Upload{tempfile: file, content_type: "image" <> _}) do
filters = Mobilizon.CommonConfig.get!([__MODULE__, :args])
file
|> Mogrify.open()
|> mogrify_filter(filters)
|> Mogrify.save(in_place: true)
:ok
end
def filter(_), do: :ok
defp mogrify_filter(mogrify, nil), do: mogrify
defp mogrify_filter(mogrify, [filter | rest]) do
mogrify
|> mogrify_filter(filter)
|> mogrify_filter(rest)
end
defp mogrify_filter(mogrify, []), do: mogrify
defp mogrify_filter(mogrify, {action, options}) do
Mogrify.custom(mogrify, action, options)
end
defp mogrify_filter(mogrify, action) when is_binary(action) do
Mogrify.custom(mogrify, action)
end
end

View file

@ -1,18 +0,0 @@
defmodule MobilizonWeb.UploadPlug do
@moduledoc """
Plug to intercept uploads
"""
use Plug.Builder
plug(Plug.Static,
at: "/",
from: {:mobilizon, "./uploads"}
)
# only: ~w(images robots.txt)
plug(:not_found)
def not_found(conn, _) do
send_resp(conn, 404, "not found")
end
end

View file

@ -1,53 +0,0 @@
defmodule MobilizonWeb.Uploaders.Avatar do
@moduledoc """
Handles avatar uploads
"""
use Arc.Definition
# Include ecto support (requires package arc_ecto installed):
# use Arc.Ecto.Definition
@versions [:original]
# To add a thumbnail version:
# @versions [:original, :thumb]
# Override the bucket on a per definition basis:
# def bucket do
# :custom_bucket_name
# end
# Whitelist file extensions:
# def validate({file, _}) do
# ~w(.jpg .jpeg .gif .png) |> Enum.member?(Path.extname(file.file_name))
# end
# Define a thumbnail transformation:
# def transform(:thumb, _) do
# {:convert, "-strip -thumbnail 250x250^ -gravity center -extent 250x250 -format png", :png}
# end
# Override the persisted filenames:
# def filename(version, _) do
# version
# end
# Override the storage directory:
# def storage_dir(version, {file, scope}) do
# "uploads/user/avatars/#{scope.id}"
# end
# Provide a default URL if there hasn't been a file uploaded
# def default_url(version, scope) do
# "/images/avatars/default_#{version}.png"
# end
# Specify custom headers for s3 objects
# Available options are [:cache_control, :content_disposition,
# :content_encoding, :content_length, :content_type,
# :expect, :expires, :storage_class, :website_redirect_location]
#
# def s3_object_headers(version, {file, scope}) do
# [content_type: MIME.from_path(file.file_name)]
# end
end

View file

@ -1,51 +0,0 @@
defmodule MobilizonWeb.Uploaders.Category do
@moduledoc """
Handles file uploads for categories
"""
use Arc.Definition
use Arc.Ecto.Definition
# To add a thumbnail version:
@versions [:original, :thumb]
@extension_whitelist ~w(.jpg .jpeg .gif .png)
# Override the bucket on a per definition basis:
# def bucket do
# :custom_bucket_name
# end
# Whitelist file extensions:
def validate({file, _}) do
file_extension = file.file_name |> Path.extname() |> String.downcase()
Enum.member?(@extension_whitelist, file_extension)
end
# Define a thumbnail transformation:
def transform(:thumb, _) do
{:convert, "-strip -thumbnail 250x250^ -gravity center -extent 250x250 -format png", :png}
end
# Override the persisted filenames:
def filename(version, {_file, %{title: title}}) do
"#{title}_#{version}"
end
# Override the storage directory:
def storage_dir(_, _) do
"uploads/event/"
end
# Provide a default URL if there hasn't been a file uploaded
# def default_url(version, scope) do
# "/images/avatars/default_#{version}.png"
# end
# Specify custom headers for s3 objects
# Available options are [:cache_control, :content_disposition,
# :content_encoding, :content_length, :content_type,
# :expect, :expires, :storage_class, :website_redirect_location]
#
# def s3_object_headers(version, {file, scope}) do
# [content_type: MIME.from_path(file.file_name)]
# end
end

View file

@ -0,0 +1,40 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/uploaders/local.ex
defmodule MobilizonWeb.Uploaders.Local do
@moduledoc """
Local uploader for files
"""
@behaviour MobilizonWeb.Uploaders.Uploader
def get_file(_) do
{:ok, {:static_dir, upload_path()}}
end
def put_file(upload) do
{local_path, file} =
case Enum.reverse(String.split(upload.path, "/", trim: true)) do
[file] ->
{upload_path(), file}
[file | folders] ->
path = Path.join([upload_path()] ++ Enum.reverse(folders))
File.mkdir_p!(path)
{path, file}
end
result_file = Path.join(local_path, file)
unless File.exists?(result_file) do
File.cp!(upload.tempfile, result_file)
end
:ok
end
def upload_path do
Mobilizon.CommonConfig.get!([__MODULE__, :uploads])
end
end

View file

@ -0,0 +1,73 @@
# Portions of this file are derived from Pleroma:
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social>
# SPDX-License-Identifier: AGPL-3.0-only
# Upstream: https://git.pleroma.social/pleroma/pleroma/blob/develop/lib/pleroma/uploaders/uploader.ex
defmodule MobilizonWeb.Uploaders.Uploader do
@moduledoc """
Defines the contract to put and get an uploaded file to any backend.
"""
@doc """
Instructs how to get the file from the backend.
Used by `MobilizonWeb.Plugs.UploadedMedia`.
"""
@type get_method :: {:static_dir, directory :: String.t()} | {:url, url :: String.t()}
@callback get_file(file :: String.t()) :: {:ok, get_method()}
@doc """
Put a file to the backend.
Returns:
* `:ok` which assumes `{:ok, upload.path}`
* `{:ok, spec}` where spec is:
* `{:file, filename :: String.t}` to handle reads with `get_file/1` (recommended)
This allows to correctly proxy or redirect requests to the backend, while allowing to migrate backends without breaking any URL.
* `{url, url :: String.t}` to bypass `get_file/2` and use the `url` directly in the activity.
* `{:error, String.t}` error information if the file failed to be saved to the backend.
* `:wait_callback` will wait for an http post request at `/api/pleroma/upload_callback/:upload_path` and call the uploader's `http_callback/3` method.
"""
@type file_spec :: {:file | :url, String.t()}
@callback put_file(MobilizonWeb.Upload.t()) ::
:ok | {:ok, file_spec()} | {:error, String.t()} | :wait_callback
@callback http_callback(Plug.Conn.t(), Map.t()) ::
{:ok, Plug.Conn.t()}
| {:ok, Plug.Conn.t(), file_spec()}
| {:error, Plug.Conn.t(), String.t()}
@optional_callbacks http_callback: 2
@spec put_file(module(), MobilizonWeb.Upload.t()) :: {:ok, file_spec()} | {:error, String.t()}
def put_file(uploader, upload) do
case uploader.put_file(upload) do
:ok -> {:ok, {:file, upload.path}}
:wait_callback -> handle_callback(uploader, upload)
{:ok, _} = ok -> ok
{:error, _} = error -> error
end
end
defp handle_callback(uploader, upload) do
:global.register_name({__MODULE__, upload.path}, self())
receive do
{__MODULE__, pid, conn, params} ->
case uploader.http_callback(conn, params) do
{:ok, conn, ok} ->
send(pid, {__MODULE__, conn})
{:ok, ok}
{:error, conn, error} ->
send(pid, {__MODULE__, conn})
{:error, error}
end
after
30_000 -> {:error, "Uploader callback timeout"}
end
end
end

View file

@ -8,15 +8,12 @@ defmodule MobilizonWeb.JsonLD.ObjectView do
def render("event.json", %{event: %Event{} = event}) do def render("event.json", %{event: %Event{} = event}) do
# TODO: event.description is actually markdown! # TODO: event.description is actually markdown!
json_ld = %{ json_ld = %{
"@context" => "https://schema.org", "@context" => "https://schema.org",
"@type" => "Event", "@type" => "Event",
"name" => event.title, "name" => event.title,
"description" => event.description, "description" => event.description,
"image" => [
event.thumbnail,
event.large_image
],
"performer" => %{ "performer" => %{
"@type" => "@type" =>
if(event.organizer_actor.type == :Group, do: "PerformingGroup", else: "Person"), if(event.organizer_actor.type == :Group, do: "PerformingGroup", else: "Person"),
@ -25,6 +22,15 @@ defmodule MobilizonWeb.JsonLD.ObjectView do
"location" => render_one(event.physical_address, ObjectView, "place.json", as: :address) "location" => render_one(event.physical_address, ObjectView, "place.json", as: :address)
} }
json_ld =
if event.picture do
Map.put(json_ld, "image", [
event.picture.file.url
])
else
json_ld
end
json_ld = json_ld =
if event.begins_on, if event.begins_on,
do: Map.put(json_ld, "startDate", DateTime.to_iso8601(event.begins_on)), do: Map.put(json_ld, "startDate", DateTime.to_iso8601(event.begins_on)),

View file

@ -44,7 +44,7 @@ defmodule MobilizonWeb.PageView do
end end
def render("event.activity-json", %{conn: %{assigns: %{object: event}}}) do def render("event.activity-json", %{conn: %{assigns: %{object: event}}}) do
event = Utils.make_event_data(event) event = Mobilizon.Service.ActivityPub.Converters.Event.model_to_as(event)
{:ok, html, []} = Earmark.as_html(event["summary"]) {:ok, html, []} = Earmark.as_html(event["summary"])
%{ %{
@ -66,7 +66,7 @@ defmodule MobilizonWeb.PageView do
end end
def render("comment.activity-json", %{conn: %{assigns: %{object: comment}}}) do def render("comment.activity-json", %{conn: %{assigns: %{object: comment}}}) do
comment = Utils.make_comment_data(comment) comment = Mobilizon.Service.ActivityPub.Converters.Comment.model_to_as(comment)
%{ %{
"actor" => comment["actor"], "actor" => comment["actor"],

View file

@ -468,10 +468,24 @@ defmodule Mobilizon.Service.ActivityPub do
""" """
@spec actor_data_from_actor_object(map()) :: {:ok, map()} @spec actor_data_from_actor_object(map()) :: {:ok, map()}
def actor_data_from_actor_object(data) when is_map(data) do def actor_data_from_actor_object(data) when is_map(data) do
avatar =
data["icon"]["url"] &&
%{
"name" => data["icon"]["name"] || "avatar",
"url" => data["icon"]["url"]
}
banner =
data["image"]["url"] &&
%{
"name" => data["image"]["name"] || "banner",
"url" => data["image"]["url"]
}
actor_data = %{ actor_data = %{
url: data["id"], url: data["id"],
avatar_url: data["icon"]["url"], avatar: avatar,
banner_url: data["image"]["url"], banner: banner,
name: data["name"], name: data["name"],
preferred_username: data["preferredUsername"], preferred_username: data["preferredUsername"],
summary: data["summary"], summary: data["summary"],
@ -512,7 +526,7 @@ defmodule Mobilizon.Service.ActivityPub do
%Activity{ %Activity{
recipients: ["https://www.w3.org/ns/activitystreams#Public"], recipients: ["https://www.w3.org/ns/activitystreams#Public"],
actor: event.organizer_actor.url, actor: event.organizer_actor.url,
data: event |> make_event_data, data: event |> Mobilizon.Service.ActivityPub.Converters.Event.model_to_as(),
local: local local: local
} }
end end
@ -523,7 +537,7 @@ defmodule Mobilizon.Service.ActivityPub do
%Activity{ %Activity{
recipients: ["https://www.w3.org/ns/activitystreams#Public"], recipients: ["https://www.w3.org/ns/activitystreams#Public"],
actor: comment.actor.url, actor: comment.actor.url,
data: comment |> make_comment_data, data: comment |> Mobilizon.Service.ActivityPub.Converters.Comment.model_to_as(),
local: local local: local
} }
end end

View file

@ -0,0 +1,9 @@
defmodule Mobilizon.Service.ActivityPub.Converter do
@moduledoc """
Converter behaviour
This module allows to convert from ActivityStream format to our own internal one, and back
"""
@callback as_to_model_data(map()) :: map()
@callback model_to_as(struct()) :: map()
end

View file

@ -0,0 +1,47 @@
defmodule Mobilizon.Service.ActivityPub.Converters.Actor do
@moduledoc """
Actor converter
This module allows to convert events from ActivityStream format to our own internal one, and back
"""
alias Mobilizon.Actors.Actor, as: ActorModel
alias Mobilizon.Service.ActivityPub.Converter
@behaviour Converter
@doc """
Converts an AP object data to our internal data structure
"""
@impl Converter
@spec as_to_model_data(map()) :: map()
def as_to_model_data(object) do
%{
"type" => String.to_existing_atom(object["type"]),
"preferred_username" => object["preferred_username"],
"summary" => object["summary"],
"url" => object["url"],
"name" => object["name"]
}
end
@doc """
Convert an actor struct to an ActivityStream representation
"""
@impl Converter
@spec model_to_as(ActorModel.t()) :: map()
def model_to_as(%ActorModel{} = actor) do
%{
"type" => Atom.to_string(actor.type),
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"preferred_username" => actor.preferred_username,
"name" => actor.name,
"summary" => actor.summary,
"following" => ActorModel.build_url(actor.preferred_username, :following),
"followers" => ActorModel.build_url(actor.preferred_username, :followers),
"inbox" => ActorModel.build_url(actor.preferred_username, :inbox),
"outbox" => ActorModel.build_url(actor.preferred_username, :outbox),
"id" => ActorModel.build_url(actor.preferred_username, :page),
"url" => actor.url
}
end
end

View file

@ -0,0 +1,96 @@
defmodule Mobilizon.Service.ActivityPub.Converters.Comment do
@moduledoc """
Comment converter
This module allows to convert events from ActivityStream format to our own internal one, and back
"""
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.Comment, as: CommentModel
alias Mobilizon.Events.Event
alias Mobilizon.Service.ActivityPub.Converter
alias Mobilizon.Service.ActivityPub
alias MobilizonWeb.Router.Helpers, as: Routes
alias MobilizonWeb.Endpoint
require Logger
@behaviour Converter
@doc """
Converts an AP object data to our internal data structure
"""
@impl Converter
@spec as_to_model_data(map()) :: map()
def as_to_model_data(object) do
{:ok, %Actor{id: actor_id}} = Actors.get_or_fetch_by_url(object["actor"])
Logger.debug("Inserting full comment")
Logger.debug(inspect(object))
data = %{
"text" => object["content"],
"url" => object["id"],
"actor_id" => actor_id,
"in_reply_to_comment_id" => nil,
"event_id" => nil,
"uuid" => object["uuid"]
}
# We fetch the parent object
Logger.debug("We're fetching the parent object")
data =
if Map.has_key?(object, "inReplyTo") && object["inReplyTo"] != nil &&
object["inReplyTo"] != "" do
Logger.debug(fn -> "Object has inReplyTo #{object["inReplyTo"]}" end)
case ActivityPub.fetch_object_from_url(object["inReplyTo"]) do
# Reply to an event (Event)
{:ok, %Event{id: id}} ->
Logger.debug("Parent object is an event")
data |> Map.put("event_id", id)
# Reply to a comment (Comment)
{:ok, %CommentModel{id: id} = comment} ->
Logger.debug("Parent object is another comment")
data
|> Map.put("in_reply_to_comment_id", id)
|> Map.put("origin_comment_id", comment |> CommentModel.get_thread_id())
# Anything else is kind of a MP
{:error, parent} ->
Logger.debug("Parent object is something we don't handle")
Logger.debug(inspect(parent))
data
end
else
Logger.debug("No parent object for this comment")
data
end
data
end
@doc """
Make an AS comment object from an existing `Comment` structure.
"""
@impl Converter
@spec model_to_as(CommentModel.t()) :: map()
def model_to_as(%CommentModel{} = comment) do
object = %{
"type" => "Note",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"content" => comment.text,
"actor" => comment.actor.url,
"attributedTo" => comment.actor.url,
"uuid" => comment.uuid,
"id" => Routes.page_url(Endpoint, :comment, comment.uuid)
}
if comment.in_reply_to_comment do
object |> Map.put("inReplyTo", comment.in_reply_to_comment.url || comment.event.url)
else
object
end
end
end

View file

@ -0,0 +1,70 @@
defmodule Mobilizon.Service.ActivityPub.Converters.Event do
@moduledoc """
Event converter
This module allows to convert events from ActivityStream format to our own internal one, and back
"""
alias Mobilizon.Actors
alias Mobilizon.Media
alias Mobilizon.Media.Picture
alias Mobilizon.Actors.Actor
alias Mobilizon.Events.Event, as: EventModel
alias Mobilizon.Service.ActivityPub.Converter
@behaviour Converter
@doc """
Converts an AP object data to our internal data structure
"""
@impl Converter
@spec as_to_model_data(map()) :: map()
def as_to_model_data(object) do
with {:ok, %Actor{id: actor_id}} <- Actors.get_actor_by_url(object["actor"]) do
picture_id =
with true <- Map.has_key?(object, "attachment"),
%Picture{id: picture_id} <-
Media.get_picture_by_url(
object["attachment"]
|> hd
|> Map.get("url")
|> hd
|> Map.get("href")
) do
picture_id
else
_ -> nil
end
%{
"title" => object["name"],
"description" => object["content"],
"organizer_actor_id" => actor_id,
"picture_id" => picture_id,
"begins_on" => object["begins_on"],
"category" => object["category"],
"url" => object["id"],
"uuid" => object["uuid"]
}
end
end
@doc """
Convert an event struct to an ActivityStream representation
"""
@impl Converter
@spec model_to_as(EventModel.t()) :: map()
def model_to_as(%EventModel{} = event) do
%{
"type" => "Event",
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
"title" => event.title,
"actor" => event.organizer_actor.url,
"uuid" => event.uuid,
"category" => event.category,
"summary" => event.description,
"publish_at" => (event.publish_at || event.inserted_at) |> DateTime.to_iso8601(),
"updated_at" => event.updated_at |> DateTime.to_iso8601(),
"id" => event.url
}
end
end

View file

@ -15,9 +15,9 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Events.Comment alias Mobilizon.Events.Comment
alias Mobilizon.Media.Picture
alias Mobilizon.Events alias Mobilizon.Events
alias Mobilizon.Activity alias Mobilizon.Activity
alias Mobilizon.Service.ActivityPub
alias Ecto.Changeset alias Ecto.Changeset
require Logger require Logger
alias MobilizonWeb.Router.Helpers, as: Routes alias MobilizonWeb.Router.Helpers, as: Routes
@ -108,23 +108,6 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
Map.put_new_lazy(map, "published", &make_date/0) Map.put_new_lazy(map, "published", &make_date/0)
end end
@doc """
Converts an AP object data to our internal data structure
"""
def object_to_event_data(object) do
{:ok, %Actor{id: actor_id}} = Actors.get_actor_by_url(object["actor"])
%{
"title" => object["name"],
"description" => object["content"],
"organizer_actor_id" => actor_id,
"begins_on" => object["begins_on"],
"category" => object["category"],
"url" => object["id"],
"uuid" => object["uuid"]
}
end
@doc """ @doc """
Inserts a full object if it is contained in an activity. Inserts a full object if it is contained in an activity.
""" """
@ -135,7 +118,8 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
""" """
def insert_full_object(%{"object" => %{"type" => "Event"} = object_data}) def insert_full_object(%{"object" => %{"type" => "Event"} = object_data})
when is_map(object_data) do when is_map(object_data) do
with object_data <- object_to_event_data(object_data), with object_data <-
Mobilizon.Service.ActivityPub.Converters.Event.as_to_model_data(object_data),
{:ok, _} <- Events.create_event(object_data) do {:ok, _} <- Events.create_event(object_data) do
:ok :ok
end end
@ -155,60 +139,14 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
""" """
def insert_full_object(%{"object" => %{"type" => "Note"} = object_data}) def insert_full_object(%{"object" => %{"type" => "Note"} = object_data})
when is_map(object_data) do when is_map(object_data) do
Logger.debug("Inserting full comment") with data <- Mobilizon.Service.ActivityPub.Converters.Comment.as_to_model_data(object_data),
Logger.debug(inspect(object_data)) {:ok, _comment} <- Events.create_comment(data) do
:ok
with {:ok, %Actor{id: actor_id}} <- Actors.get_or_fetch_by_url(object_data["actor"]) do else
data = %{ err ->
"text" => object_data["content"], Logger.error("Error while inserting a remote comment inside database")
"url" => object_data["id"], Logger.error(inspect(err))
"actor_id" => actor_id, {:error, err}
"in_reply_to_comment_id" => nil,
"event_id" => nil,
"uuid" => object_data["uuid"]
}
# We fetch the parent object
Logger.debug("We're fetching the parent object")
data =
if Map.has_key?(object_data, "inReplyTo") && object_data["inReplyTo"] != nil &&
object_data["inReplyTo"] != "" do
Logger.debug(fn -> "Object has inReplyTo #{object_data["inReplyTo"]}" end)
case ActivityPub.fetch_object_from_url(object_data["inReplyTo"]) do
# Reply to an event (Comment)
{:ok, %Event{id: id}} ->
Logger.debug("Parent object is an event")
data |> Map.put("event_id", id)
# Reply to a comment (Comment)
{:ok, %Comment{id: id} = comment} ->
Logger.debug("Parent object is another comment")
data
|> Map.put("in_reply_to_comment_id", id)
|> Map.put("origin_comment_id", comment |> Comment.get_thread_id())
# Anything else is kind of a MP
{:error, object} ->
Logger.debug("Parent object is something we don't handle")
Logger.debug(inspect(object))
data
end
else
Logger.debug("No parent object for this comment")
data
end
with {:ok, _comment} <- Events.create_comment(data) do
:ok
else
err ->
Logger.error("Error while inserting a remote comment inside database")
Logger.error(inspect(err))
{:error, err}
end
end end
end end
@ -238,6 +176,43 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
# Repo.one(query) # Repo.one(query)
# end # end
def make_picture_data(%Plug.Upload{} = picture) do
with {:ok, picture} <- MobilizonWeb.Upload.store(picture) do
picture
else
_ -> nil
end
end
def make_picture_data(%Picture{file: file} = _picture) do
%{
"type" => "Document",
"url" => [
%{
"type" => "Link",
"mediaType" => file.content_type,
"href" => file.url
}
],
"name" => file.name
}
end
def make_picture_data(%{picture: picture}) do
with {:ok, %{"url" => [%{"href" => url}]}} <- MobilizonWeb.Upload.store(picture.file),
{:ok, %Picture{file: _file} = pic} <-
Mobilizon.Media.create_picture(%{
"file" => %{
"url" => url,
"name" => picture.name
}
}) do
make_picture_data(pic)
end
end
def make_picture_data(nil), do: nil
@doc """ @doc """
Make an AP event object from an set of values Make an AP event object from an set of values
""" """
@ -246,6 +221,7 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
String.t(), String.t(),
String.t(), String.t(),
String.t(), String.t(),
map(),
list(), list(),
list(), list(),
map(), map(),
@ -256,7 +232,7 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
to, to,
title, title,
content_html, content_html,
# attachments, picture \\ nil,
tags \\ [], tags \\ [],
# _cw \\ nil, # _cw \\ nil,
cc \\ [], cc \\ [],
@ -266,14 +242,13 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
Logger.debug("Making event data") Logger.debug("Making event data")
uuid = Ecto.UUID.generate() uuid = Ecto.UUID.generate()
%{ res = %{
"type" => "Event", "type" => "Event",
"to" => to, "to" => to,
"cc" => cc, "cc" => cc,
"content" => content_html, "content" => content_html,
"name" => title, "name" => title,
# "summary" => cw, # "summary" => cw,
# "attachment" => attachments,
"begins_on" => metadata.begins_on, "begins_on" => metadata.begins_on,
"category" => category, "category" => category,
"actor" => actor, "actor" => actor,
@ -281,55 +256,8 @@ defmodule Mobilizon.Service.ActivityPub.Utils do
"uuid" => uuid, "uuid" => uuid,
"tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq() "tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq()
} }
end
@spec make_event_data(Event.t(), list(String.t())) :: map() if is_nil(picture), do: res, else: Map.put(res, "attachment", [make_picture_data(picture)])
def make_event_data(
%Event{} = event,
to \\ ["https://www.w3.org/ns/activitystreams#Public"]
) do
%{
"type" => "Event",
"to" => to,
"title" => event.title,
"actor" => event.organizer_actor.url,
"uuid" => event.uuid,
"category" => event.category,
"summary" => event.description,
"publish_at" => (event.publish_at || event.inserted_at) |> DateTime.to_iso8601(),
"updated_at" => event.updated_at |> DateTime.to_iso8601(),
"id" => Routes.page_url(Endpoint, :event, event.uuid)
}
end
@doc """
Make an AP comment object from an existing `Comment` structure.
"""
def make_comment_data(
%Comment{
text: text,
actor: actor,
uuid: uuid,
in_reply_to_comment: reply_to,
event: event
},
to \\ ["https://www.w3.org/ns/activitystreams#Public"]
) do
object = %{
"type" => "Note",
"to" => to,
"content" => text,
"actor" => actor.url,
"attributedTo" => actor.url,
"uuid" => uuid,
"id" => Routes.page_url(Endpoint, :comment, uuid)
}
if reply_to do
object |> Map.put("inReplyTo", reply_to.url || event.url)
else
object
end
end end
@doc """ @doc """

View file

@ -74,12 +74,19 @@ defmodule Mobilizon.Service.Export.Feed do
|> Feed.generator("Mobilizon", uri: "https://joinmobilizon.org", version: version()) |> Feed.generator("Mobilizon", uri: "https://joinmobilizon.org", version: version())
|> Feed.entries(Enum.map(events, &get_entry/1)) |> Feed.entries(Enum.map(events, &get_entry/1))
feed = if actor.avatar_url, do: Feed.icon(feed, actor.avatar_url), else: feed feed =
if actor.avatar do
Feed.icon(feed, actor.avatar.url)
else
feed
end
feed = feed =
if actor.banner_url, if actor.banner do
do: Feed.logo(feed, actor.banner_url), Feed.logo(feed, actor.banner.url)
else: feed else
feed
end
feed feed
|> Feed.build() |> Feed.build()
@ -113,7 +120,8 @@ defmodule Mobilizon.Service.Export.Feed do
@spec fetch_events_from_token(String.t()) :: String.t() @spec fetch_events_from_token(String.t()) :: String.t()
defp fetch_events_from_token(token) do defp fetch_events_from_token(token) do
with %FeedToken{actor: actor, user: %User{} = user} <- Events.get_feed_token(token) do with {:ok, _uuid} <- Ecto.UUID.cast(token),
%FeedToken{actor: actor, user: %User{} = user} <- Events.get_feed_token(token) do
case actor do case actor do
%Actor{} = actor -> %Actor{} = actor ->
events = fetch_identity_going_to_events(actor) events = fetch_identity_going_to_events(actor)

View file

@ -65,7 +65,7 @@ defmodule Mobilizon.Service.Formatter do
@link_regex ~r/[0-9a-z+\-\.]+:[0-9a-z$-_.+!*'(),]+/ui @link_regex ~r/[0-9a-z+\-\.]+:[0-9a-z$-_.+!*'(),]+/ui
@uri_schemes Application.get_env(:pleroma, :uri_schemes, []) @uri_schemes Application.get_env(:mobilizon, :uri_schemes, [])
@valid_schemes Keyword.get(@uri_schemes, :valid_schemes, []) @valid_schemes Keyword.get(@uri_schemes, :valid_schemes, [])
# # TODO: make it use something other than @link_regex # # TODO: make it use something other than @link_regex

View file

@ -3,14 +3,19 @@ defimpl Mobilizon.Service.Metadata, for: Mobilizon.Actors.Actor do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
def build_tags(%Actor{} = actor) do def build_tags(%Actor{} = actor) do
[ tags = [
Tag.tag(:meta, property: "og:title", content: Actor.display_name_and_username(actor)), Tag.tag(:meta, property: "og:title", content: Actor.display_name_and_username(actor)),
Tag.tag(:meta, property: "og:url", content: actor.url), Tag.tag(:meta, property: "og:url", content: actor.url),
Tag.tag(:meta, property: "og:description", content: actor.summary), Tag.tag(:meta, property: "og:description", content: actor.summary),
Tag.tag(:meta, property: "og:type", content: "profile"), Tag.tag(:meta, property: "og:type", content: "profile"),
Tag.tag(:meta, property: "profile:username", content: actor.preferred_username), Tag.tag(:meta, property: "profile:username", content: actor.preferred_username),
Tag.tag(:meta, property: "og:image", content: actor.avatar_url),
Tag.tag(:meta, property: "twitter:card", content: "summary") Tag.tag(:meta, property: "twitter:card", content: "summary")
] ]
if is_nil(actor.avatar) do
tags
else
tags ++ [Tag.tag(:meta, property: "og:image", content: actor.avatar.url)]
end
end end
end end

View file

@ -5,16 +5,28 @@ defimpl Mobilizon.Service.Metadata, for: Mobilizon.Events.Event do
alias MobilizonWeb.JsonLD.ObjectView alias MobilizonWeb.JsonLD.ObjectView
def build_tags(%Event{} = event) do def build_tags(%Event{} = event) do
[ tags = [
Tag.tag(:meta, property: "og:title", content: event.title), Tag.tag(:meta, property: "og:title", content: event.title),
Tag.tag(:meta, property: "og:url", content: event.url), Tag.tag(:meta, property: "og:url", content: event.url),
Tag.tag(:meta, property: "og:description", content: event.description), Tag.tag(:meta, property: "og:description", content: event.description),
Tag.tag(:meta, property: "og:type", content: "website"), Tag.tag(:meta, property: "og:type", content: "website")
Tag.tag(:meta, property: "og:image", content: event.thumbnail),
Tag.tag(:meta, property: "og:image", content: event.large_image),
Tag.tag(:meta, property: "twitter:card", content: "summary_large_image"),
~s{<script type="application/ld+json">#{json(event)}</script>} |> HTML.raw()
] ]
tags =
if is_nil(event.picture) do
tags
else
tags ++
[
Tag.tag(:meta, property: "og:image", content: event.picture.file.url)
]
end
tags ++
[
Tag.tag(:meta, property: "twitter:card", content: "summary_large_image"),
~s{<script type="application/ld+json">#{json(event)}</script>} |> HTML.raw()
]
end end
# Insert JSON-LD schema by hand because Tag.content_tag wants to escape it # Insert JSON-LD schema by hand because Tag.content_tag wants to escape it

View file

@ -84,13 +84,12 @@ defmodule Mobilizon.Mixfile do
{:absinthe_plug, "~> 1.4.6"}, {:absinthe_plug, "~> 1.4.6"},
{:absinthe_ecto, "~> 0.1.3"}, {:absinthe_ecto, "~> 0.1.3"},
{:dataloader, "~> 1.0.6"}, {:dataloader, "~> 1.0.6"},
{:arc, "~> 0.11.0"},
{:arc_ecto, "~> 0.11.0"},
{:plug_cowboy, "~> 2.0"}, {:plug_cowboy, "~> 2.0"},
{:atomex, "0.3.0"}, {:atomex, "0.3.0"},
{:cachex, "~> 3.1"}, {:cachex, "~> 3.1"},
{:earmark, "~> 1.3.1"}, {:earmark, "~> 1.3.1"},
{:geohax, "~> 0.3.0"}, {:geohax, "~> 0.3.0"},
{:mogrify, "~> 0.7.2"},
# Dev and test dependencies # Dev and test dependencies
{:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_reload, "~> 1.2", only: :dev},
{:ex_machina, "~> 2.3", only: [:dev, :test]}, {:ex_machina, "~> 2.3", only: [:dev, :test]},
@ -226,7 +225,6 @@ defmodule Mobilizon.Mixfile do
MobilizonWeb.Guardian.Plug, MobilizonWeb.Guardian.Plug,
MobilizonWeb.JsonLD.ObjectView, MobilizonWeb.JsonLD.ObjectView,
MobilizonWeb.PageController, MobilizonWeb.PageController,
MobilizonWeb.UploadPlug,
MobilizonWeb.Uploaders.Avatar, MobilizonWeb.Uploaders.Avatar,
MobilizonWeb.Uploaders.Category, MobilizonWeb.Uploaders.Category,
MobilizonWeb.Uploaders.Category.Type MobilizonWeb.Uploaders.Category.Type

View file

@ -1,11 +1,11 @@
%{ %{
"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": {: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_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.3", "cea34e7ebbc9a252038c1f1164878ee86bcb108905fe462be77efacda15c1e70", [: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.2", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.10.5 or ~> 2.11", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:poison, "~> 2.0 or ~> 3.0", [hex: :poison, 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.6", "ac5d2d3d02acf52fda0f151b294017ab06e2ed1c6c15334e06aac82c94e36e08", [: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"}, "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"},
"arc": {:hex, :arc, "0.11.0", "ac7a0cc03035317b6fef9fe94c97d7d9bd183a3e7ce1606aa0c175cfa8d1ba6d", [:mix], [{:ex_aws, "~> 2.0", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:ex_aws_s3, "~> 2.0", [hex: :ex_aws_s3, repo: "hexpm", optional: true]}, {:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.1", [hex: :poison, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.6", [hex: :sweet_xml, repo: "hexpm", optional: true]}], "hexpm"}, "arc": {:git, "https://github.com/tcitworld/arc.git", "f5788de02935bcbd38941d964b9bdd8833fe263d", []},
"arc_ecto": {:hex, :arc_ecto, "0.11.1", "27aedf8c236b2097eed09d96f4ae73b43eb4c042a0e2ae42d44bf644cf16115c", [:mix], [{:arc, "~> 0.11.0", [hex: :arc, repo: "hexpm", optional: false]}, {:ecto, "~> 2.1 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm"}, "arc_ecto": {:git, "https://github.com/tcitworld/arc_ecto.git", "e0d8db119c564744404cff68157417e2a83941af", []},
"argon2_elixir": {:hex, :argon2_elixir, "2.0.3", "f2272c89d6a84f85c22b9b83912fd60740bf6052bf0078e621a6e9d1127e25c8", [:make, :mix], [{:comeonin, "~> 5.1", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.5", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm"}, "argon2_elixir": {:hex, :argon2_elixir, "2.0.5", "0073a87d755c7e63fc4f9d08b1d1646585b93f144cecde126e15061b24240b20", [:make, :mix], [{:comeonin, "~> 5.1", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.5", [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"}, "atomex": {:hex, :atomex, "0.3.0", "19b5d1a2aef8706dbd307385f7d5d9f6f273869226d317492c396c7bacf26402", [:mix], [{:xml_builder, "~> 2.0.0", [hex: :xml_builder, repo: "hexpm", optional: false]}], "hexpm"},
"bamboo": {:hex, :bamboo, "1.2.0", "8aebd24f7c606c32d0163c398004a11608ca1028182a169b2e527793bfab7561", [:mix], [{:hackney, ">= 1.13.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, "bamboo": {:hex, :bamboo, "1.2.0", "8aebd24f7c606c32d0163c398004a11608ca1028182a169b2e527793bfab7561", [:mix], [{:hackney, ">= 1.13.0", [hex: :hackney, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, ">= 1.5.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"},
"bamboo_smtp": {:hex, :bamboo_smtp, "1.6.0", "0a3607b77f22554af58c547350c1c73ebba6f4fb2c4bd0b11713ab5b4081588f", [:mix], [{:bamboo, "~> 1.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 0.12.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}], "hexpm"}, "bamboo_smtp": {:hex, :bamboo_smtp, "1.6.0", "0a3607b77f22554af58c547350c1c73ebba6f4fb2c4bd0b11713ab5b4081588f", [:mix], [{:bamboo, "~> 1.0", [hex: :bamboo, repo: "hexpm", optional: false]}, {:gen_smtp, "~> 0.12.0", [hex: :gen_smtp, repo: "hexpm", optional: false]}], "hexpm"},
@ -25,7 +25,7 @@
"decimal": {:hex, :decimal, "1.7.0", "30d6b52c88541f9a66637359ddf85016df9eb266170d53105f02e4a67e00c5aa", [:mix], [], "hexpm"}, "decimal": {:hex, :decimal, "1.7.0", "30d6b52c88541f9a66637359ddf85016df9eb266170d53105f02e4a67e00c5aa", [:mix], [], "hexpm"},
"dialyxir": {:hex, :dialyxir, "1.0.0-rc.6", "78e97d9c0ff1b5521dd68041193891aebebce52fc3b93463c0a6806874557d7d", [:mix], [{:erlex, "~> 0.2.1", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm"}, "dialyxir": {:hex, :dialyxir, "1.0.0-rc.6", "78e97d9c0ff1b5521dd68041193891aebebce52fc3b93463c0a6806874557d7d", [:mix], [{:erlex, "~> 0.2.1", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm"},
"earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm"}, "earmark": {:hex, :earmark, "1.3.2", "b840562ea3d67795ffbb5bd88940b1bed0ed9fa32834915125ea7d02e35888a5", [:mix], [], "hexpm"},
"ecto": {:hex, :ecto, "3.0.8", "9eb6a1fcfc593e6619d45ef51afe607f1554c21ca188a1cd48eecc27223567f1", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"}, "ecto": {:hex, :ecto, "3.0.9", "f01922a0b91a41d764d4e3a914d7f058d99a03460d3082c61dd2dcadd724c934", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"},
"ecto_autoslug_field": {:hex, :ecto_autoslug_field, "1.0.0", "577eed25e6d045b8d783f82c9872f97c3a84017a4feae50eaf3cf4e1334a19e2", [:mix], [{:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:slugger, ">= 0.2.0", [hex: :slugger, repo: "hexpm", optional: false]}], "hexpm"}, "ecto_autoslug_field": {:hex, :ecto_autoslug_field, "1.0.0", "577eed25e6d045b8d783f82c9872f97c3a84017a4feae50eaf3cf4e1334a19e2", [: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.2.0", "9ead3ee04efc4cb68a50560a9d9ebb665dd697f957f1c3df8e81bf863cf7a4e9", [:mix], [{:ecto, ">= 2.0.0", [hex: :ecto, 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_enum": {:hex, :ecto_enum, "1.2.0", "9ead3ee04efc4cb68a50560a9d9ebb665dd697f957f1c3df8e81bf863cf7a4e9", [:mix], [{:ecto, ">= 2.0.0", [hex: :ecto, 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.0.5", "7e44172b4f7aca4469f38d7f6a3da394dbf43a1bcf0ca975e958cb957becd74e", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0.6", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.3.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, "ecto_sql": {:hex, :ecto_sql, "3.0.5", "7e44172b4f7aca4469f38d7f6a3da394dbf43a1bcf0ca975e958cb957becd74e", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0.6", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.3.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
@ -36,15 +36,16 @@
"ex_crypto": {:hex, :ex_crypto, "0.10.0", "af600a89b784b36613a989da6e998c1b200ff1214c3cfbaf8deca4aa2f0a1739", [:mix], [{:poison, ">= 2.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "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.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"}, "ex_doc": {:hex, :ex_doc, "0.20.2", "1bd0dfb0304bade58beb77f20f21ee3558cc3c753743ae0ddbb0fd7ba2912331", [:mix], [{:earmark, "~> 1.3", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [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_ical": {:hex, :ex_ical, "0.2.0", "4b928b554614704016cc0c9ee226eb854da9327a1cc460457621ceacb1ac29a6", [:mix], [{:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm"},
"ex_image_info": {:hex, :ex_image_info, "0.2.4", "610002acba43520a9b1cf1421d55812bde5b8a8aeaf1fe7b1f8823e84e762adb", [:mix], [], "hexpm"},
"ex_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [: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_machina": {:hex, :ex_machina, "2.3.0", "92a5ad0a8b10ea6314b876a99c8c9e3f25f4dde71a2a835845b136b9adaf199a", [: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_unit_notifier": {:hex, :ex_unit_notifier, "0.1.4", "36a2dcab829f506e01bf17816590680dd1474407926d43e64c1263e627c364b8", [:mix], [], "hexpm"}, "ex_unit_notifier": {:hex, :ex_unit_notifier, "0.1.4", "36a2dcab829f506e01bf17816590680dd1474407926d43e64c1263e627c364b8", [:mix], [], "hexpm"},
"exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm"}, "exactor": {:hex, :exactor, "2.2.4", "5efb4ddeb2c48d9a1d7c9b465a6fffdd82300eb9618ece5d34c3334d5d7245b1", [:mix], [], "hexpm"},
"excoveralls": {:hex, :excoveralls, "0.10.6", "e2b9718c9d8e3ef90bc22278c3f76c850a9f9116faf4ebe9678063310742edc2", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"}, "excoveralls": {:hex, :excoveralls, "0.11.1", "dd677fbdd49114fdbdbf445540ec735808250d56b011077798316505064edb2c", [: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.1", "66d595c7d63dd6bbac442c5542a724375ae29144059c6fe093e61553850aace4", [:mix], [], "hexpm"}, "exgravatar": {:hex, :exgravatar, "2.0.1", "66d595c7d63dd6bbac442c5542a724375ae29144059c6fe093e61553850aace4", [:mix], [], "hexpm"},
"exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"}, "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"},
"exvcr": {:hex, :exvcr, "0.10.3", "1ae3b97560430acfa88ebc737c85b2b7a9dbacd8a2b26789a19718b51ae3522c", [: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"}, "exvcr": {:hex, :exvcr, "0.10.3", "1ae3b97560430acfa88ebc737c85b2b7a9dbacd8a2b26789a19718b51ae3522c", [: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"},
"feeder": {:hex, :feeder, "2.2.4", "56ec535cf2f79719bc53b5c2abe5f6cf481fc01e5ae6229ab7cc829644f039ec", [:make], [], "hexpm"}, "feeder": {:hex, :feeder, "2.2.4", "56ec535cf2f79719bc53b5c2abe5f6cf481fc01e5ae6229ab7cc829644f039ec", [:make], [], "hexpm"},
"file_system": {:hex, :file_system, "0.2.6", "fd4dc3af89b9ab1dc8ccbcc214a0e60c41f34be251d9307920748a14bf41f1d3", [:mix], [], "hexpm"}, "file_system": {:hex, :file_system, "0.2.7", "e6f7f155970975789f26e77b8b8d8ab084c59844d8ecfaf58cbda31c494d14aa", [:mix], [], "hexpm"},
"gen_smtp": {:hex, :gen_smtp, "0.12.0", "97d44903f5ca18ca85cb39aee7d9c77e98d79804bbdef56078adcf905cb2ef00", [:rebar3], [], "hexpm"}, "gen_smtp": {:hex, :gen_smtp, "0.12.0", "97d44903f5ca18ca85cb39aee7d9c77e98d79804bbdef56078adcf905cb2ef00", [:rebar3], [], "hexpm"},
"geo": {:hex, :geo, "3.1.0", "727e005262430d037e870ff364e65d80ca5ca21d5ac8eddd57a1ada72c3f83b0", [:mix], [], "hexpm"}, "geo": {:hex, :geo, "3.1.0", "727e005262430d037e870ff364e65d80ca5ca21d5ac8eddd57a1ada72c3f83b0", [:mix], [], "hexpm"},
"geo_postgis": {:hex, :geo_postgis, "3.1.0", "d06c8fa5fd140a52a5c9dab4ad6623a696dd7d99dd791bb361d3f94942442ff9", [:mix], [{:geo, "~> 3.1", [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"}, "geo_postgis": {:hex, :geo_postgis, "3.1.0", "d06c8fa5fd140a52a5c9dab4ad6623a696dd7d99dd791bb361d3f94942442ff9", [:mix], [{:geo, "~> 3.1", [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"},
@ -72,19 +73,20 @@
"mix_test_watch": {:hex, :mix_test_watch, "0.9.0", "c72132a6071261893518fa08e121e911c9358713f62794a90c95db59042af375", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm"}, "mix_test_watch": {:hex, :mix_test_watch, "0.9.0", "c72132a6071261893518fa08e121e911c9358713f62794a90c95db59042af375", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm"},
"mmdb2_decoder": {:hex, :mmdb2_decoder, "1.0.0", "48929cdadae9dd5a9705133dff024763774a2615f35354912832b98e72261110", [:mix], [], "hexpm"}, "mmdb2_decoder": {:hex, :mmdb2_decoder, "1.0.0", "48929cdadae9dd5a9705133dff024763774a2615f35354912832b98e72261110", [:mix], [], "hexpm"},
"mock": {:hex, :mock, "0.3.3", "42a433794b1291a9cf1525c6d26b38e039e0d3a360732b5e467bfc77ef26c914", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"}, "mock": {:hex, :mock, "0.3.3", "42a433794b1291a9cf1525c6d26b38e039e0d3a360732b5e467bfc77ef26c914", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm"},
"mogrify": {:hex, :mogrify, "0.7.2", "4d00b60288e338028e2af4cccff9b0da365d83b7e5da52e58fb2de513ef5fedd", [:mix], [], "hexpm"},
"nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"}, "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"},
"parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm"},
"phoenix": {:hex, :phoenix, "1.4.3", "8eed4a64ff1e12372cd634724bddd69185938f52c18e1396ebac76375d85677d", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"}, "phoenix": {:hex, :phoenix, "1.4.6", "8535f4a01291f0fbc2c30c78c4ca6a2eacc148db5178ad76e8b2fc976c590115", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [: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_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [: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.13.2", "f5d27c9b10ce881a60177d2b5227314fc60881e6b66b41dfe3349db6ed06cf57", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "phoenix_html": {:hex, :phoenix_html, "2.13.2", "f5d27c9b10ce881a60177d2b5227314fc60881e6b66b41dfe3349db6ed06cf57", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.0", "3bb31a9fbd40ffe8652e60c8660dffd72dd231efcdf49b744fb75b9ef7db5dd2", [: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_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"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.2", "496c303bdf1b2e98a9d26e89af5bba3ab487ba3a3735f74bf1f4064d2a845a3e", [:mix], [], "hexpm"},
"plug": {:hex, :plug, "1.7.2", "d7b7db7fbd755e8283b6c0a50be71ec0a3d67d9213d74422d9372effc8e87fd1", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"}, "plug": {:hex, :plug, "1.7.2", "d7b7db7fbd755e8283b6c0a50be71ec0a3d67d9213d74422d9372effc8e87fd1", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"},
"plug_cowboy": {:hex, :plug_cowboy, "2.0.2", "6055f16868cc4882b24b6e1d63d2bada94fb4978413377a3b32ac16c18dffba2", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "plug_cowboy": {:hex, :plug_cowboy, "2.0.2", "6055f16868cc4882b24b6e1d63d2bada94fb4978413377a3b32ac16c18dffba2", [: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.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"},
"poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"},
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"}, "poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm"},
"postgrex": {:hex, :postgrex, "0.14.2", "6680591bbce28d92f043249205e8b01b36cab9ef2a7911abc43649242e1a3b78", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [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"}, "postgrex": {:hex, :postgrex, "0.14.3", "5754dee2fdf6e9e508cbf49ab138df964278700b764177e8f3871e658b345a1e", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [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"},
"ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"},
"rdf": {:hex, :rdf, "0.6.0", "e0d9098c157b91b9e7318a2fca18cc2e2b178e1290c5cfbb014cf2077c4aa778", [:mix], [{:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"}, "rdf": {:hex, :rdf, "0.6.0", "e0d9098c157b91b9e7318a2fca18cc2e2b178e1290c5cfbb014cf2077c4aa778", [:mix], [{:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"},
"rsa_ex": {:hex, :rsa_ex, "0.4.0", "e28dd7dc5236e156df434af0e4aa822384c8866c928e17b785d4edb7c253b558", [:mix], [], "hexpm"}, "rsa_ex": {:hex, :rsa_ex, "0.4.0", "e28dd7dc5236e156df434af0e4aa822384c8866c928e17b785d4edb7c253b558", [:mix], [], "hexpm"},

View file

@ -0,0 +1,41 @@
defmodule Mobilizon.Repo.Migrations.CreatePictures do
use Ecto.Migration
def up do
create table(:pictures) do
add(:file, :map)
timestamps()
end
alter table(:actors) do
remove(:avatar_url)
remove(:banner_url)
add(:avatar, :map)
add(:banner, :map)
end
alter table(:events) do
remove(:thumbnail)
remove(:large_image)
add(:picture_id, references(:pictures, on_delete: :delete_all))
end
end
def down do
alter table(:actors) do
add(:avatar_url, :string)
add(:banner_url, :string)
remove(:avatar)
remove(:banner)
end
alter table(:events) do
add(:large_image, :string)
add(:thumbnail, :string)
remove(:picture_id)
end
drop(table(:pictures))
end
end

View file

@ -1,5 +1,5 @@
# source: http://localhost:4001/api # source: http://localhost:4000/api
# timestamp: Fri Apr 26 2019 14:47:01 GMT+0200 (heure dété dEurope centrale) # timestamp: Thu May 02 2019 16:24:47 GMT+0200 (GMT+02:00)
schema { schema {
query: RootQueryType query: RootQueryType
@ -8,11 +8,11 @@ schema {
"""An ActivityPub actor""" """An ActivityPub actor"""
interface Actor { interface Actor {
"""The actor's avatar url""" """The actor's avatar picture"""
avatarUrl: String avatar: Picture
"""The actor's banner url""" """The actor's banner picture"""
bannerUrl: String banner: Picture
"""The actor's domain if (null if it's this instance)""" """The actor's domain if (null if it's this instance)"""
domain: String domain: String
@ -193,9 +193,6 @@ type Event {
"""Internal ID for this event""" """Internal ID for this event"""
id: Int id: Int
"""A large picture for the event"""
largeImage: String
"""Whether the event is local or not""" """Whether the event is local or not"""
local: Boolean local: Boolean
@ -214,6 +211,9 @@ type Event {
"""The type of the event's address""" """The type of the event's address"""
physicalAddress: Address physicalAddress: Address
"""The event's picture"""
picture: Picture
"""When the event was published""" """When the event was published"""
publishAt: DateTime publishAt: DateTime
@ -229,9 +229,6 @@ type Event {
"""The event's tags""" """The event's tags"""
tags: [Tag] tags: [Tag]
"""A thumbnail picture for the event"""
thumbnail: String
"""The event's title""" """The event's title"""
title: String title: String
@ -319,11 +316,11 @@ Represents a group of actors
""" """
type Group implements Actor { type Group implements Actor {
"""The actor's avatar url""" """The actor's avatar picture"""
avatarUrl: String avatar: Picture
"""The actor's banner url""" """The actor's banner picture"""
bannerUrl: String banner: Picture
"""The actor's domain if (null if it's this instance)""" """The actor's domain if (null if it's this instance)"""
domain: String domain: String
@ -465,11 +462,11 @@ Represents a person identity
""" """
type Person implements Actor { type Person implements Actor {
"""The actor's avatar url""" """The actor's avatar picture"""
avatarUrl: String avatar: Picture
"""The actor's banner url""" """The actor's banner picture"""
bannerUrl: String banner: Picture
"""The actor's domain if (null if it's this instance)""" """The actor's domain if (null if it's this instance)"""
domain: String domain: String
@ -546,6 +543,37 @@ type PhoneAddress {
phone: String phone: String
} }
"""A picture"""
type Picture {
"""The picture's alternative text"""
alt: String
"""The picture's UUID"""
id: UUID
"""The picture's name"""
name: String
"""The picture's full thumbnail URL"""
thumbnailUrl: String
"""The picture's full URL"""
url: String
}
"""An attached picture or a link to a picture"""
input PictureInput {
picture: PictureInputObject
pictureId: String
}
"""An attached picture"""
input PictureInputObject {
alt: String
file: Upload!
name: String!
}
""" """
The `Point` scalar type represents Point geographic information compliant string data, The `Point` scalar type represents Point geographic information compliant string data,
represented as floats separated by a semi-colon. The geodetic system is WGS 84 represented as floats separated by a semi-colon. The geodetic system is WGS 84
@ -560,7 +588,25 @@ type RootMutationType {
createComment(actorUsername: String!, text: String!): Comment createComment(actorUsername: String!, text: String!): Comment
"""Create an event""" """Create an event"""
createEvent(beginsOn: DateTime!, category: String!, description: String!, endsOn: DateTime, largeImage: String, onlineAddress: String, organizerActorId: ID!, phoneAddress: String, public: Boolean, publishAt: DateTime, state: Int, status: Int, thumbnail: String, title: String!): Event createEvent(
beginsOn: DateTime!
category: String!
description: String!
endsOn: DateTime
onlineAddress: String
organizerActorId: ID!
phoneAddress: String
"""
The picture for the event, either as an object or directly the ID of an existing Picture
"""
picture: PictureInput
public: Boolean
publishAt: DateTime
state: Int
status: Int
title: String!
): Event
"""Create a Feed Token""" """Create a Feed Token"""
createFeedToken(actorId: Int): FeedToken createFeedToken(actorId: Int): FeedToken
@ -572,6 +618,16 @@ type RootMutationType {
""" """
adminActorUsername: String adminActorUsername: String
"""
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 summary for the group""" """The summary for the group"""
description: String = "" description: String = ""
@ -584,6 +640,16 @@ type RootMutationType {
"""Create a new person for user""" """Create a new person for user"""
createPerson( createPerson(
"""
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 displayed name for the new profile""" """The displayed name for the new profile"""
name: String = "" name: String = ""
preferredUsername: String! preferredUsername: String!
@ -621,6 +687,16 @@ type RootMutationType {
"""Register a first profile on registration""" """Register a first profile on registration"""
registerPerson( 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""" """The email from the user previously created"""
email: String! email: String!
@ -641,6 +717,9 @@ type RootMutationType {
"""Send a link through email to reset user password""" """Send a link through email to reset user password"""
sendResetPassword(email: String!, locale: String = "en"): String sendResetPassword(email: String!, locale: String = "en"): String
"""Upload a picture"""
uploadPicture(alt: String, file: Upload!, name: String!): Picture
"""Validate an user after registration""" """Validate an user after registration"""
validateUser(token: String!): Login validateUser(token: String!): Login
} }
@ -680,6 +759,9 @@ type RootQueryType {
"""Get a person by it's preferred username""" """Get a person by it's preferred username"""
person(preferredUsername: String!): Person person(preferredUsername: String!): Person
"""Get a picture"""
picture(id: String!): Picture
"""Reverse geocode coordinates""" """Reverse geocode coordinates"""
reverseGeocode(latitude: Float!, longitude: Float!): [Address] reverseGeocode(latitude: Float!, longitude: Float!): [Address]
@ -731,6 +813,12 @@ type Tag {
title: String title: String
} }
"""
Represents an uploaded file.
"""
scalar Upload
"""A local user of Mobilizon""" """A local user of Mobilizon"""
type User { type User {
"""The datetime the last activation/confirmation token was sent""" """The datetime the last activation/confirmation token was sent"""

BIN
test/fixtures/image.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -20,7 +20,16 @@
"endpoints": { "endpoints": {
"sharedInbox": "http://mastodon.example.org/inbox" "sharedInbox": "http://mastodon.example.org/inbox"
}, },
"icon":{"type":"Image","mediaType":"image/jpeg","url":"https://cd.niu.moe/accounts/avatars/000/033/323/original/fd7f8ae0b3ffedc9.jpeg"},"image":{"type":"Image","mediaType":"image/png","url":"https://cd.niu.moe/accounts/headers/000/033/323/original/850b3448fa5fd477.png"} "icon":{
"type":"Image",
"mediaType":"image/png",
"url":"https://files.mastodon.social/accounts/avatars/000/000/001/original/a285c086605e4182.png"
},
"image":{
"type":"Image",
"mediaType":"image/png",
"url":"https://files.mastodon.social/accounts/headers/000/000/001/original/c91b871f294ea63e.png"
}
}, },
"id": "http://mastodon.example.org/users/gargron#updates/1519563538", "id": "http://mastodon.example.org/users/gargron#updates/1519563538",
"actor": "http://mastodon.example.org/users/gargron", "actor": "http://mastodon.example.org/users/gargron",

View file

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

1
test/fixtures/test.txt vendored Normal file
View file

@ -0,0 +1 @@
this is a text file

1
test/fixtures/test_tmp.txt vendored Normal file
View file

@ -0,0 +1 @@
this is a text file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,156 +0,0 @@
[
{
"request": {
"body": "",
"headers": {
"Accept": "application/activity+json"
},
"method": "get",
"options": {
"follow_redirect": "true",
"recv_timeout": 20000,
"connect_timeout": 10000
},
"request_body": "",
"url": "https://social.tcit.fr/users/tcit/statuses/101160654038714030"
},
"response": {
"binary": false,
"body": "{\"@context\":[\"https://www.w3.org/ns/activitystreams\",\"https://w3id.org/security/v1\",{\"manuallyApprovesFollowers\":\"as:manuallyApprovesFollowers\",\"sensitive\":\"as:sensitive\",\"movedTo\":{\"@id\":\"as:movedTo\",\"@type\":\"@id\"},\"Hashtag\":\"as:Hashtag\",\"ostatus\":\"http://ostatus.org#\",\"atomUri\":\"ostatus:atomUri\",\"inReplyToAtomUri\":\"ostatus:inReplyToAtomUri\",\"conversation\":\"ostatus:conversation\",\"toot\":\"http://joinmastodon.org/ns#\",\"Emoji\":\"toot:Emoji\",\"focalPoint\":{\"@container\":\"@list\",\"@id\":\"toot:focalPoint\"},\"featured\":{\"@id\":\"toot:featured\",\"@type\":\"@id\"},\"schema\":\"http://schema.org#\",\"PropertyValue\":\"schema:PropertyValue\",\"value\":\"schema:value\"}],\"id\":\"https://social.tcit.fr/users/tcit/statuses/101160654038714030\",\"type\":\"Note\",\"summary\":null,\"inReplyTo\":\"https://social.tcit.fr/users/tcit/statuses/101160195754333819\",\"published\":\"2018-11-30T14:44:41Z\",\"url\":\"https://social.tcit.fr/@tcit/101160654038714030\",\"attributedTo\":\"https://social.tcit.fr/users/tcit\",\"to\":[\"https://www.w3.org/ns/activitystreams#Public\"],\"cc\":[\"https://social.tcit.fr/users/tcit/followers\"],\"sensitive\":false,\"atomUri\":\"https://social.tcit.fr/users/tcit/statuses/101160654038714030\",\"inReplyToAtomUri\":\"https://social.tcit.fr/users/tcit/statuses/101160195754333819\",\"conversation\":\"tag:social.tcit.fr,2018-11-30:objectId=3642669:objectType=Conversation\",\"content\":\"\\u003cp\\u003eOkay so that\\u0026apos;s it.\\u003cbr /\\u003e\\u003ca href=\\\"https://tcit.frama.io/group-uri-scheme/draft-tcit-group-uri-01.txt\\\" rel=\\\"nofollow noopener\\\" target=\\\"_blank\\\"\\u003e\\u003cspan class=\\\"invisible\\\"\\u003ehttps://\\u003c/span\\u003e\\u003cspan class=\\\"ellipsis\\\"\\u003etcit.frama.io/group-uri-scheme\\u003c/span\\u003e\\u003cspan class=\\\"invisible\\\"\\u003e/draft-tcit-group-uri-01.txt\\u003c/span\\u003e\\u003c/a\\u003e\\u003c/p\\u003e\",\"contentMap\":{\"fr\":\"\\u003cp\\u003eOkay so that\\u0026apos;s it.\\u003cbr /\\u003e\\u003ca href=\\\"https://tcit.frama.io/group-uri-scheme/draft-tcit-group-uri-01.txt\\\" rel=\\\"nofollow noopener\\\" target=\\\"_blank\\\"\\u003e\\u003cspan class=\\\"invisible\\\"\\u003ehttps://\\u003c/span\\u003e\\u003cspan class=\\\"ellipsis\\\"\\u003etcit.frama.io/group-uri-scheme\\u003c/span\\u003e\\u003cspan class=\\\"invisible\\\"\\u003e/draft-tcit-group-uri-01.txt\\u003c/span\\u003e\\u003c/a\\u003e\\u003c/p\\u003e\"},\"attachment\":[],\"tag\":[]}",
"headers": {
"Date": "Tue, 04 Dec 2018 13:59:58 GMT",
"Content-Type": "application/activity+json; charset=utf-8",
"Transfer-Encoding": "chunked",
"Connection": "keep-alive",
"Server": "Mastodon",
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"X-XSS-Protection": "1; mode=block",
"Link": "<https://social.tcit.fr/users/tcit/updates/15979.atom>; rel=\"alternate\"; type=\"application/atom+xml\", <https://social.tcit.fr/users/tcit/statuses/101160654038714030>; rel=\"alternate\"; type=\"application/activity+json\"",
"Vary": "Accept,Accept-Encoding",
"Cache-Control": "max-age=180, public",
"ETag": "W/\"619af54f65bbb41538e430b8247c36d7\"",
"X-Request-Id": "84a750de-2dfa-4a36-976e-bae0b0ac4821",
"X-Runtime": "0.056423",
"X-Cached": "MISS"
},
"status_code": 200,
"type": "ok"
}
},
{
"request": {
"body": "",
"headers": {
"Accept": "application/activity+json"
},
"method": "get",
"options": {
"follow_redirect": "true"
},
"request_body": "",
"url": "https://social.tcit.fr/users/tcit"
},
"response": {
"binary": false,
"body": "{\"@context\":[\"https://www.w3.org/ns/activitystreams\",\"https://w3id.org/security/v1\",{\"manuallyApprovesFollowers\":\"as:manuallyApprovesFollowers\",\"sensitive\":\"as:sensitive\",\"movedTo\":{\"@id\":\"as:movedTo\",\"@type\":\"@id\"},\"Hashtag\":\"as:Hashtag\",\"ostatus\":\"http://ostatus.org#\",\"atomUri\":\"ostatus:atomUri\",\"inReplyToAtomUri\":\"ostatus:inReplyToAtomUri\",\"conversation\":\"ostatus:conversation\",\"toot\":\"http://joinmastodon.org/ns#\",\"Emoji\":\"toot:Emoji\",\"focalPoint\":{\"@container\":\"@list\",\"@id\":\"toot:focalPoint\"},\"featured\":{\"@id\":\"toot:featured\",\"@type\":\"@id\"},\"schema\":\"http://schema.org#\",\"PropertyValue\":\"schema:PropertyValue\",\"value\":\"schema:value\"}],\"id\":\"https://social.tcit.fr/users/tcit\",\"type\":\"Person\",\"following\":\"https://social.tcit.fr/users/tcit/following\",\"followers\":\"https://social.tcit.fr/users/tcit/followers\",\"inbox\":\"https://social.tcit.fr/users/tcit/inbox\",\"outbox\":\"https://social.tcit.fr/users/tcit/outbox\",\"featured\":\"https://social.tcit.fr/users/tcit/collections/featured\",\"preferredUsername\":\"tcit\",\"name\":\"🦄 Thomas Citharel\",\"summary\":\"\\u003cp\\u003eHoping to make people\\u0026apos;s life better with free software at \\u003cspan class=\\\"h-card\\\"\\u003e\\u003ca href=\\\"https://framapiaf.org/@Framasoft\\\" class=\\\"u-url mention\\\"\\u003e@\\u003cspan\\u003eFramasoft\\u003c/span\\u003e\\u003c/a\\u003e\\u003c/span\\u003e.\\u003c/p\\u003e\",\"url\":\"https://social.tcit.fr/@tcit\",\"manuallyApprovesFollowers\":false,\"publicKey\":{\"id\":\"https://social.tcit.fr/users/tcit#main-key\",\"owner\":\"https://social.tcit.fr/users/tcit\",\"publicKeyPem\":\"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApXwYMUdFg3XUd+bGsh8C\\nyiMRGpRGAWuCdM5pDWx5uM4pW2pM3xbHbcI21j9h8BmlAiPg6hbZD73KGly2N8Rt\\n5iIS0I+l6i8kA1JCCdlAaDTRd41RKMggZDoQvjVZQtsyE1VzMeU2kbqqTFN6ew7H\\nvbd6O0NhixoKoZ5f3jwuBDZoT0p1TAcaMdmG8oqHD97isizkDnRn8cOBA6wtI+xb\\n5xP2zxZMsLpTDZLiKU8XcPKZCw4OfQfmDmKkHtrFb77jCAQj/s/FxjVnvxRwmfhN\\nnWy0D+LUV/g63nHh/b5zXIeV92QZLvDYbgbezmzUzv9UeA1s70GGbaDqCIy85gw9\\n+wIDAQAB\\n-----END PUBLIC KEY-----\\n\"},\"tag\":[],\"attachment\":[{\"type\":\"PropertyValue\",\"name\":\"Works at\",\"value\":\"\\u003cspan class=\\\"h-card\\\"\\u003e\\u003ca href=\\\"https://framapiaf.org/@Framasoft\\\" class=\\\"u-url mention\\\"\\u003e@\\u003cspan\\u003eFramasoft\\u003c/span\\u003e\\u003c/a\\u003e\\u003c/span\\u003e\"},{\"type\":\"PropertyValue\",\"name\":\"Pronouns\",\"value\":\"He/Him\"},{\"type\":\"PropertyValue\",\"name\":\"Work Account\",\"value\":\"\\u003ca href=\\\"https://framapiaf.org/@tcit\\\" rel=\\\"me nofollow noopener\\\" target=\\\"_blank\\\"\\u003e\\u003cspan class=\\\"invisible\\\"\\u003ehttps://\\u003c/span\\u003e\\u003cspan class=\\\"\\\"\\u003eframapiaf.org/@tcit\\u003c/span\\u003e\\u003cspan class=\\\"invisible\\\"\\u003e\\u003c/span\\u003e\\u003c/a\\u003e\"},{\"type\":\"PropertyValue\",\"name\":\"Site\",\"value\":\"\\u003ca href=\\\"https://tcit.fr\\\" rel=\\\"me nofollow noopener\\\" target=\\\"_blank\\\"\\u003e\\u003cspan class=\\\"invisible\\\"\\u003ehttps://\\u003c/span\\u003e\\u003cspan class=\\\"\\\"\\u003etcit.fr\\u003c/span\\u003e\\u003cspan class=\\\"invisible\\\"\\u003e\\u003c/span\\u003e\\u003c/a\\u003e\"}],\"endpoints\":{\"sharedInbox\":\"https://social.tcit.fr/inbox\"},\"icon\":{\"type\":\"Image\",\"mediaType\":\"image/jpeg\",\"url\":\"https://media.social.tcit.fr/mastodontcit/accounts/avatars/000/000/001/original/a28c50ce5f2b13fd.jpg\"},\"image\":{\"type\":\"Image\",\"mediaType\":\"image/jpeg\",\"url\":\"https://media.social.tcit.fr/mastodontcit/accounts/headers/000/000/001/original/4d1ab77c20265ee9.jpg\"}}",
"headers": {
"Date": "Tue, 04 Dec 2018 13:59:58 GMT",
"Content-Type": "application/activity+json; charset=utf-8",
"Transfer-Encoding": "chunked",
"Connection": "keep-alive",
"Server": "Mastodon",
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"X-XSS-Protection": "1; mode=block",
"Link": "<https://social.tcit.fr/.well-known/webfinger?resource=acct%3Atcit%40social.tcit.fr>; rel=\"lrdd\"; type=\"application/xrd+xml\", <https://social.tcit.fr/users/tcit.atom>; rel=\"alternate\"; type=\"application/atom+xml\", <https://social.tcit.fr/users/tcit>; rel=\"alternate\"; type=\"application/activity+json\"",
"Vary": "Accept,Accept-Encoding",
"Cache-Control": "max-age=180, public",
"ETag": "W/\"039b9e136f81a55656fb1f38a23640d2\"",
"X-Request-Id": "91a50164-aa87-45c9-8100-786b9c74fbe0",
"X-Runtime": "0.039489",
"X-Cached": "MISS"
},
"status_code": 200,
"type": "ok"
}
},
{
"request": {
"body": "",
"headers": {
"Accept": "application/activity+json"
},
"method": "get",
"options": {
"follow_redirect": "true",
"recv_timeout": 20000,
"connect_timeout": 10000
},
"request_body": "",
"url": "https://social.tcit.fr/users/tcit/statuses/101160195754333819"
},
"response": {
"binary": false,
"body": "{\"@context\":[\"https://www.w3.org/ns/activitystreams\",\"https://w3id.org/security/v1\",{\"manuallyApprovesFollowers\":\"as:manuallyApprovesFollowers\",\"sensitive\":\"as:sensitive\",\"movedTo\":{\"@id\":\"as:movedTo\",\"@type\":\"@id\"},\"Hashtag\":\"as:Hashtag\",\"ostatus\":\"http://ostatus.org#\",\"atomUri\":\"ostatus:atomUri\",\"inReplyToAtomUri\":\"ostatus:inReplyToAtomUri\",\"conversation\":\"ostatus:conversation\",\"toot\":\"http://joinmastodon.org/ns#\",\"Emoji\":\"toot:Emoji\",\"focalPoint\":{\"@container\":\"@list\",\"@id\":\"toot:focalPoint\"},\"featured\":{\"@id\":\"toot:featured\",\"@type\":\"@id\"},\"schema\":\"http://schema.org#\",\"PropertyValue\":\"schema:PropertyValue\",\"value\":\"schema:value\"}],\"id\":\"https://social.tcit.fr/users/tcit/statuses/101160195754333819\",\"type\":\"Note\",\"summary\":null,\"inReplyTo\":\"https://social.tcit.fr/users/tcit/statuses/101159468934977010\",\"published\":\"2018-11-30T12:48:08Z\",\"url\":\"https://social.tcit.fr/@tcit/101160195754333819\",\"attributedTo\":\"https://social.tcit.fr/users/tcit\",\"to\":[\"https://www.w3.org/ns/activitystreams#Public\"],\"cc\":[\"https://social.tcit.fr/users/tcit/followers\"],\"sensitive\":false,\"atomUri\":\"https://social.tcit.fr/users/tcit/statuses/101160195754333819\",\"inReplyToAtomUri\":\"https://social.tcit.fr/users/tcit/statuses/101159468934977010\",\"conversation\":\"tag:social.tcit.fr,2018-11-30:objectId=3642669:objectType=Conversation\",\"content\":\"\\u003cp\\u003eOkay so YOLO.\\u003c/p\\u003e\",\"contentMap\":{\"fr\":\"\\u003cp\\u003eOkay so YOLO.\\u003c/p\\u003e\"},\"attachment\":[{\"type\":\"Document\",\"mediaType\":\"image/png\",\"url\":\"https://media.social.tcit.fr/mastodontcit/media_attachments/files/000/718/393/original/b56706a78fd355b8.png\",\"name\":\"Start of a 'group' URI RFC\"}],\"tag\":[]}",
"headers": {
"Date": "Tue, 04 Dec 2018 13:59:58 GMT",
"Content-Type": "application/activity+json; charset=utf-8",
"Transfer-Encoding": "chunked",
"Connection": "keep-alive",
"Server": "Mastodon",
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"X-XSS-Protection": "1; mode=block",
"Link": "<https://social.tcit.fr/users/tcit/updates/15967.atom>; rel=\"alternate\"; type=\"application/atom+xml\", <https://social.tcit.fr/users/tcit/statuses/101160195754333819>; rel=\"alternate\"; type=\"application/activity+json\"",
"Vary": "Accept,Accept-Encoding",
"Cache-Control": "max-age=180, public",
"ETag": "W/\"e878d9ab8dfa31073b27b4661046b911\"",
"X-Request-Id": "b598d538-88b5-4d7a-867c-d78b85ee5677",
"X-Runtime": "0.078823",
"X-Cached": "MISS"
},
"status_code": 200,
"type": "ok"
}
},
{
"request": {
"body": "",
"headers": {
"Accept": "application/activity+json"
},
"method": "get",
"options": {
"follow_redirect": "true",
"recv_timeout": 20000,
"connect_timeout": 10000
},
"request_body": "",
"url": "https://social.tcit.fr/users/tcit/statuses/101159468934977010"
},
"response": {
"binary": false,
"body": "{\"@context\":[\"https://www.w3.org/ns/activitystreams\",\"https://w3id.org/security/v1\",{\"manuallyApprovesFollowers\":\"as:manuallyApprovesFollowers\",\"sensitive\":\"as:sensitive\",\"movedTo\":{\"@id\":\"as:movedTo\",\"@type\":\"@id\"},\"Hashtag\":\"as:Hashtag\",\"ostatus\":\"http://ostatus.org#\",\"atomUri\":\"ostatus:atomUri\",\"inReplyToAtomUri\":\"ostatus:inReplyToAtomUri\",\"conversation\":\"ostatus:conversation\",\"toot\":\"http://joinmastodon.org/ns#\",\"Emoji\":\"toot:Emoji\",\"focalPoint\":{\"@container\":\"@list\",\"@id\":\"toot:focalPoint\"},\"featured\":{\"@id\":\"toot:featured\",\"@type\":\"@id\"},\"schema\":\"http://schema.org#\",\"PropertyValue\":\"schema:PropertyValue\",\"value\":\"schema:value\"}],\"id\":\"https://social.tcit.fr/users/tcit/statuses/101159468934977010\",\"type\":\"Note\",\"summary\":null,\"inReplyTo\":null,\"published\":\"2018-11-30T09:43:18Z\",\"url\":\"https://social.tcit.fr/@tcit/101159468934977010\",\"attributedTo\":\"https://social.tcit.fr/users/tcit\",\"to\":[\"https://www.w3.org/ns/activitystreams#Public\"],\"cc\":[\"https://social.tcit.fr/users/tcit/followers\"],\"sensitive\":false,\"atomUri\":\"https://social.tcit.fr/users/tcit/statuses/101159468934977010\",\"inReplyToAtomUri\":null,\"conversation\":\"tag:social.tcit.fr,2018-11-30:objectId=3642669:objectType=Conversation\",\"content\":\"\\u003cp\\u003eApart from PeerTube, which software that implements ActivityPub does have a group functionnality?\\u003cbr /\\u003eIt\\u0026apos;s to discuss about a Webfinger group: query prefix, similar to the acct: query prefix.\\u003c/p\\u003e\",\"contentMap\":{\"en\":\"\\u003cp\\u003eApart from PeerTube, which software that implements ActivityPub does have a group functionnality?\\u003cbr /\\u003eIt\\u0026apos;s to discuss about a Webfinger group: query prefix, similar to the acct: query prefix.\\u003c/p\\u003e\"},\"attachment\":[],\"tag\":[]}",
"headers": {
"Date": "Tue, 04 Dec 2018 13:59:58 GMT",
"Content-Type": "application/activity+json; charset=utf-8",
"Transfer-Encoding": "chunked",
"Connection": "keep-alive",
"Server": "Mastodon",
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"X-XSS-Protection": "1; mode=block",
"Link": "<https://social.tcit.fr/users/tcit/updates/15941.atom>; rel=\"alternate\"; type=\"application/atom+xml\", <https://social.tcit.fr/users/tcit/statuses/101159468934977010>; rel=\"alternate\"; type=\"application/activity+json\"",
"Vary": "Accept,Accept-Encoding",
"Cache-Control": "max-age=180, public",
"ETag": "W/\"a29cc605a433ed904736da57572038d3\"",
"X-Request-Id": "18488387-c5a3-40db-8c9e-5a3a067401a9",
"X-Runtime": "0.054993",
"X-Cached": "MISS"
},
"status_code": 200,
"type": "ok"
}
}
]

View file

@ -1,78 +0,0 @@
[
{
"request": {
"body": "",
"headers": {
"Accept": "application/activity+json"
},
"method": "get",
"options": {
"follow_redirect": "true",
"recv_timeout": 20000,
"connect_timeout": 10000
},
"request_body": "",
"url": "https://social.tcit.fr/users/tcit/statuses/99908779444618462"
},
"response": {
"binary": false,
"body": "{\"@context\":[\"https://www.w3.org/ns/activitystreams\",\"https://w3id.org/security/v1\",{\"manuallyApprovesFollowers\":\"as:manuallyApprovesFollowers\",\"sensitive\":\"as:sensitive\",\"movedTo\":{\"@id\":\"as:movedTo\",\"@type\":\"@id\"},\"Hashtag\":\"as:Hashtag\",\"ostatus\":\"http://ostatus.org#\",\"atomUri\":\"ostatus:atomUri\",\"inReplyToAtomUri\":\"ostatus:inReplyToAtomUri\",\"conversation\":\"ostatus:conversation\",\"toot\":\"http://joinmastodon.org/ns#\",\"Emoji\":\"toot:Emoji\",\"focalPoint\":{\"@container\":\"@list\",\"@id\":\"toot:focalPoint\"},\"featured\":{\"@id\":\"toot:featured\",\"@type\":\"@id\"},\"schema\":\"http://schema.org#\",\"PropertyValue\":\"schema:PropertyValue\",\"value\":\"schema:value\"}],\"id\":\"https://social.tcit.fr/users/tcit/statuses/99908779444618462\",\"type\":\"Note\",\"summary\":null,\"inReplyTo\":null,\"published\":\"2018-04-23T12:36:31Z\",\"url\":\"https://social.tcit.fr/@tcit/99908779444618462\",\"attributedTo\":\"https://social.tcit.fr/users/tcit\",\"to\":[\"https://www.w3.org/ns/activitystreams#Public\"],\"cc\":[\"https://social.tcit.fr/users/tcit/followers\"],\"sensitive\":false,\"atomUri\":\"https://social.tcit.fr/users/tcit/statuses/99908779444618462\",\"inReplyToAtomUri\":null,\"conversation\":\"tag:social.tcit.fr,2018-04-23:objectId=1769180:objectType=Conversation\",\"content\":\"\\u003cp\\u003eRimini - Les Wampas\\u003cbr /\\u003e\\u003ca href=\\\"https://combine.fm/spotify/track/5xo1GjsebrOd1iUVoJ6SEK\\\" rel=\\\"nofollow noopener\\\" target=\\\"_blank\\\"\\u003e\\u003cspan class=\\\"invisible\\\"\\u003ehttps://\\u003c/span\\u003e\\u003cspan class=\\\"ellipsis\\\"\\u003ecombine.fm/spotify/track/5xo1G\\u003c/span\\u003e\\u003cspan class=\\\"invisible\\\"\\u003ejsebrOd1iUVoJ6SEK\\u003c/span\\u003e\\u003c/a\\u003e\\u003c/p\\u003e\",\"contentMap\":{\"fr\":\"\\u003cp\\u003eRimini - Les Wampas\\u003cbr /\\u003e\\u003ca href=\\\"https://combine.fm/spotify/track/5xo1GjsebrOd1iUVoJ6SEK\\\" rel=\\\"nofollow noopener\\\" target=\\\"_blank\\\"\\u003e\\u003cspan class=\\\"invisible\\\"\\u003ehttps://\\u003c/span\\u003e\\u003cspan class=\\\"ellipsis\\\"\\u003ecombine.fm/spotify/track/5xo1G\\u003c/span\\u003e\\u003cspan class=\\\"invisible\\\"\\u003ejsebrOd1iUVoJ6SEK\\u003c/span\\u003e\\u003c/a\\u003e\\u003c/p\\u003e\"},\"attachment\":[],\"tag\":[]}",
"headers": {
"Date": "Tue, 13 Nov 2018 11:02:32 GMT",
"Content-Type": "application/activity+json; charset=utf-8",
"Transfer-Encoding": "chunked",
"Connection": "keep-alive",
"Server": "Mastodon",
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"X-XSS-Protection": "1; mode=block",
"Link": "<https://social.tcit.fr/users/tcit/updates/9225.atom>; rel=\"alternate\"; type=\"application/atom+xml\", <https://social.tcit.fr/users/tcit/statuses/99908779444618462>; rel=\"alternate\"; type=\"application/activity+json\"",
"Vary": "Accept,Accept-Encoding",
"Cache-Control": "max-age=180, public",
"ETag": "W/\"4f1620f67825ded8c3ebde01dc48e44f\"",
"X-Request-Id": "6e8e4d12-8396-445e-909c-81dab9797449",
"X-Runtime": "0.057592",
"X-Cached": "MISS"
},
"status_code": 200,
"type": "ok"
}
},
{
"request": {
"body": "",
"headers": {
"Accept": "application/activity+json"
},
"method": "get",
"options": {
"follow_redirect": "true"
},
"request_body": "",
"url": "https://social.tcit.fr/users/tcit"
},
"response": {
"binary": false,
"body": "{\"@context\":[\"https://www.w3.org/ns/activitystreams\",\"https://w3id.org/security/v1\",{\"manuallyApprovesFollowers\":\"as:manuallyApprovesFollowers\",\"sensitive\":\"as:sensitive\",\"movedTo\":{\"@id\":\"as:movedTo\",\"@type\":\"@id\"},\"Hashtag\":\"as:Hashtag\",\"ostatus\":\"http://ostatus.org#\",\"atomUri\":\"ostatus:atomUri\",\"inReplyToAtomUri\":\"ostatus:inReplyToAtomUri\",\"conversation\":\"ostatus:conversation\",\"toot\":\"http://joinmastodon.org/ns#\",\"Emoji\":\"toot:Emoji\",\"focalPoint\":{\"@container\":\"@list\",\"@id\":\"toot:focalPoint\"},\"featured\":{\"@id\":\"toot:featured\",\"@type\":\"@id\"},\"schema\":\"http://schema.org#\",\"PropertyValue\":\"schema:PropertyValue\",\"value\":\"schema:value\"}],\"id\":\"https://social.tcit.fr/users/tcit\",\"type\":\"Person\",\"following\":\"https://social.tcit.fr/users/tcit/following\",\"followers\":\"https://social.tcit.fr/users/tcit/followers\",\"inbox\":\"https://social.tcit.fr/users/tcit/inbox\",\"outbox\":\"https://social.tcit.fr/users/tcit/outbox\",\"featured\":\"https://social.tcit.fr/users/tcit/collections/featured\",\"preferredUsername\":\"tcit\",\"name\":\"🦄 Thomas Citharel\",\"summary\":\"\\u003cp\\u003eHoping to make people\\u0026apos;s life better with free software at \\u003cspan class=\\\"h-card\\\"\\u003e\\u003ca href=\\\"https://framapiaf.org/@Framasoft\\\" class=\\\"u-url mention\\\"\\u003e@\\u003cspan\\u003eFramasoft\\u003c/span\\u003e\\u003c/a\\u003e\\u003c/span\\u003e.\\u003c/p\\u003e\",\"url\":\"https://social.tcit.fr/@tcit\",\"manuallyApprovesFollowers\":false,\"publicKey\":{\"id\":\"https://social.tcit.fr/users/tcit#main-key\",\"owner\":\"https://social.tcit.fr/users/tcit\",\"publicKeyPem\":\"-----BEGIN PUBLIC KEY-----\\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApXwYMUdFg3XUd+bGsh8C\\nyiMRGpRGAWuCdM5pDWx5uM4pW2pM3xbHbcI21j9h8BmlAiPg6hbZD73KGly2N8Rt\\n5iIS0I+l6i8kA1JCCdlAaDTRd41RKMggZDoQvjVZQtsyE1VzMeU2kbqqTFN6ew7H\\nvbd6O0NhixoKoZ5f3jwuBDZoT0p1TAcaMdmG8oqHD97isizkDnRn8cOBA6wtI+xb\\n5xP2zxZMsLpTDZLiKU8XcPKZCw4OfQfmDmKkHtrFb77jCAQj/s/FxjVnvxRwmfhN\\nnWy0D+LUV/g63nHh/b5zXIeV92QZLvDYbgbezmzUzv9UeA1s70GGbaDqCIy85gw9\\n+wIDAQAB\\n-----END PUBLIC KEY-----\\n\"},\"tag\":[],\"attachment\":[{\"type\":\"PropertyValue\",\"name\":\"Works at\",\"value\":\"\\u003cspan class=\\\"h-card\\\"\\u003e\\u003ca href=\\\"https://framapiaf.org/@Framasoft\\\" class=\\\"u-url mention\\\"\\u003e@\\u003cspan\\u003eFramasoft\\u003c/span\\u003e\\u003c/a\\u003e\\u003c/span\\u003e\"},{\"type\":\"PropertyValue\",\"name\":\"Pronouns\",\"value\":\"He/Him\"},{\"type\":\"PropertyValue\",\"name\":\"Work Account\",\"value\":\"\\u003cspan class=\\\"h-card\\\"\\u003e\\u003ca href=\\\"https://framapiaf.org/@tcit\\\" class=\\\"u-url mention\\\"\\u003e@\\u003cspan\\u003etcit\\u003c/span\\u003e\\u003c/a\\u003e\\u003c/span\\u003e\"},{\"type\":\"PropertyValue\",\"name\":\"Pixelfed Account\",\"value\":\"@tcit@pix.tcit.fr\"}],\"endpoints\":{\"sharedInbox\":\"https://social.tcit.fr/inbox\"},\"icon\":{\"type\":\"Image\",\"mediaType\":\"image/jpeg\",\"url\":\"https://media.social.tcit.fr/mastodontcit/accounts/avatars/000/000/001/original/a28c50ce5f2b13fd.jpg\"},\"image\":{\"type\":\"Image\",\"mediaType\":\"image/jpeg\",\"url\":\"https://media.social.tcit.fr/mastodontcit/accounts/headers/000/000/001/original/4d1ab77c20265ee9.jpg\"}}",
"headers": {
"Date": "Tue, 13 Nov 2018 11:02:32 GMT",
"Content-Type": "application/activity+json; charset=utf-8",
"Transfer-Encoding": "chunked",
"Connection": "keep-alive",
"Server": "Mastodon",
"X-Frame-Options": "DENY",
"X-Content-Type-Options": "nosniff",
"X-XSS-Protection": "1; mode=block",
"Link": "<https://social.tcit.fr/.well-known/webfinger?resource=acct%3Atcit%40social.tcit.fr>; rel=\"lrdd\"; type=\"application/xrd+xml\", <https://social.tcit.fr/users/tcit.atom>; rel=\"alternate\"; type=\"application/atom+xml\", <https://social.tcit.fr/users/tcit>; rel=\"alternate\"; type=\"application/activity+json\"",
"Vary": "Accept,Accept-Encoding",
"Cache-Control": "max-age=180, public",
"ETag": "W/\"928f8a090d8c180ccc82fc1699f6c2a5\"",
"X-Request-Id": "9520c2ef-0089-4fb8-a6b4-95f3e217487c",
"X-Runtime": "0.043715",
"X-Cached": "MISS"
},
"status_code": 200,
"type": "ok"
}
}
]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,6 +4,7 @@ defmodule Mobilizon.ActorsTest do
alias Mobilizon.Actors alias Mobilizon.Actors
alias Mobilizon.Actors.{Actor, Member, Follower, Bot} alias Mobilizon.Actors.{Actor, Member, Follower, Bot}
alias Mobilizon.Users alias Mobilizon.Users
alias Mobilizon.Media.File
import Mobilizon.Factory import Mobilizon.Factory
use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney
@ -91,13 +92,23 @@ defmodule Mobilizon.ActorsTest do
test "get_actor_by_name/1 returns a remote actor" do test "get_actor_by_name/1 returns a remote actor" do
use_cassette "actors/remote_actor_mastodon_tcit" do use_cassette "actors/remote_actor_mastodon_tcit" do
with {:ok, {:ok,
%Actor{id: actor_id, preferred_username: preferred_username, domain: domain} = %Actor{
_actor} <- Actors.get_or_fetch_by_url(@remote_account_url), id: actor_id,
%Actor{id: actor_found_id} <- preferred_username: preferred_username,
Actors.get_actor_by_name("#{preferred_username}@#{domain}").id do domain: domain,
assert actor_found_id == actor_id avatar: %File{name: picture_name} = _picture
end } = _actor} = Actors.get_or_fetch_by_url(@remote_account_url)
assert picture_name == "avatar"
%Actor{
id: actor_found_id,
avatar: %File{name: picture_name} = _picture
} = Actors.get_actor_by_name("#{preferred_username}@#{domain}")
assert actor_found_id == actor_id
assert picture_name == "avatar"
end end
end end

View file

@ -0,0 +1,50 @@
defmodule Mobilizon.MediaTest do
use Mobilizon.DataCase
alias Mobilizon.Media
import Mobilizon.Factory
describe "media" do
alias Mobilizon.Media.Picture
@valid_attrs %{
file: %{
url: "https://something.tld/media/something",
name: "something old"
}
}
@update_attrs %{
file: %{
url: "https://something.tld/media/something_updated",
name: "something new"
}
}
test "get_picture!/1 returns the picture with given id" do
picture = insert(:picture)
assert Media.get_picture!(picture.id) == picture
end
test "create_picture/1 with valid data creates a picture" do
assert {:ok, %Picture{} = picture} = Media.create_picture(@valid_attrs)
assert picture.file.name == "something old"
end
test "update_picture/2 with valid data updates the picture" do
picture = insert(:picture)
assert {:ok, %Picture{} = picture} = Media.update_picture(picture, @update_attrs)
assert picture.file.name == "something new"
end
test "delete_picture/1 deletes the picture" do
picture = insert(:picture)
assert {:ok, %Picture{}} = Media.delete_picture(picture)
assert_raise Ecto.NoResultsError, fn -> Media.get_picture!(picture.id) end
end
test "change_picture/1 returns a picture changeset" do
picture = insert(:picture)
assert %Ecto.Changeset{} = Media.change_picture(picture)
end
end
end

View file

@ -72,15 +72,15 @@ defmodule Mobilizon.Service.ActivityPub.ActivityPubTest do
describe "fetching an" do describe "fetching an" do
test "object by url" do test "object by url" do
use_cassette "activity_pub/fetch_social_tcit_fr_status" do use_cassette "activity_pub/fetch_framapiaf_framasoft_status" do
{:ok, object} = {:ok, object} =
ActivityPub.fetch_object_from_url( ActivityPub.fetch_object_from_url(
"https://social.tcit.fr/users/tcit/statuses/99908779444618462" "https://framapiaf.org/users/Framasoft/statuses/102093631881522097"
) )
{:ok, object_again} = {:ok, object_again} =
ActivityPub.fetch_object_from_url( ActivityPub.fetch_object_from_url(
"https://social.tcit.fr/users/tcit/statuses/99908779444618462" "https://framapiaf.org/users/Framasoft/statuses/102093631881522097"
) )
assert object.id == object_again.id assert object.id == object_again.id
@ -88,14 +88,12 @@ defmodule Mobilizon.Service.ActivityPub.ActivityPubTest do
end end
test "object reply by url" do test "object reply by url" do
use_cassette "activity_pub/fetch_social_tcit_fr_reply" do use_cassette "activity_pub/fetch_framasoft_framapiaf_reply" do
{:ok, object} = {:ok, object} =
ActivityPub.fetch_object_from_url( ActivityPub.fetch_object_from_url("https://mamot.fr/@imacrea/102094441327423790")
"https://social.tcit.fr/users/tcit/statuses/101160654038714030"
)
assert object.in_reply_to_comment.url == assert object.in_reply_to_comment.url ==
"https://social.tcit.fr/users/tcit/statuses/101160195754333819" "https://framapiaf.org/users/Framasoft/statuses/102093632302210150"
end end
end end
@ -103,7 +101,7 @@ defmodule Mobilizon.Service.ActivityPub.ActivityPubTest do
use_cassette "activity_pub/fetch_reply_to_framatube" do use_cassette "activity_pub/fetch_reply_to_framatube" do
{:ok, object} = {:ok, object} =
ActivityPub.fetch_object_from_url( ActivityPub.fetch_object_from_url(
"https://framapiaf.org/@troisiemelobe/101156292125317651" "https://diaspodon.fr/users/dada/statuses/100820008426311925"
) )
assert object.in_reply_to_comment == nil assert object.in_reply_to_comment == nil

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