Merge branch 'detect-images-in-body' into 'master'

Track usage of media files and add a job to clean them

See merge request framasoft/mobilizon!727
This commit is contained in:
Thomas Citharel 2020-11-26 18:10:04 +01:00
commit 620187a056
79 changed files with 1429 additions and 700 deletions

View File

@ -6,6 +6,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
**This release adds new migrations, be sure to run them before restarting Mobilizon**
**This release has a repair step, be sure to run the command right after restarting Mobilizon**
### Special operations
* **Reattach media files to their entity.**
When media files were uploaded and added in events and posts bodies, they were only attached to the profile that uploaded them, not to the event or post. This task attaches them back to their entity so that the command to clean orphan media files doesn't remove them.
* Source install
`MIX_ENV=prod mix mobilizon.maintenance.fix_unattached_media_in_body`
* Docker
`docker-compose exec mobilizon mobilizon_ctl maintenance.fix_unattached_media_in_body`
### Added
- **Add a command to clean orphan media files**. There's a `--dry-run` option to see what files would have been deleted.
**Make sure all media files have been reattached properly (see above) before running this command.**
In 1.1.0 a scheduled job will be enabled to clear orphan media files automatically after a while.
### Fixed
- Fix inline media that weren't being tracked, so that they are not considered orphans media files.
## 1.0.2 - 2020-11-15 ## 1.0.2 - 2020-11-15
**This release adds new migrations, be sure to run them before restarting Mobilizon** **This release adds new migrations, be sure to run them before restarting Mobilizon**

View File

@ -28,6 +28,8 @@ config :mobilizon, :instance,
upload_limit: 10_000_000, upload_limit: 10_000_000,
avatar_upload_limit: 2_000_000, avatar_upload_limit: 2_000_000,
banner_upload_limit: 4_000_000, banner_upload_limit: 4_000_000,
remove_orphan_uploads: true,
orphan_upload_grace_period_hours: 48,
email_from: "noreply@localhost", email_from: "noreply@localhost",
email_reply_to: "noreply@localhost" email_reply_to: "noreply@localhost"
@ -250,6 +252,8 @@ config :mobilizon, Oban,
crontab: [ crontab: [
{"@hourly", Mobilizon.Service.Workers.BuildSiteMap, queue: :background}, {"@hourly", Mobilizon.Service.Workers.BuildSiteMap, queue: :background},
{"17 * * * *", Mobilizon.Service.Workers.RefreshGroups, queue: :background} {"17 * * * *", Mobilizon.Service.Workers.RefreshGroups, queue: :background}
# To be activated in Mobilizon 1.2
# {"@hourly", Mobilizon.Service.Workers.CleanOrphanMediaWorker, queue: :background}
] ]
config :mobilizon, :rich_media, config :mobilizon, :rich_media,

View File

@ -212,7 +212,7 @@ import { SEARCH_PERSONS } from "../graphql/search";
import { Actor, IActor, IPerson } from "../types/actor"; import { Actor, IActor, IPerson } from "../types/actor";
import Image from "./Editor/Image"; import Image from "./Editor/Image";
import MaxSize from "./Editor/MaxSize"; import MaxSize from "./Editor/MaxSize";
import { UPLOAD_PICTURE } from "../graphql/upload"; import { UPLOAD_MEDIA } from "../graphql/upload";
import { listenFileUpload } from "../utils/upload"; import { listenFileUpload } from "../utils/upload";
import { CURRENT_ACTOR_CLIENT } from "../graphql/actor"; import { CURRENT_ACTOR_CLIENT } from "../graphql/actor";
import { IComment } from "../types/comment.model"; import { IComment } from "../types/comment.model";
@ -395,7 +395,15 @@ export default class EditorComponent extends Vue {
new Image(), new Image(),
new MaxSize({ maxSize: this.maxSize }), new MaxSize({ maxSize: this.maxSize }),
], ],
onUpdate: ({ getHTML }: { getHTML: Function }) => { onUpdate: ({
getHTML,
transaction,
getJSON,
}: {
getHTML: Function;
getJSON: Function;
transaction: unknown;
}) => {
this.$emit("input", getHTML()); this.$emit("input", getHTML());
}, },
}); });
@ -526,14 +534,14 @@ export default class EditorComponent extends Vue {
const image = await listenFileUpload(); const image = await listenFileUpload();
try { try {
const { data } = await this.$apollo.mutate({ const { data } = await this.$apollo.mutate({
mutation: UPLOAD_PICTURE, mutation: UPLOAD_MEDIA,
variables: { variables: {
file: image, file: image,
name: image.name, name: image.name,
}, },
}); });
if (data.uploadPicture && data.uploadPicture.url) { if (data.uploadMedia && data.uploadMedia.url) {
command({ src: data.uploadPicture.url }); command({ src: data.uploadMedia.url, "data-media-id": data.uploadMedia.id });
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@ -1,6 +1,7 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck // @ts-nocheck
import { Node } from "tiptap"; import { Node } from "tiptap";
import { UPLOAD_PICTURE } from "@/graphql/upload"; import { UPLOAD_MEDIA } from "@/graphql/upload";
import apolloProvider from "@/vue-apollo"; import apolloProvider from "@/vue-apollo";
import ApolloClient from "apollo-client"; import ApolloClient from "apollo-client";
import { NormalizedCacheObject } from "apollo-cache-inmemory"; import { NormalizedCacheObject } from "apollo-cache-inmemory";
@ -27,16 +28,18 @@ export default class Image extends Node {
title: { title: {
default: null, default: null,
}, },
"data-media-id": {},
}, },
group: "inline", group: "inline",
draggable: true, draggable: true,
parseDOM: [ parseDOM: [
{ {
tag: "img[src]", tag: "img",
getAttrs: (dom: any) => ({ getAttrs: (dom: any) => ({
src: dom.getAttribute("src"), src: dom.getAttribute("src"),
title: dom.getAttribute("title"), title: dom.getAttribute("title"),
alt: dom.getAttribute("alt"), alt: dom.getAttribute("alt"),
"data-media-id": dom.getAttribute("data-media-id"),
}), }),
}, },
], ],
@ -92,13 +95,16 @@ export default class Image extends Node {
try { try {
images.forEach(async (image) => { images.forEach(async (image) => {
const { data } = await client.mutate({ const { data } = await client.mutate({
mutation: UPLOAD_PICTURE, mutation: UPLOAD_MEDIA,
variables: { variables: {
file: image, file: image,
name: image.name, name: image.name,
}, },
}); });
const node = schema.nodes.image.create({ src: data.uploadPicture.url }); const node = schema.nodes.image.create({
src: data.uploadMedia.url,
"data-media-id": data.uploadMedia.id,
});
const transaction = view.state.tr.insert(coordinates.pos, node); const transaction = view.state.tr.insert(coordinates.pos, node);
view.dispatch(transaction); view.dispatch(transaction);
}); });

View File

@ -60,14 +60,14 @@ figure.image {
</style> </style>
<script lang="ts"> <script lang="ts">
import { IPicture } from "@/types/picture.model"; import { IMedia } from "@/types/media.model";
import { Component, Model, Prop, Vue, Watch } from "vue-property-decorator"; import { Component, Model, Prop, Vue, Watch } from "vue-property-decorator";
@Component @Component
export default class PictureUpload extends Vue { export default class PictureUpload extends Vue {
@Model("change", { type: File }) readonly pictureFile!: File; @Model("change", { type: File }) readonly pictureFile!: File;
@Prop({ type: Object, required: false }) defaultImage!: IPicture; @Prop({ type: Object, required: false }) defaultImage!: IMedia;
@Prop({ type: String, required: false, default: "image/gif,image/png,image/jpeg,image/webp" }) @Prop({ type: String, required: false, default: "image/gif,image/png,image/jpeg,image/webp" })
accept!: string; accept!: string;
@ -100,7 +100,7 @@ export default class PictureUpload extends Vue {
} }
@Watch("defaultImage") @Watch("defaultImage")
onDefaultImageChange(defaultImage: IPicture): void { onDefaultImageChange(defaultImage: IMedia): void {
console.log("onDefaultImageChange", defaultImage); console.log("onDefaultImageChange", defaultImage);
this.imageSrc = defaultImage ? defaultImage.url : null; this.imageSrc = defaultImage ? defaultImage.url : null;
} }

View File

@ -421,7 +421,7 @@ export const CREATE_PERSON = gql`
$preferredUsername: String! $preferredUsername: String!
$name: String! $name: String!
$summary: String $summary: String
$avatar: PictureInput $avatar: MediaInput
) { ) {
createPerson( createPerson(
preferredUsername: $preferredUsername preferredUsername: $preferredUsername
@ -442,7 +442,7 @@ export const CREATE_PERSON = gql`
`; `;
export const UPDATE_PERSON = gql` export const UPDATE_PERSON = gql`
mutation UpdatePerson($id: ID!, $name: String, $summary: String, $avatar: PictureInput) { mutation UpdatePerson($id: ID!, $name: String, $summary: String, $avatar: MediaInput) {
updatePerson(id: $id, name: $name, summary: $summary, avatar: $avatar) { updatePerson(id: $id, name: $name, summary: $summary, avatar: $avatar) {
id id
preferredUsername preferredUsername

View File

@ -244,7 +244,7 @@ export const CREATE_EVENT = gql`
$joinOptions: EventJoinOptions, $joinOptions: EventJoinOptions,
$draft: Boolean, $draft: Boolean,
$tags: [String], $tags: [String],
$picture: PictureInput, $picture: MediaInput,
$onlineAddress: String, $onlineAddress: String,
$phoneAddress: String, $phoneAddress: String,
$category: String, $category: String,
@ -355,7 +355,7 @@ export const EDIT_EVENT = gql`
$joinOptions: EventJoinOptions, $joinOptions: EventJoinOptions,
$draft: Boolean, $draft: Boolean,
$tags: [String], $tags: [String],
$picture: PictureInput, $picture: MediaInput,
$onlineAddress: String, $onlineAddress: String,
$phoneAddress: String, $phoneAddress: String,
$organizerActorId: ID, $organizerActorId: ID,

View File

@ -227,8 +227,8 @@ export const CREATE_GROUP = gql`
$preferredUsername: String! $preferredUsername: String!
$name: String! $name: String!
$summary: String $summary: String
$avatar: PictureInput $avatar: MediaInput
$banner: PictureInput $banner: MediaInput
) { ) {
createGroup( createGroup(
preferredUsername: $preferredUsername preferredUsername: $preferredUsername
@ -259,8 +259,8 @@ export const UPDATE_GROUP = gql`
$id: ID! $id: ID!
$name: String $name: String
$summary: String $summary: String
$avatar: PictureInput $avatar: MediaInput
$banner: PictureInput $banner: MediaInput
$visibility: GroupVisibility $visibility: GroupVisibility
$openness: Openness $openness: Openness
$physicalAddress: AddressInput $physicalAddress: AddressInput

View File

@ -119,7 +119,7 @@ export const CREATE_POST = gql`
$visibility: PostVisibility $visibility: PostVisibility
$draft: Boolean $draft: Boolean
$tags: [String] $tags: [String]
$picture: PictureInput $picture: MediaInput
) { ) {
createPost( createPost(
title: $title title: $title
@ -145,7 +145,7 @@ export const UPDATE_POST = gql`
$visibility: PostVisibility $visibility: PostVisibility
$draft: Boolean $draft: Boolean
$tags: [String] $tags: [String]
$picture: PictureInput $picture: MediaInput
) { ) {
updatePost( updatePost(
id: $id id: $id

View File

@ -1,17 +1,17 @@
import gql from "graphql-tag"; import gql from "graphql-tag";
export const UPLOAD_PICTURE = gql` export const UPLOAD_MEDIA = gql`
mutation UploadPicture($file: Upload!, $alt: String, $name: String!) { mutation UploadMedia($file: Upload!, $alt: String, $name: String!) {
uploadPicture(file: $file, alt: $alt, name: $name) { uploadMedia(file: $file, alt: $alt, name: $name) {
url url
id id
} }
} }
`; `;
export const REMOVE_PICTURE = gql` export const REMOVE_MEDIA = gql`
mutation RemovePicture($id: ID!) { mutation RemoveMedia($id: ID!) {
removePicture(id: $id) { removeMedia(id: $id) {
id id
} }
} }

View File

@ -1,4 +1,4 @@
import { IPicture } from "@/types/picture.model"; import { IMedia } from "@/types/media.model";
export enum ActorType { export enum ActorType {
PERSON = "PERSON", PERSON = "PERSON",
@ -17,17 +17,17 @@ export interface IActor {
summary: string; summary: string;
preferredUsername: string; preferredUsername: string;
suspended: boolean; suspended: boolean;
avatar?: IPicture | null; avatar?: IMedia | null;
banner?: IPicture | null; banner?: IMedia | null;
type: ActorType; type: ActorType;
} }
export class Actor implements IActor { export class Actor implements IActor {
id?: string; id?: string;
avatar: IPicture | null = null; avatar: IMedia | null = null;
banner: IPicture | null = null; banner: IMedia | null = null;
domain: string | null = null; domain: string | null = null;

View File

@ -1,6 +1,6 @@
import { Address, IAddress } from "@/types/address.model"; import { Address, IAddress } from "@/types/address.model";
import { ITag } from "@/types/tag.model"; import { ITag } from "@/types/tag.model";
import { IPicture } from "@/types/picture.model"; import { IMedia } from "@/types/media.model";
import { IComment } from "@/types/comment.model"; import { IComment } from "@/types/comment.model";
import { Paginate } from "@/types/paginate"; import { Paginate } from "@/types/paginate";
import { Actor, Group, IActor, IGroup, IPerson } from "./actor"; import { Actor, Group, IActor, IGroup, IPerson } from "./actor";
@ -69,7 +69,7 @@ interface IEventEditJSON {
visibility: EventVisibility; visibility: EventVisibility;
joinOptions: EventJoinOptions; joinOptions: EventJoinOptions;
draft: boolean; draft: boolean;
picture?: IPicture | { pictureId: string } | null; picture?: IMedia | { mediaId: string } | null;
attributedToId: string | null; attributedToId: string | null;
onlineAddress?: string; onlineAddress?: string;
phoneAddress?: string; phoneAddress?: string;
@ -96,7 +96,7 @@ export interface IEvent {
joinOptions: EventJoinOptions; joinOptions: EventJoinOptions;
draft: boolean; draft: boolean;
picture: IPicture | null; picture: IMedia | null;
organizerActor?: IActor; organizerActor?: IActor;
attributedTo?: IGroup; attributedTo?: IGroup;
@ -142,7 +142,7 @@ export class EventModel implements IEvent {
physicalAddress?: IAddress; physicalAddress?: IAddress;
picture: IPicture | null = null; picture: IMedia | null = null;
visibility = EventVisibility.PUBLIC; visibility = EventVisibility.PUBLIC;

View File

@ -1,11 +1,11 @@
export interface IPicture { export interface IMedia {
id: string; id: string;
url: string; url: string;
name: string; name: string;
alt: string; alt: string;
} }
export interface IPictureUpload { export interface IMediaUpload {
file: File; file: File;
name: string; name: string;
alt: string | null; alt: string | null;

View File

@ -1,5 +1,5 @@
import { ITag } from "./tag.model"; import { ITag } from "./tag.model";
import { IPicture } from "./picture.model"; import { IMedia } from "./media.model";
import { IActor } from "./actor"; import { IActor } from "./actor";
export enum PostVisibility { export enum PostVisibility {
@ -17,7 +17,7 @@ export interface IPost {
title: string; title: string;
body: string; body: string;
tags?: ITag[]; tags?: ITag[];
picture?: IPicture | null; picture?: IMedia | null;
draft: boolean; draft: boolean;
visibility: PostVisibility; visibility: PostVisibility;
author?: IActor; author?: IActor;

View File

@ -1,6 +1,6 @@
import { IPicture } from "@/types/picture.model"; import { IMedia } from "@/types/media.model";
export async function buildFileFromIPicture(obj: IPicture | null | undefined): Promise<File | null> { export async function buildFileFromIMedia(obj: IMedia | null | undefined): Promise<File | null> {
if (!obj) return Promise.resolve(null); if (!obj) return Promise.resolve(null);
const response = await fetch(obj.url); const response = await fetch(obj.url);
@ -14,7 +14,7 @@ export function buildFileVariable(file: File | null, name: string, alt?: string)
return { return {
[name]: { [name]: {
picture: { media: {
name: file.name, name: file.name,
alt: alt || file.name, alt: alt || file.name,
file, file,

View File

@ -124,7 +124,6 @@ h1 {
<script lang="ts"> <script lang="ts">
import { Component, Prop, Watch } from "vue-property-decorator"; import { Component, Prop, Watch } from "vue-property-decorator";
import { mixins } from "vue-class-component"; import { mixins } from "vue-class-component";
import { IPicture } from "@/types/picture.model";
import { import {
CREATE_PERSON, CREATE_PERSON,
CURRENT_ACTOR_CLIENT, CURRENT_ACTOR_CLIENT,
@ -137,7 +136,7 @@ import { IPerson, Person } from "../../../types/actor";
import PictureUpload from "../../../components/PictureUpload.vue"; import PictureUpload from "../../../components/PictureUpload.vue";
import { MOBILIZON_INSTANCE_HOST } from "../../../api/_entrypoint"; import { MOBILIZON_INSTANCE_HOST } from "../../../api/_entrypoint";
import RouteName from "../../../router/name"; import RouteName from "../../../router/name";
import { buildFileFromIPicture, buildFileVariable } from "../../../utils/image"; import { buildFileVariable } from "../../../utils/image";
import { changeIdentity } from "../../../utils/auth"; import { changeIdentity } from "../../../utils/auth";
import identityEditionMixin from "../../../mixins/identityEdition"; import identityEditionMixin from "../../../mixins/identityEdition";

View File

@ -377,7 +377,7 @@ import {
import { IPerson, Person, displayNameAndUsername } from "../../types/actor"; import { IPerson, Person, displayNameAndUsername } from "../../types/actor";
import { TAGS } from "../../graphql/tags"; import { TAGS } from "../../graphql/tags";
import { ITag } from "../../types/tag.model"; import { ITag } from "../../types/tag.model";
import { buildFileFromIPicture, buildFileVariable, readFileAsync } from "../../utils/image"; import { buildFileFromIMedia, buildFileVariable, readFileAsync } from "../../utils/image";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import "intersection-observer"; import "intersection-observer";
import { CONFIG } from "../../graphql/config"; import { CONFIG } from "../../graphql/config";
@ -517,7 +517,7 @@ export default class EditEvent extends Vue {
); );
this.observer.observe(this.$refs.bottomObserver as Element); this.observer.observe(this.$refs.bottomObserver as Element);
this.pictureFile = await buildFileFromIPicture(this.event.picture); this.pictureFile = await buildFileFromIMedia(this.event.picture);
this.limitedPlaces = this.event.options.maximumAttendeeCapacity > 0; this.limitedPlaces = this.event.options.maximumAttendeeCapacity > 0;
if (!(this.isUpdate || this.isDuplicate)) { if (!(this.isUpdate || this.isDuplicate)) {
this.initializeEvent(); this.initializeEvent();
@ -775,11 +775,11 @@ export default class EditEvent extends Vue {
try { try {
if (this.event.picture && this.pictureFile) { if (this.event.picture && this.pictureFile) {
const oldPictureFile = (await buildFileFromIPicture(this.event.picture)) as File; const oldPictureFile = (await buildFileFromIMedia(this.event.picture)) as File;
const oldPictureFileContent = await readFileAsync(oldPictureFile); const oldPictureFileContent = await readFileAsync(oldPictureFile);
const newPictureFileContent = await readFileAsync(this.pictureFile as File); const newPictureFileContent = await readFileAsync(this.pictureFile as File);
if (oldPictureFileContent === newPictureFileContent) { if (oldPictureFileContent === newPictureFileContent) {
res.picture = { pictureId: this.event.picture.id }; res.picture = { mediaId: this.event.picture.id };
} }
} }
} catch (e) { } catch (e) {

View File

@ -246,7 +246,7 @@ export default class GroupSettings extends mixins(GroupMixin) {
if (this.avatarFile) { if (this.avatarFile) {
avatarObj = { avatarObj = {
avatar: { avatar: {
picture: { media: {
name: this.avatarFile.name, name: this.avatarFile.name,
alt: `${this.group.preferredUsername}'s avatar`, alt: `${this.group.preferredUsername}'s avatar`,
file: this.avatarFile, file: this.avatarFile,
@ -258,7 +258,7 @@ export default class GroupSettings extends mixins(GroupMixin) {
if (this.bannerFile) { if (this.bannerFile) {
bannerObj = { bannerObj = {
banner: { banner: {
picture: { media: {
name: this.bannerFile.name, name: this.bannerFile.name,
alt: `${this.group.preferredUsername}'s banner`, alt: `${this.group.preferredUsername}'s banner`,
file: this.bannerFile, file: this.bannerFile,

View File

@ -103,7 +103,7 @@
import { Component, Prop } from "vue-property-decorator"; import { Component, Prop } from "vue-property-decorator";
import { mixins } from "vue-class-component"; import { mixins } from "vue-class-component";
import { FETCH_GROUP } from "@/graphql/group"; import { FETCH_GROUP } from "@/graphql/group";
import { buildFileFromIPicture, readFileAsync } from "@/utils/image"; import { buildFileFromIMedia, readFileAsync } from "@/utils/image";
import GroupMixin from "@/mixins/group"; import GroupMixin from "@/mixins/group";
import { TAGS } from "../../graphql/tags"; import { TAGS } from "../../graphql/tags";
import { CONFIG } from "../../graphql/config"; import { CONFIG } from "../../graphql/config";
@ -188,7 +188,7 @@ export default class EditPost extends mixins(GroupMixin) {
errors: Record<string, unknown> = {}; errors: Record<string, unknown> = {};
async mounted(): Promise<void> { async mounted(): Promise<void> {
this.pictureFile = await buildFileFromIPicture(this.post.picture); this.pictureFile = await buildFileFromIMedia(this.post.picture);
} }
// eslint-disable-next-line consistent-return // eslint-disable-next-line consistent-return
@ -277,11 +277,11 @@ export default class EditPost extends mixins(GroupMixin) {
} }
try { try {
if (this.post.picture) { if (this.post.picture) {
const oldPictureFile = (await buildFileFromIPicture(this.post.picture)) as File; const oldPictureFile = (await buildFileFromIMedia(this.post.picture)) as File;
const oldPictureFileContent = await readFileAsync(oldPictureFile); const oldPictureFileContent = await readFileAsync(oldPictureFile);
const newPictureFileContent = await readFileAsync(this.pictureFile as File); const newPictureFileContent = await readFileAsync(this.pictureFile as File);
if (oldPictureFileContent === newPictureFileContent) { if (oldPictureFileContent === newPictureFileContent) {
obj.picture = { pictureId: this.post.picture.id }; obj.picture = { mediaId: this.post.picture.id };
} }
} }
} catch (e) { } catch (e) {

View File

@ -10,7 +10,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
alias Mobilizon.Actors alias Mobilizon.Actors
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Media.Picture alias Mobilizon.Medias.Media
alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.{Activity, Federator, Relay} alias Mobilizon.Federation.ActivityPub.{Activity, Federator, Relay}
@ -333,50 +333,50 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
Return AS Link data from Return AS Link data from
* a `Plug.Upload` struct, stored an returned * a `Plug.Upload` struct, stored an returned
* a `Picture`, directly returned * a `Media`, directly returned
* a map containing picture information, stored, saved and returned * a map containing media information, stored, saved and returned
Save picture data from %Plug.Upload{} and return AS Link data. Save media data from %Plug.Upload{} and return AS Link data.
""" """
def make_picture_data(%Plug.Upload{} = picture, opts) do def make_media_data(%Plug.Upload{} = media, opts) do
case Mobilizon.Web.Upload.store(picture, opts) do case Mobilizon.Web.Upload.store(media, opts) do
{:ok, picture} -> {:ok, media} ->
picture media
_ -> _ ->
nil nil
end end
end end
def make_picture_data(%Picture{} = picture) do def make_media_data(%Media{} = media) do
Converter.Picture.model_to_as(picture) Converter.Media.model_to_as(media)
end end
def make_picture_data(picture) when is_map(picture) do def make_media_data(media) when is_map(media) do
with {:ok, %{"url" => [%{"href" => url, "mediaType" => content_type}], "size" => size}} <- with {:ok, %{"url" => [%{"href" => url, "mediaType" => content_type}], "size" => size}} <-
Mobilizon.Web.Upload.store(picture.file), Mobilizon.Web.Upload.store(media.file),
{:picture_exists, nil} <- {:picture_exists, Mobilizon.Media.get_picture_by_url(url)}, {:media_exists, nil} <- {:media_exists, Mobilizon.Medias.get_media_by_url(url)},
{:ok, %Picture{file: _file} = picture} <- {:ok, %Media{file: _file} = media} <-
Mobilizon.Media.create_picture(%{ Mobilizon.Medias.create_media(%{
"file" => %{ "file" => %{
"url" => url, "url" => url,
"name" => picture.name, "name" => media.name,
"content_type" => content_type, "content_type" => content_type,
"size" => size "size" => size
}, },
"actor_id" => picture.actor_id "actor_id" => media.actor_id
}) do }) do
Converter.Picture.model_to_as(picture) Converter.Media.model_to_as(media)
else else
{:picture_exists, %Picture{file: _file} = picture} -> {:media_exists, %Media{file: _file} = media} ->
Converter.Picture.model_to_as(picture) Converter.Media.model_to_as(media)
err -> err ->
err err
end end
end end
def make_picture_data(nil), do: nil def make_media_data(nil), do: nil
@doc """ @doc """
Make announce activity data for the given actor and object Make announce activity data for the given actor and object

View File

@ -10,11 +10,11 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
alias Mobilizon.Addresses alias Mobilizon.Addresses
alias Mobilizon.Addresses.Address alias Mobilizon.Addresses.Address
alias Mobilizon.Events.Event, as: EventModel alias Mobilizon.Events.Event, as: EventModel
alias Mobilizon.Media.Picture alias Mobilizon.Medias.Media
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible} alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
alias Mobilizon.Federation.ActivityStream.Converter.Address, as: AddressConverter alias Mobilizon.Federation.ActivityStream.Converter.Address, as: AddressConverter
alias Mobilizon.Federation.ActivityStream.Converter.Picture, as: PictureConverter alias Mobilizon.Federation.ActivityStream.Converter.Media, as: MediaConverter
import Mobilizon.Federation.ActivityStream.Converter.Utils, import Mobilizon.Federation.ActivityStream.Converter.Utils,
only: [ only: [
@ -55,10 +55,10 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
picture_id = picture_id =
with true <- length(attachments) > 0, with true <- length(attachments) > 0,
{:ok, %Picture{id: picture_id}} <- {:ok, %Media{id: picture_id}} <-
attachments attachments
|> hd() |> hd()
|> PictureConverter.find_or_create_picture(actor_id) do |> MediaConverter.find_or_create_media(actor_id) do
picture_id picture_id
else else
_err -> _err ->
@ -239,7 +239,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
res, res,
"attachment", "attachment",
[], [],
&(&1 ++ [PictureConverter.model_to_as(event.picture)]) &(&1 ++ [MediaConverter.model_to_as(event.picture)])
) )
end end

View File

@ -0,0 +1,63 @@
defmodule Mobilizon.Federation.ActivityStream.Converter.Media do
@moduledoc """
Media converter.
This module allows to convert events from ActivityStream format to our own
internal one, and back.
"""
alias Mobilizon.Medias
alias Mobilizon.Medias.Media, as: MediaModel
alias Mobilizon.Web.Upload
@http_options [
ssl: [{:versions, [:"tlsv1.2"]}]
]
@doc """
Convert a media struct to an ActivityStream representation.
"""
@spec model_to_as(MediaModel.t()) :: map
def model_to_as(%MediaModel{file: file}) do
%{
"type" => "Document",
"mediaType" => file.content_type,
"url" => file.url,
"name" => file.name
}
end
@doc """
Save media data from raw data and return AS Link data.
"""
def find_or_create_media(%{"type" => "Link", "href" => url}, actor_id),
do: find_or_create_media(url, actor_id)
def find_or_create_media(
%{"type" => "Document", "url" => media_url, "name" => name},
actor_id
)
when is_bitstring(media_url) do
with {:ok, %{body: body}} <- Tesla.get(media_url, opts: @http_options),
{:ok, %{name: name, url: url, content_type: content_type, size: size}} <-
Upload.store(%{body: body, name: name}),
{:media_exists, nil} <- {:media_exists, Medias.get_media_by_url(url)} do
Medias.create_media(%{
"file" => %{
"url" => url,
"name" => name,
"content_type" => content_type,
"size" => size
},
"actor_id" => actor_id
})
else
{:media_exists, %MediaModel{file: _file} = media} ->
{:ok, media}
err ->
err
end
end
end

View File

@ -1,63 +0,0 @@
defmodule Mobilizon.Federation.ActivityStream.Converter.Picture do
@moduledoc """
Picture converter.
This module allows to convert events from ActivityStream format to our own
internal one, and back.
"""
alias Mobilizon.Media
alias Mobilizon.Media.Picture, as: PictureModel
alias Mobilizon.Web.Upload
@http_options [
ssl: [{:versions, [:"tlsv1.2"]}]
]
@doc """
Convert a picture struct to an ActivityStream representation.
"""
@spec model_to_as(PictureModel.t()) :: map
def model_to_as(%PictureModel{file: file}) do
%{
"type" => "Document",
"mediaType" => file.content_type,
"url" => file.url,
"name" => file.name
}
end
@doc """
Save picture data from raw data and return AS Link data.
"""
def find_or_create_picture(%{"type" => "Link", "href" => url}, actor_id),
do: find_or_create_picture(url, actor_id)
def find_or_create_picture(
%{"type" => "Document", "url" => picture_url, "name" => name},
actor_id
)
when is_bitstring(picture_url) do
with {:ok, %{body: body}} <- Tesla.get(picture_url, opts: @http_options),
{:ok, %{name: name, url: url, content_type: content_type, size: size}} <-
Upload.store(%{body: body, name: name}),
{:picture_exists, nil} <- {:picture_exists, Media.get_picture_by_url(url)} do
Media.create_picture(%{
"file" => %{
"url" => url,
"name" => name,
"content_type" => content_type,
"size" => size
},
"actor_id" => actor_id
})
else
{:picture_exists, %PictureModel{file: _file} = picture} ->
{:ok, picture}
err ->
err
end
end
end

View File

@ -1,34 +1,59 @@
defmodule Mobilizon.GraphQL.API.Comments do defmodule Mobilizon.GraphQL.API.Comments do
@moduledoc """ @moduledoc """
API for Comments. API for discussions and comments.
""" """
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.Comment alias Mobilizon.Discussions.Comment
alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Activity alias Mobilizon.Federation.ActivityPub.Activity
alias Mobilizon.GraphQL.API.Utils
@doc """ @doc """
Create a comment Create a comment
Creates a comment from an actor
""" """
@spec create_comment(map) :: {:ok, Activity.t(), Comment.t()} | any @spec create_comment(map) :: {:ok, Activity.t(), Comment.t()} | any
def create_comment(args) do def create_comment(args) do
args = extract_pictures_from_comment_body(args)
ActivityPub.create(:comment, args, true) ActivityPub.create(:comment, args, true)
end end
@doc """
Updates a comment
"""
@spec update_comment(Comment.t(), map()) :: {:ok, Activity.t(), Comment.t()} | any
def update_comment(%Comment{} = comment, args) do def update_comment(%Comment{} = comment, args) do
args = extract_pictures_from_comment_body(args)
ActivityPub.update(comment, args, true) ActivityPub.update(comment, args, true)
end end
@doc """ @doc """
Deletes a comment Deletes a comment
Deletes a comment from an actor
""" """
@spec delete_comment(Comment.t(), Actor.t()) :: {:ok, Activity.t(), Comment.t()} | any @spec delete_comment(Comment.t(), Actor.t()) :: {:ok, Activity.t(), Comment.t()} | any
def delete_comment(%Comment{} = comment, %Actor{} = actor) do def delete_comment(%Comment{} = comment, %Actor{} = actor) do
ActivityPub.delete(comment, actor, true) ActivityPub.delete(comment, actor, true)
end end
@doc """
Creates a discussion (or reply to a discussion)
"""
@spec create_discussion(map()) :: map()
def create_discussion(args) do
args = extract_pictures_from_comment_body(args)
ActivityPub.create(
:discussion,
args,
true
)
end
@spec extract_pictures_from_comment_body(map()) :: map()
defp extract_pictures_from_comment_body(%{text: text, actor_id: actor_id} = args) do
pictures = Utils.extract_pictures_from_body(text, actor_id)
Map.put(args, :media, pictures)
end
defp extract_pictures_from_comment_body(args), do: args
end end

View File

@ -8,6 +8,7 @@ defmodule Mobilizon.GraphQL.API.Events do
alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.{Activity, Utils} alias Mobilizon.Federation.ActivityPub.{Activity, Utils}
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
@doc """ @doc """
Create an event Create an event
@ -15,6 +16,7 @@ defmodule Mobilizon.GraphQL.API.Events do
@spec create_event(map) :: {:ok, Activity.t(), Event.t()} | any @spec create_event(map) :: {:ok, Activity.t(), Event.t()} | any
def create_event(args) do def create_event(args) do
with organizer_actor <- Map.get(args, :organizer_actor), with organizer_actor <- Map.get(args, :organizer_actor),
args <- extract_pictures_from_event_body(args, organizer_actor),
args <- args <-
Map.update(args, :picture, nil, fn picture -> Map.update(args, :picture, nil, fn picture ->
process_picture(picture, organizer_actor) process_picture(picture, organizer_actor)
@ -30,6 +32,7 @@ defmodule Mobilizon.GraphQL.API.Events do
@spec update_event(map, Event.t()) :: {:ok, Activity.t(), Event.t()} | any @spec update_event(map, Event.t()) :: {:ok, Activity.t(), Event.t()} | any
def update_event(args, %Event{} = event) do def update_event(args, %Event{} = event) do
with organizer_actor <- Map.get(args, :organizer_actor), with organizer_actor <- Map.get(args, :organizer_actor),
args <- extract_pictures_from_event_body(args, organizer_actor),
args <- args <-
Map.update(args, :picture, nil, fn picture -> Map.update(args, :picture, nil, fn picture ->
process_picture(picture, organizer_actor) process_picture(picture, organizer_actor)
@ -40,23 +43,32 @@ defmodule Mobilizon.GraphQL.API.Events do
@doc """ @doc """
Trigger the deletion of an event Trigger the deletion of an event
If the event is deleted by
""" """
def delete_event(%Event{} = event, %Actor{} = actor, federate \\ true) do def delete_event(%Event{} = event, %Actor{} = actor, federate \\ true) do
ActivityPub.delete(event, actor, federate) ActivityPub.delete(event, actor, federate)
end end
defp process_picture(nil, _), do: nil defp process_picture(nil, _), do: nil
defp process_picture(%{picture_id: _picture_id} = args, _), do: args defp process_picture(%{media_id: _picture_id} = args, _), do: args
defp process_picture(%{picture: picture}, %Actor{id: actor_id}) do defp process_picture(%{media: media}, %Actor{id: actor_id}) do
%{ %{
file: file:
picture media
|> Map.get(:file) |> Map.get(:file)
|> Utils.make_picture_data(description: Map.get(picture, :name)), |> Utils.make_media_data(description: Map.get(media, :name)),
actor_id: actor_id actor_id: actor_id
} }
end end
@spec extract_pictures_from_event_body(map(), Actor.t()) :: map()
defp extract_pictures_from_event_body(
%{description: description} = args,
%Actor{id: organizer_actor_id}
) do
pictures = APIUtils.extract_pictures_from_body(description, organizer_actor_id)
Map.put(args, :media, pictures)
end
defp extract_pictures_from_event_body(args, _), do: args
end end

View File

@ -3,7 +3,8 @@ defmodule Mobilizon.GraphQL.API.Utils do
Utils for API. Utils for API.
""" """
alias Mobilizon.Config alias Mobilizon.{Config, Medias}
alias Mobilizon.Medias.Media
alias Mobilizon.Service.Formatter alias Mobilizon.Service.Formatter
@doc """ @doc """
@ -40,4 +41,41 @@ defmodule Mobilizon.GraphQL.API.Utils do
{:error, "Comment must be up to #{max_size} characters"} {:error, "Comment must be up to #{max_size} characters"}
end end
end end
@doc """
Use the data-media-id attributes to extract media from body text
"""
@spec extract_pictures_from_body(String.t(), integer() | String.t()) :: list(Media.t())
def extract_pictures_from_body(body, actor_id) do
body
|> do_extract_pictures_from_body()
|> Enum.map(&fetch_picture(&1, actor_id))
|> Enum.filter(& &1)
end
@spec do_extract_pictures_from_body(String.t()) :: list(String.t())
defp do_extract_pictures_from_body(body) when is_nil(body) or body == "", do: []
defp do_extract_pictures_from_body(body) do
{:ok, document} = Floki.parse_document(body)
document
|> Floki.attribute("img", "data-media-id")
end
@spec fetch_picture(String.t() | integer(), String.t() | integer()) :: Media.t() | nil
defp fetch_picture(id, actor_id) do
with %Media{actor_id: media_actor_id} = media <- Medias.get_media(id),
{:owns_media, true} <-
{:owns_media, check_actor_owns_media?(actor_id, media_actor_id)} do
media
else
_ -> nil
end
end
@spec check_actor_owns_media?(integer() | String.t(), integer() | String.t()) :: boolean()
defp check_actor_owns_media?(actor_id, media_actor_id) do
actor_id == media_actor_id || Mobilizon.Actors.is_member?(media_actor_id, actor_id)
end
end end

View File

@ -7,6 +7,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Discussion do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.{Comment, Discussion} alias Mobilizon.Discussions.{Comment, Discussion}
alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub
alias Mobilizon.GraphQL.API.Comments
alias Mobilizon.Storage.Page alias Mobilizon.Storage.Page
alias Mobilizon.Users.User alias Mobilizon.Users.User
import Mobilizon.Web.Gettext import Mobilizon.Web.Gettext
@ -94,17 +95,13 @@ defmodule Mobilizon.GraphQL.Resolvers.Discussion do
with {:actor, %Actor{id: creator_id} = _actor} <- {:actor, Users.get_actor_for_user(user)}, with {:actor, %Actor{id: creator_id} = _actor} <- {:actor, Users.get_actor_for_user(user)},
{:member, true} <- {:member, Actors.is_member?(creator_id, group_id)}, {:member, true} <- {:member, Actors.is_member?(creator_id, group_id)},
{:ok, _activity, %Discussion{} = discussion} <- {:ok, _activity, %Discussion{} = discussion} <-
ActivityPub.create( Comments.create_discussion(%{
:discussion, title: title,
%{ text: text,
title: title, actor_id: group_id,
text: text, creator_id: creator_id,
actor_id: group_id, attributed_to_id: group_id
creator_id: creator_id, }) do
attributed_to_id: group_id
},
true
) do
{:ok, discussion} {:ok, discussion}
else else
{:member, false} -> {:member, false} ->
@ -134,19 +131,15 @@ defmodule Mobilizon.GraphQL.Resolvers.Discussion do
{:no_discussion, Discussions.get_discussion(discussion_id)}, {:no_discussion, Discussions.get_discussion(discussion_id)},
{:member, true} <- {:member, Actors.is_member?(creator_id, actor_id)}, {:member, true} <- {:member, Actors.is_member?(creator_id, actor_id)},
{:ok, _activity, %Discussion{} = discussion} <- {:ok, _activity, %Discussion{} = discussion} <-
ActivityPub.create( Comments.create_discussion(%{
:discussion, text: text,
%{ discussion_id: discussion_id,
text: text, actor_id: creator_id,
discussion_id: discussion_id, attributed_to_id: actor_id,
actor_id: creator_id, in_reply_to_comment_id: last_comment_id,
attributed_to_id: actor_id, origin_comment_id:
in_reply_to_comment_id: last_comment_id, origin_comment_id || previous_in_reply_to_comment_id || last_comment_id
origin_comment_id: }) do
origin_comment_id || previous_in_reply_to_comment_id || last_comment_id
},
true
) do
{:ok, discussion} {:ok, discussion}
end end
end end

View File

@ -96,8 +96,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
# TODO Move me to somewhere cleaner # TODO Move me to somewhere cleaner
defp save_attached_pictures(args) do defp save_attached_pictures(args) do
Enum.reduce([:avatar, :banner], args, fn key, args -> Enum.reduce([:avatar, :banner], args, fn key, args ->
if Map.has_key?(args, key) && !is_nil(args[key][:picture]) do if Map.has_key?(args, key) && !is_nil(args[key][:media]) do
pic = args[key][:picture] pic = args[key][:media]
with {:ok, %{name: name, url: url, content_type: content_type, size: _size}} <- with {:ok, %{name: name, url: url, content_type: content_type, size: _size}} <-
Upload.store(pic.file, type: key, description: pic.alt) do Upload.store(pic.file, type: key, description: pic.alt) do

View File

@ -1,50 +1,47 @@
defmodule Mobilizon.GraphQL.Resolvers.Picture do defmodule Mobilizon.GraphQL.Resolvers.Media do
@moduledoc """ @moduledoc """
Handles the picture-related GraphQL calls Handles the media-related GraphQL calls
""" """
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.{Media, Users} alias Mobilizon.{Medias, Users}
alias Mobilizon.Media.Picture alias Mobilizon.Medias.Media
alias Mobilizon.Users.User alias Mobilizon.Users.User
import Mobilizon.Web.Gettext import Mobilizon.Web.Gettext
@doc """ @doc """
Get picture for an event Get media for an event
See Mobilizon.Web.Resolvers.Event.create_event/3 See Mobilizon.Web.Resolvers.Event.create_event/3
""" """
def picture(%{picture_id: picture_id} = _parent, _args, _resolution) do def media(%{picture_id: media_id} = _parent, _args, _resolution) do
with {:ok, picture} <- do_fetch_picture(picture_id), do: {:ok, picture} with {:ok, media} <- do_fetch_media(media_id), do: {:ok, media}
end end
def picture(%{picture: picture} = _parent, _args, _resolution), do: {:ok, picture} def media(%{picture: media} = _parent, _args, _resolution), do: {:ok, media}
def picture(_parent, %{id: picture_id}, _resolution), do: do_fetch_picture(picture_id) def media(_parent, %{id: media_id}, _resolution), do: do_fetch_media(media_id)
def picture(_parent, _args, _resolution), do: {:ok, nil} def media(_parent, _args, _resolution), do: {:ok, nil}
@spec do_fetch_picture(nil) :: {:error, nil} def medias(%{media: medias}, _args, _resolution) do
defp do_fetch_picture(nil), do: {:error, nil} {:ok, Enum.map(medias, &transform_media/1)}
end
@spec do_fetch_picture(String.t()) :: {:ok, Picture.t()} | {:error, :not_found} @spec do_fetch_media(nil) :: {:error, nil}
defp do_fetch_picture(picture_id) do defp do_fetch_media(nil), do: {:error, nil}
case Media.get_picture(picture_id) do
%Picture{id: id, file: file} -> @spec do_fetch_media(String.t()) :: {:ok, Media.t()} | {:error, :not_found}
{:ok, defp do_fetch_media(media_id) do
%{ case Medias.get_media(media_id) do
name: file.name, %Media{} = media ->
url: file.url, {:ok, transform_media(media)}
id: id,
content_type: file.content_type,
size: file.size
}}
nil -> nil ->
{:error, :not_found} {:error, :not_found}
end end
end end
@spec upload_picture(map, map, map) :: {:ok, Picture.t()} | {:error, any} @spec upload_media(map, map, map) :: {:ok, Media.t()} | {:error, any}
def upload_picture( def upload_media(
_parent, _parent,
%{file: %Plug.Upload{} = file} = args, %{file: %Plug.Upload{} = file} = args,
%{context: %{current_user: %User{} = user}} %{context: %{current_user: %User{} = user}}
@ -57,16 +54,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
|> Map.put(:url, url) |> Map.put(:url, url)
|> Map.put(:size, size) |> Map.put(:size, size)
|> Map.put(:content_type, content_type), |> Map.put(:content_type, content_type),
{:ok, picture = %Picture{}} <- {:ok, media = %Media{}} <-
Media.create_picture(%{"file" => args, "actor_id" => actor_id}) do Medias.create_media(%{"file" => args, "actor_id" => actor_id}) do
{:ok, {:ok, transform_media(media)}
%{
name: picture.file.name,
url: picture.file.url,
id: picture.id,
content_type: picture.file.content_type,
size: picture.file.size
}}
else else
{:error, :mime_type_not_allowed} -> {:error, :mime_type_not_allowed} ->
{:error, dgettext("errors", "File doesn't have an allowed MIME type.")} {:error, dgettext("errors", "File doesn't have an allowed MIME type.")}
@ -76,28 +66,28 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
end end
end end
def upload_picture(_parent, _args, _resolution), do: {:error, :unauthenticated} def upload_media(_parent, _args, _resolution), do: {:error, :unauthenticated}
@doc """ @doc """
Remove a picture that the user owns Remove a media that the user owns
""" """
@spec remove_picture(map(), map(), map()) :: @spec remove_media(map(), map(), map()) ::
{:ok, Picture.t()} {:ok, Media.t()}
| {:error, :unauthorized} | {:error, :unauthorized}
| {:error, :unauthenticated} | {:error, :unauthenticated}
| {:error, :not_found} | {:error, :not_found}
def remove_picture(_parent, %{id: picture_id}, %{context: %{current_user: %User{} = user}}) do def remove_media(_parent, %{id: media_id}, %{context: %{current_user: %User{} = user}}) do
with {:picture, %Picture{actor_id: actor_id} = picture} <- with {:media, %Media{actor_id: actor_id} = media} <-
{:picture, Media.get_picture(picture_id)}, {:media, Medias.get_media(media_id)},
{:is_owned, %Actor{} = _actor} <- User.owns_actor(user, actor_id) do {:is_owned, %Actor{} = _actor} <- User.owns_actor(user, actor_id) do
Media.delete_picture(picture) Medias.delete_media(media)
else else
{:picture, nil} -> {:error, :not_found} {:media, nil} -> {:error, :not_found}
{:is_owned, _} -> {:error, :unauthorized} {:is_owned, _} -> {:error, :unauthorized}
end end
end end
def remove_picture(_parent, _args, _resolution), do: {:error, :unauthenticated} def remove_media(_parent, _args, _resolution), do: {:error, :unauthenticated}
@doc """ @doc """
Return the total media size for an actor Return the total media size for an actor
@ -108,7 +98,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
context: %{current_user: %User{} = user} context: %{current_user: %User{} = user}
}) do }) do
if can_get_actor_size?(user, actor_id) do if can_get_actor_size?(user, actor_id) do
{:ok, Media.media_size_for_actor(actor_id)} {:ok, Medias.media_size_for_actor(actor_id)}
else else
{:error, :unauthorized} {:error, :unauthorized}
end end
@ -125,7 +115,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
context: %{current_user: %User{} = logged_user} context: %{current_user: %User{} = logged_user}
}) do }) do
if can_get_user_size?(logged_user, user_id) do if can_get_user_size?(logged_user, user_id) do
{:ok, Media.media_size_for_user(user_id)} {:ok, Medias.media_size_for_user(user_id)}
else else
{:error, :unauthorized} {:error, :unauthorized}
end end
@ -133,6 +123,17 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
def user_size(_parent, _args, _resolution), do: {:error, :unauthenticated} def user_size(_parent, _args, _resolution), do: {:error, :unauthenticated}
@spec transform_media(Media.t()) :: map()
defp transform_media(%Media{id: id, file: file}) do
%{
name: file.name,
url: file.url,
id: id,
content_type: file.content_type,
size: file.size
}
end
@spec can_get_user_size?(User.t(), integer()) :: boolean() @spec can_get_user_size?(User.t(), integer()) :: boolean()
defp can_get_actor_size?(%User{role: role} = user, actor_id) do defp can_get_actor_size?(%User{role: role} = user, actor_id) do
role in [:moderator, :administrator] || owns_actor?(User.owns_actor(user, actor_id)) role in [:moderator, :administrator] || owns_actor?(User.owns_actor(user, actor_id))

View File

@ -13,6 +13,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
import Mobilizon.Web.Gettext import Mobilizon.Web.Gettext
alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub
require Logger
alias Mobilizon.Web.{MediaProxy, Upload} alias Mobilizon.Web.{MediaProxy, Upload}
@ -137,6 +138,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
%{id: id} = args, %{id: id} = args,
%{context: %{current_user: user}} = _resolution %{context: %{current_user: user}} = _resolution
) do ) do
require Logger
args = Map.put(args, :user_id, user.id) args = Map.put(args, :user_id, user.id)
with {:find_actor, %Actor{} = actor} <- with {:find_actor, %Actor{} = actor} <-
@ -198,11 +200,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
defp save_attached_pictures(args) do defp save_attached_pictures(args) do
Enum.reduce([:avatar, :banner], args, fn key, args -> Enum.reduce([:avatar, :banner], args, fn key, args ->
if Map.has_key?(args, key) && !is_nil(args[key][:picture]) do if Map.has_key?(args, key) && !is_nil(args[key][:media]) do
pic = args[key][:picture] media = args[key][:media]
with {:ok, %{name: name, url: url, content_type: content_type, size: _size}} <- with {:ok, %{name: name, url: url, content_type: content_type, size: _size}} <-
Upload.store(pic.file, type: key, description: pic.alt) do Upload.store(media.file, type: key, description: media.alt) do
Map.put(args, key, %{"name" => name, "url" => url, "mediaType" => content_type}) Map.put(args, key, %{"name" => name, "url" => url, "mediaType" => content_type})
end end
else else

View File

@ -116,6 +116,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
Map.update(args, :picture, nil, fn picture -> Map.update(args, :picture, nil, fn picture ->
process_picture(picture, group) process_picture(picture, group)
end), end),
args <- extract_pictures_from_post_body(args, actor_id),
{:ok, _, %Post{} = post} <- {:ok, _, %Post{} = post} <-
ActivityPub.create( ActivityPub.create(
:post, :post,
@ -156,6 +157,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
Map.update(args, :picture, nil, fn picture -> Map.update(args, :picture, nil, fn picture ->
process_picture(picture, group) process_picture(picture, group)
end), end),
args <- extract_pictures_from_post_body(args, actor_id),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)}, {:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Post{} = post} <- {:ok, _, %Post{} = post} <-
ActivityPub.update(post, args, true, %{"actor" => actor_url}) do ActivityPub.update(post, args, true, %{"actor" => actor_url}) do
@ -210,15 +212,23 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
end end
defp process_picture(nil, _), do: nil defp process_picture(nil, _), do: nil
defp process_picture(%{picture_id: _picture_id} = args, _), do: args defp process_picture(%{media_id: _picture_id} = args, _), do: args
defp process_picture(%{picture: picture}, %Actor{id: actor_id}) do defp process_picture(%{media: media}, %Actor{id: actor_id}) do
%{ %{
file: file:
picture media
|> Map.get(:file) |> Map.get(:file)
|> Utils.make_picture_data(description: Map.get(picture, :name)), |> Utils.make_media_data(description: Map.get(media, :name)),
actor_id: actor_id actor_id: actor_id
} }
end end
@spec extract_pictures_from_post_body(map(), String.t()) :: map()
defp extract_pictures_from_post_body(%{body: body} = args, actor_id) do
pictures = Mobilizon.GraphQL.API.Utils.extract_pictures_from_body(body, actor_id)
Map.put(args, :media, pictures)
end
defp extract_pictures_from_post_body(args, _actor_id), do: args
end end

View File

@ -529,7 +529,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
context: %{current_user: %User{id: logged_in_user_id}} context: %{current_user: %User{id: logged_in_user_id}}
}) })
when user_id == logged_in_user_id do when user_id == logged_in_user_id do
%{elements: elements, total: total} = Mobilizon.Media.pictures_for_user(user_id, page, limit) %{elements: elements, total: total} = Mobilizon.Medias.medias_for_user(user_id, page, limit)
{:ok, {:ok,
%{ %{

View File

@ -30,7 +30,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_types(Schema.Custom.Point) import_types(Schema.Custom.Point)
import_types(Schema.UserType) import_types(Schema.UserType)
import_types(Schema.PictureType) import_types(Schema.MediaType)
import_types(Schema.ActorInterface) import_types(Schema.ActorInterface)
import_types(Schema.Actors.PersonType) import_types(Schema.Actors.PersonType)
import_types(Schema.Actors.GroupType) import_types(Schema.Actors.GroupType)
@ -145,7 +145,7 @@ defmodule Mobilizon.GraphQL.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) import_fields(:media_queries)
import_fields(:report_queries) import_fields(:report_queries)
import_fields(:admin_queries) import_fields(:admin_queries)
import_fields(:todo_list_queries) import_fields(:todo_list_queries)
@ -168,7 +168,7 @@ defmodule Mobilizon.GraphQL.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) import_fields(:media_mutations)
import_fields(:report_mutations) import_fields(:report_mutations)
import_fields(:admin_mutations) import_fields(:admin_mutations)
import_fields(:todo_list_mutations) import_fields(:todo_list_mutations)

View File

@ -28,8 +28,8 @@ defmodule Mobilizon.GraphQL.Schema.ActorInterface do
field(:suspended, :boolean, description: "If the actor is suspended") field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar, :picture, description: "The actor's avatar picture") field(:avatar, :media, description: "The actor's avatar media")
field(:banner, :picture, description: "The actor's banner picture") field(:banner, :media, description: "The actor's banner media")
# 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

@ -3,7 +3,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.ApplicationType do
Schema representation for Group. Schema representation for Group.
""" """
alias Mobilizon.GraphQL.Resolvers.Picture alias Mobilizon.GraphQL.Resolvers.Media
use Absinthe.Schema.Notation use Absinthe.Schema.Notation
@desc """ @desc """
@ -27,8 +27,8 @@ defmodule Mobilizon.GraphQL.Schema.Actors.ApplicationType do
field(:suspended, :boolean, description: "If the actor is suspended") field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar, :picture, description: "The actor's avatar picture") field(:avatar, :media, description: "The actor's avatar media")
field(:banner, :picture, description: "The actor's banner picture") field(:banner, :media, description: "The actor's banner media")
# 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")
@ -37,7 +37,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.ApplicationType do
field(:followingCount, :integer, description: "Number of actors following this actor") field(:followingCount, :integer, description: "Number of actors following this actor")
field(:media_size, :integer, field(:media_size, :integer,
resolve: &Picture.actor_size/3, resolve: &Media.actor_size/3,
description: "The total size of the media from this actor" description: "The total size of the media from this actor"
) )
end end

View File

@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
import Absinthe.Resolution.Helpers, only: [dataloader: 1] import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Addresses alias Mobilizon.Addresses
alias Mobilizon.GraphQL.Resolvers.{Discussion, Group, Member, Picture, Post, Resource, Todos} alias Mobilizon.GraphQL.Resolvers.{Discussion, Group, Media, Member, Post, Resource, Todos}
alias Mobilizon.GraphQL.Schema alias Mobilizon.GraphQL.Schema
import_types(Schema.Actors.MemberType) import_types(Schema.Actors.MemberType)
@ -38,8 +38,8 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
field(:suspended, :boolean, description: "If the actor is suspended") field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar, :picture, description: "The actor's avatar picture") field(:avatar, :media, description: "The actor's avatar media")
field(:banner, :picture, description: "The actor's banner picture") field(:banner, :media, description: "The actor's banner media")
field(:physical_address, :address, field(:physical_address, :address,
resolve: dataloader(Addresses), resolve: dataloader(Addresses),
@ -53,7 +53,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
field(:followingCount, :integer, description: "Number of actors following this actor") field(:followingCount, :integer, description: "Number of actors following this actor")
field(:media_size, :integer, field(:media_size, :integer,
resolve: &Picture.actor_size/3, resolve: &Media.actor_size/3,
description: "The total size of the media from this actor" description: "The total size of the media from this actor"
) )
@ -198,14 +198,14 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
default_value: :public default_value: :public
) )
arg(:avatar, :picture_input, arg(:avatar, :media_input,
description: description:
"The avatar for the group, either as an object or directly the ID of an existing Picture" "The avatar for the group, either as an object or directly the ID of an existing media"
) )
arg(:banner, :picture_input, arg(:banner, :media_input,
description: description:
"The banner for the group, either as an object or directly the ID of an existing Picture" "The banner for the group, either as an object or directly the ID of an existing media"
) )
arg(:physical_address, :address_input, description: "The physical address for the group") arg(:physical_address, :address_input, description: "The physical address for the group")
@ -226,14 +226,14 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
description: "Whether the group can be join freely, with approval or is invite-only." description: "Whether the group can be join freely, with approval or is invite-only."
) )
arg(:avatar, :picture_input, arg(:avatar, :media_input,
description: description:
"The avatar for the group, either as an object or directly the ID of an existing Picture" "The avatar for the group, either as an object or directly the ID of an existing media"
) )
arg(:banner, :picture_input, arg(:banner, :media_input,
description: description:
"The banner for the group, either as an object or directly the ID of an existing Picture" "The banner for the group, either as an object or directly the ID of an existing media"
) )
arg(:physical_address, :address_input, description: "The physical address for the group") arg(:physical_address, :address_input, description: "The physical address for the group")

View File

@ -7,7 +7,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
import Absinthe.Resolution.Helpers, only: [dataloader: 1] import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Events alias Mobilizon.Events
alias Mobilizon.GraphQL.Resolvers.{Person, Picture} alias Mobilizon.GraphQL.Resolvers.{Media, Person}
alias Mobilizon.GraphQL.Schema alias Mobilizon.GraphQL.Schema
import_types(Schema.Events.FeedTokenType) import_types(Schema.Events.FeedTokenType)
@ -40,8 +40,8 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
field(:suspended, :boolean, description: "If the actor is suspended") field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar, :picture, description: "The actor's avatar picture") field(:avatar, :media, description: "The actor's avatar media")
field(:banner, :picture, description: "The actor's banner picture") field(:banner, :media, description: "The actor's banner media")
# 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")
@ -50,7 +50,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
field(:followingCount, :integer, description: "Number of actors following this actor") field(:followingCount, :integer, description: "Number of actors following this actor")
field(:media_size, :integer, field(:media_size, :integer,
resolve: &Picture.actor_size/3, resolve: &Media.actor_size/3,
description: "The total size of the media from this actor" description: "The total size of the media from this actor"
) )
@ -150,14 +150,14 @@ defmodule Mobilizon.GraphQL.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(:avatar, :picture_input, arg(:avatar, :media_input,
description: description:
"The avatar for the profile, either as an object or directly the ID of an existing Picture" "The avatar for the profile, either as an object or directly the ID of an existing media"
) )
arg(:banner, :picture_input, arg(:banner, :media_input,
description: description:
"The banner for the profile, either as an object or directly the ID of an existing Picture" "The banner for the profile, either as an object or directly the ID of an existing media"
) )
resolve(&Person.create_person/3) resolve(&Person.create_person/3)
@ -171,14 +171,14 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
arg(:summary, :string, description: "The summary for this profile") arg(:summary, :string, description: "The summary for this profile")
arg(:avatar, :picture_input, arg(:avatar, :media_input,
description: description:
"The avatar for the profile, either as an object or directly the ID of an existing Picture" "The avatar for the profile, either as an object or directly the ID of an existing media"
) )
arg(:banner, :picture_input, arg(:banner, :media_input,
description: description:
"The banner for the profile, either as an object or directly the ID of an existing Picture" "The banner for the profile, either as an object or directly the ID of an existing media"
) )
resolve(&Person.update_person/3) resolve(&Person.update_person/3)
@ -200,14 +200,14 @@ defmodule Mobilizon.GraphQL.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")
arg(:avatar, :picture_input, arg(:avatar, :media_input,
description: description:
"The avatar for the profile, either as an object or directly the ID of an existing Picture" "The avatar for the profile, either as an object or directly the ID of an existing media"
) )
arg(:banner, :picture_input, arg(:banner, :media_input,
description: description:
"The banner for the profile, either as an object or directly the ID of an existing Picture" "The banner for the profile, either as an object or directly the ID of an existing media"
) )
resolve(&Person.register_person/3) resolve(&Person.register_person/3)

View File

@ -43,7 +43,6 @@ defmodule Mobilizon.GraphQL.Schema.AddressType do
An address input An address input
""" """
input_object :address_input do input_object :address_input do
# Either a full picture object
field(:geom, :point, description: "The geocoordinates for the point where this address is") field(:geom, :point, description: "The geocoordinates for the point where this address is")
field(:street, :string, description: "The address's street name (with number)") field(:street, :string, description: "The address's street name (with number)")
field(:locality, :string, description: "The address's locality") field(:locality, :string, description: "The address's locality")

View File

@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
import Absinthe.Resolution.Helpers, only: [dataloader: 1] import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.{Actors, Addresses, Discussions} alias Mobilizon.{Actors, Addresses, Discussions}
alias Mobilizon.GraphQL.Resolvers.{Event, Picture, Tag} alias Mobilizon.GraphQL.Resolvers.{Event, Media, Tag}
alias Mobilizon.GraphQL.Schema alias Mobilizon.GraphQL.Schema
import_types(Schema.AddressType) import_types(Schema.AddressType)
@ -31,9 +31,14 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
field(:visibility, :event_visibility, description: "The event's visibility") field(:visibility, :event_visibility, description: "The event's visibility")
field(:join_options, :event_join_options, description: "The event's visibility") field(:join_options, :event_join_options, description: "The event's visibility")
field(:picture, :picture, field(:picture, :media,
description: "The event's picture", description: "The event's picture",
resolve: &Picture.picture/3 resolve: &Media.media/3
)
field(:media, list_of(:media),
description: "The event's media",
resolve: &Media.medias/3
) )
field(:publish_at, :datetime, description: "When the event was published") field(:publish_at, :datetime, description: "When the event was published")
@ -328,9 +333,9 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
description: "The list of tags associated to the event" description: "The list of tags associated to the event"
) )
arg(:picture, :picture_input, arg(:picture, :media_input,
description: description:
"The picture for the event, either as an object or directly the ID of an existing Picture" "The picture for the event, either as an object or directly the ID of an existing media"
) )
arg(:publish_at, :datetime, description: "Datetime when the event was published") arg(:publish_at, :datetime, description: "Datetime when the event was published")
@ -379,9 +384,9 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
arg(:tags, list_of(:string), description: "The list of tags associated to the event") arg(:tags, list_of(:string), description: "The list of tags associated to the event")
arg(:picture, :picture_input, arg(:picture, :media_input,
description: description:
"The picture for the event, either as an object or directly the ID of an existing Picture" "The picture for the event, either as an object or directly the ID of an existing media"
) )
arg(:online_address, :string, description: "Online address of the event") arg(:online_address, :string, description: "Online address of the event")

View File

@ -0,0 +1,68 @@
defmodule Mobilizon.GraphQL.Schema.MediaType do
@moduledoc """
Schema representation for Medias
"""
use Absinthe.Schema.Notation
alias Mobilizon.GraphQL.Resolvers.Media
@desc "A media"
object :media do
field(:id, :id, description: "The media's ID")
field(:alt, :string, description: "The media's alternative text")
field(:name, :string, description: "The media's name")
field(:url, :string, description: "The media's full URL")
field(:content_type, :string, description: "The media's detected content type")
field(:size, :integer, description: "The media's size")
end
@desc """
A paginated list of medias
"""
object :paginated_media_list do
field(:elements, list_of(:media), description: "The list of medias")
field(:total, :integer, description: "The total number of medias in the list")
end
@desc "An attached media or a link to a media"
input_object :media_input do
# Either a full media object
field(:media, :media_input_object, description: "A full media attached")
# Or directly the ID of an existing media
field(:media_id, :id, description: "The ID of an existing media")
end
@desc "An attached media"
input_object :media_input_object do
field(:name, non_null(:string), description: "The media's name")
field(:alt, :string, description: "The media's alternative text")
field(:file, non_null(:upload), description: "The media file")
field(:actor_id, :id, description: "The media owner")
end
object :media_queries do
@desc "Get a media"
field :media, :media do
arg(:id, non_null(:id), description: "The media ID")
resolve(&Media.media/3)
end
end
object :media_mutations do
@desc "Upload a media"
field :upload_media, :media do
arg(:name, non_null(:string), description: "The media's name")
arg(:alt, :string, description: "The media's alternative text")
arg(:file, non_null(:upload), description: "The media file")
resolve(&Media.upload_media/3)
end
@desc """
Remove a media
"""
field :remove_media, :deleted_object do
arg(:id, non_null(:id), description: "The media's ID")
resolve(&Media.remove_media/3)
end
end
end

View File

@ -1,68 +0,0 @@
defmodule Mobilizon.GraphQL.Schema.PictureType do
@moduledoc """
Schema representation for Pictures
"""
use Absinthe.Schema.Notation
alias Mobilizon.GraphQL.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")
field(:content_type, :string, description: "The picture's detected content type")
field(:size, :integer, description: "The picture's size")
end
@desc """
A paginated list of pictures
"""
object :paginated_picture_list do
field(:elements, list_of(:picture), description: "The list of pictures")
field(:total, :integer, description: "The total number of pictures in the list")
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, description: "A full picture attached")
# Or directly the ID of an existing picture
field(:picture_id, :id, description: "The ID of an existing picture")
end
@desc "An attached picture"
input_object :picture_input_object do
field(:name, non_null(:string), description: "The picture's name")
field(:alt, :string, description: "The picture's alternative text")
field(:file, non_null(:upload), description: "The picture file")
field(:actor_id, :id, description: "The picture owner")
end
object :picture_queries do
@desc "Get a picture"
field :picture, :picture do
arg(:id, non_null(:id), description: "The picture ID")
resolve(&Picture.picture/3)
end
end
object :picture_mutations do
@desc "Upload a picture"
field :upload_picture, :picture do
arg(:name, non_null(:string), description: "The picture's name")
arg(:alt, :string, description: "The picture's alternative text")
arg(:file, non_null(:upload), description: "The picture file")
resolve(&Picture.upload_picture/3)
end
@desc """
Remove a picture
"""
field :remove_picture, :deleted_object do
arg(:id, non_null(:id), description: "The picture's ID")
resolve(&Picture.remove_picture/3)
end
end
end

View File

@ -3,7 +3,7 @@ defmodule Mobilizon.GraphQL.Schema.PostType do
Schema representation for Posts Schema representation for Posts
""" """
use Absinthe.Schema.Notation use Absinthe.Schema.Notation
alias Mobilizon.GraphQL.Resolvers.{Picture, Post, Tag} alias Mobilizon.GraphQL.Resolvers.{Media, Post, Tag}
@desc "A post" @desc "A post"
object :post do object :post do
@ -25,9 +25,9 @@ defmodule Mobilizon.GraphQL.Schema.PostType do
description: "The post's tags" description: "The post's tags"
) )
field(:picture, :picture, field(:picture, :media,
description: "The posts's picture", description: "The posts's media",
resolve: &Picture.picture/3 resolve: &Media.media/3
) )
end end
@ -76,9 +76,9 @@ defmodule Mobilizon.GraphQL.Schema.PostType do
description: "The list of tags associated to the post" description: "The list of tags associated to the post"
) )
arg(:picture, :picture_input, arg(:picture, :media_input,
description: description:
"The banner for the post, either as an object or directly the ID of an existing Picture" "The banner for the post, either as an object or directly the ID of an existing media"
) )
resolve(&Post.create_post/3) resolve(&Post.create_post/3)
@ -99,9 +99,9 @@ defmodule Mobilizon.GraphQL.Schema.PostType do
arg(:tags, list_of(:string), description: "The list of tags associated to the post") arg(:tags, list_of(:string), description: "The list of tags associated to the post")
arg(:picture, :picture_input, arg(:picture, :media_input,
description: description:
"The banner for the post, either as an object or directly the ID of an existing Picture" "The banner for the post, either as an object or directly the ID of an existing media"
) )
resolve(&Post.update_post/3) resolve(&Post.update_post/3)

View File

@ -7,7 +7,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
import Absinthe.Resolution.Helpers, only: [dataloader: 1] import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Events alias Mobilizon.Events
alias Mobilizon.GraphQL.Resolvers.{Picture, User} alias Mobilizon.GraphQL.Resolvers.{Media, User}
alias Mobilizon.GraphQL.Schema alias Mobilizon.GraphQL.Schema
import_types(Schema.SortType) import_types(Schema.SortType)
@ -111,7 +111,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
description: "The IP adress the user's currently signed-in with" description: "The IP adress the user's currently signed-in with"
) )
field(:media, :paginated_picture_list, description: "The user's media objects") do field(:media, :paginated_media_list, description: "The user's media objects") do
arg(:page, :integer, arg(:page, :integer,
default_value: 1, default_value: 1,
description: "The page in the paginated user media list" description: "The page in the paginated user media list"
@ -122,7 +122,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
end end
field(:media_size, :integer, field(:media_size, :integer,
resolve: &Picture.user_size/3, resolve: &Media.user_size/3,
description: "The total size of all the media from this user (from all their actors)" description: "The total size of all the media from this user (from all their actors)"
) )
end end

View File

@ -47,12 +47,14 @@ defmodule Mix.Tasks.Mobilizon.Common do
else: shell_prompt(message, "Continue?") in ~w(Yn Y y) else: shell_prompt(message, "Continue?") in ~w(Yn Y y)
end end
@spec shell_info(String.t()) :: :ok
def shell_info(message) do def shell_info(message) do
if mix_shell?(), if mix_shell?(),
do: Mix.shell().info(message), do: Mix.shell().info(message),
else: IO.puts(message) else: IO.puts(message)
end end
@spec shell_error(String.t()) :: :ok
def shell_error(message) do def shell_error(message) do
if mix_shell?(), if mix_shell?(),
do: Mix.shell().error(message), do: Mix.shell().error(message),

View File

@ -0,0 +1,23 @@
defmodule Mix.Tasks.Mobilizon.Maintenance do
@moduledoc """
Tasks to maintain mobilizon
"""
use Mix.Task
alias Mix.Tasks
import Mix.Tasks.Mobilizon.Common
@shortdoc "List common Mobilizon maintenance tasks"
@impl Mix.Task
def run(_) do
shell_info("\nAvailable tasks:")
if mix_shell?() do
Tasks.Help.run(["--search", "mobilizon.maintenance."])
else
show_subtasks_for_module(__MODULE__)
end
end
end

View File

@ -0,0 +1,107 @@
defmodule Mix.Tasks.Mobilizon.Maintenance.FixUnattachedMediaInBody do
@moduledoc """
Task to reattach media files that were added in event, post or comment bodies without being attached to their entities.
This task should only be run once.
"""
use Mix.Task
import Mix.Tasks.Mobilizon.Common
alias Mobilizon.{Discussions, Events, Medias, Posts}
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Posts.Post
alias Mobilizon.Storage.Repo
require Logger
@preferred_cli_env "prod"
# TODO: Remove me in Mobilizon 1.2
@shortdoc "Reattaches inline media from events and posts"
def run([]) do
start_mobilizon()
shell_info("Going to extract pictures from events")
extract_inline_pictures_from_bodies(Event)
shell_info("Going to extract pictures from posts")
extract_inline_pictures_from_bodies(Post)
shell_info("Going to extract pictures from comments")
extract_inline_pictures_from_bodies(Comment)
end
defp extract_inline_pictures_from_bodies(entity) do
Repo.transaction(
fn ->
entity
|> Repo.stream()
|> Stream.map(&extract_pictures(&1))
|> Stream.map(fn {entity, pics} -> save_entity(entity, pics) end)
|> Stream.run()
end,
timeout: :infinity
)
end
defp extract_pictures(entity) do
extracted_pictures = entity |> get_body() |> parse_body() |> get_media_entities_from_urls()
attached_picture = entity |> get_picture() |> get_media_entity_from_media_id()
attached_pictures = [attached_picture] |> Enum.filter(& &1)
{entity, extracted_pictures ++ attached_pictures}
end
defp get_body(%Event{description: description}), do: description
defp get_body(%Post{body: body}), do: body
defp get_body(%Comment{text: text}), do: text
defp get_picture(%Event{picture_id: picture_id}), do: picture_id
defp get_picture(%Post{picture_id: picture_id}), do: picture_id
defp get_picture(%Comment{}), do: nil
defp parse_body(nil), do: []
defp parse_body(body) do
with res <- Regex.scan(~r/<img\s[^>]*?src\s*=\s*['\"]([^'\"]*?)['\"][^>]*?>/, body),
res <- Enum.map(res, fn [_, res] -> res end) do
res
end
end
defp get_media_entities_from_urls(media_urls) do
media_urls
|> Enum.map(fn media_url ->
# We prefer orphan media, but fallback on already attached media just in case
Medias.get_unattached_media_by_url(media_url) || Medias.get_media_by_url(media_url)
end)
|> Enum.filter(& &1)
end
defp get_media_entity_from_media_id(nil), do: nil
defp get_media_entity_from_media_id(media_id) do
Medias.get_media(media_id)
end
defp save_entity(%Event{} = _event, []), do: :ok
defp save_entity(%Event{} = event, media) do
event = Repo.preload(event, [:contacts, :media])
Events.update_event(event, %{media: media})
end
defp save_entity(%Post{} = _post, []), do: :ok
defp save_entity(%Post{} = post, media) do
post = Repo.preload(post, [:media])
Posts.update_post(post, %{media: media})
end
defp save_entity(%Comment{} = _comment, []), do: :ok
defp save_entity(%Comment{} = comment, media) do
comment = Repo.preload(comment, [:media])
Discussions.update_comment(comment, %{media: media})
end
end

View File

@ -0,0 +1,23 @@
defmodule Mix.Tasks.Mobilizon.Media do
@moduledoc """
Tasks to manage media
"""
use Mix.Task
alias Mix.Tasks
import Mix.Tasks.Mobilizon.Common
@shortdoc "Manages Mobilizon media"
@impl Mix.Task
def run(_) do
shell_info("\nAvailable tasks:")
if mix_shell?() do
Tasks.Help.run(["--search", "mobilizon.media."])
else
show_subtasks_for_module(__MODULE__)
end
end
end

View File

@ -0,0 +1,87 @@
defmodule Mix.Tasks.Mobilizon.Media.CleanOrphan do
@moduledoc """
Task to accept an instance follow request
"""
use Mix.Task
import Mix.Tasks.Mobilizon.Common
alias Mobilizon.Service.CleanOrphanMedia
@shortdoc "Clean orphan media"
@grace_period Mobilizon.Config.get([:instance, :orphan_upload_grace_period_hours], 48)
@impl Mix.Task
def run(options) do
{options, [], []} =
OptionParser.parse(
options,
strict: [
dry_run: :boolean,
days: :integer,
verbose: :boolean
],
aliases: [
d: :days,
v: :verbose
]
)
dry_run = Keyword.get(options, :dry_run, false)
grace_period = Keyword.get(options, :days)
grace_period = if is_nil(grace_period), do: @grace_period, else: grace_period * 24
verbose = Keyword.get(options, :verbose, false)
start_mobilizon()
case CleanOrphanMedia.clean(dry_run: dry_run, grace_period: grace_period) do
{:ok, medias} ->
if length(medias) > 0 do
if dry_run or verbose do
details(medias, dry_run, verbose)
end
result(dry_run, length(medias))
else
empty_result(dry_run)
end
:ok
_err ->
shell_error("Error while cleaning orphan media files")
end
end
@spec details(list(Media.t()), boolean(), boolean()) :: :ok
defp details(medias, dry_run, verbose) do
cond do
dry_run ->
shell_info("List of files that would have been deleted")
verbose ->
shell_info("List of files that have been deleted")
end
Enum.each(medias, fn media ->
shell_info("ID: #{media.id}, Actor: #{media.actor_id}, URL: #{media.file.url}")
end)
end
@spec result(boolean(), boolean()) :: :ok
defp result(dry_run, nb_medias) do
if dry_run do
shell_info("#{nb_medias} files would have been deleted")
else
shell_info("#{nb_medias} files have been deleted")
end
end
@spec empty_result(boolean()) :: :ok
defp empty_result(dry_run) do
if dry_run do
shell_info("No files would have been deleted")
else
shell_info("No files were deleted")
end
end
end

View File

@ -58,7 +58,11 @@ defmodule Mobilizon do
cachex_spec(:statistics, 10, 60, 60), cachex_spec(:statistics, 10, 60, 60),
cachex_spec(:config, 10, 60, 60), cachex_spec(:config, 10, 60, 60),
cachex_spec(:rich_media_cache, 10, 60, 60), cachex_spec(:rich_media_cache, 10, 60, 60),
cachex_spec(:activity_pub, 2500, 3, 15) cachex_spec(:activity_pub, 2500, 3, 15),
%{
id: :cache_key_value,
start: {Cachex, :start_link, [:key_value]}
}
] ++ ] ++
task_children(@env) task_children(@env)

View File

@ -12,7 +12,7 @@ defmodule Mobilizon.Actors.Actor do
alias Mobilizon.Addresses.Address alias Mobilizon.Addresses.Address
alias Mobilizon.Discussions.Comment alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.{Event, FeedToken} alias Mobilizon.Events.{Event, FeedToken}
alias Mobilizon.Media.File alias Mobilizon.Medias.File
alias Mobilizon.Reports.{Note, Report} alias Mobilizon.Reports.{Note, Report}
alias Mobilizon.Users.User alias Mobilizon.Users.User

View File

@ -14,7 +14,7 @@ defmodule Mobilizon.Actors do
alias Mobilizon.Addresses.Address alias Mobilizon.Addresses.Address
alias Mobilizon.{Crypto, Events} alias Mobilizon.{Crypto, Events}
alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Media.File alias Mobilizon.Medias.File
alias Mobilizon.Service.Workers alias Mobilizon.Service.Workers
alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users alias Mobilizon.Users
@ -285,7 +285,7 @@ defmodule Mobilizon.Actors do
# if is_nil(file) do # if is_nil(file) do
# nil # nil
# else # else
# struct(Mobilizon.Media.File, file) # struct(Mobilizon.Medias.File, file)
# end # end
# end # end
@ -1673,7 +1673,8 @@ defmodule Mobilizon.Actors do
:attributed_to, :attributed_to,
:tags, :tags,
:physical_address, :physical_address,
:contacts :contacts,
:media
]) ])
ActivityPub.delete(event, actor, false) ActivityPub.delete(event, actor, false)

View File

@ -11,6 +11,7 @@ defmodule Mobilizon.Discussions.Comment do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.{Comment, CommentVisibility, Discussion} alias Mobilizon.Discussions.{Comment, CommentVisibility, Discussion}
alias Mobilizon.Events.{Event, Tag} alias Mobilizon.Events.{Event, Tag}
alias Mobilizon.Medias.Media
alias Mobilizon.Mention alias Mobilizon.Mention
alias Mobilizon.Web.Endpoint alias Mobilizon.Web.Endpoint
@ -27,6 +28,7 @@ defmodule Mobilizon.Discussions.Comment do
event: Event.t(), event: Event.t(),
tags: [Tag.t()], tags: [Tag.t()],
mentions: [Mention.t()], mentions: [Mention.t()],
media: [Media.t()],
in_reply_to_comment: t, in_reply_to_comment: t,
origin_comment: t origin_comment: t
} }
@ -66,6 +68,7 @@ defmodule Mobilizon.Discussions.Comment do
has_many(:replies, Comment, foreign_key: :in_reply_to_comment_id) has_many(:replies, Comment, foreign_key: :in_reply_to_comment_id)
many_to_many(:tags, Tag, join_through: "comments_tags", on_replace: :delete) many_to_many(:tags, Tag, join_through: "comments_tags", on_replace: :delete)
has_many(:mentions, Mention) has_many(:mentions, Mention)
many_to_many(:media, Media, join_through: "comments_medias", on_replace: :delete)
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@ -120,6 +123,7 @@ defmodule Mobilizon.Discussions.Comment do
|> maybe_add_published_at() |> maybe_add_published_at()
|> maybe_generate_uuid() |> maybe_generate_uuid()
|> maybe_generate_url() |> maybe_generate_url()
|> put_assoc(:media, Map.get(attrs, :media, []))
|> put_tags(attrs) |> put_tags(attrs)
|> put_mentions(attrs) |> put_mentions(attrs)
end end

View File

@ -10,7 +10,7 @@ defmodule Mobilizon.Events.Event do
alias Ecto.Changeset alias Ecto.Changeset
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.{Addresses, Events, Media, Mention} alias Mobilizon.{Addresses, Events, Medias, Mention}
alias Mobilizon.Addresses.Address alias Mobilizon.Addresses.Address
alias Mobilizon.Discussions.Comment alias Mobilizon.Discussions.Comment
@ -27,7 +27,7 @@ defmodule Mobilizon.Events.Event do
Track Track
} }
alias Mobilizon.Media.Picture alias Mobilizon.Medias.Media
alias Mobilizon.Storage.Repo alias Mobilizon.Storage.Repo
alias Mobilizon.Web.Endpoint alias Mobilizon.Web.Endpoint
@ -54,7 +54,8 @@ defmodule Mobilizon.Events.Event do
organizer_actor: Actor.t(), organizer_actor: Actor.t(),
attributed_to: Actor.t(), attributed_to: Actor.t(),
physical_address: Address.t(), physical_address: Address.t(),
picture: Picture.t(), picture: Media.t(),
media: [Media.t()],
tracks: [Track.t()], tracks: [Track.t()],
sessions: [Session.t()], sessions: [Session.t()],
mentions: [Mention.t()], mentions: [Mention.t()],
@ -110,7 +111,7 @@ defmodule Mobilizon.Events.Event do
belongs_to(:organizer_actor, Actor, foreign_key: :organizer_actor_id) belongs_to(:organizer_actor, Actor, foreign_key: :organizer_actor_id)
belongs_to(:attributed_to, Actor, foreign_key: :attributed_to_id) belongs_to(:attributed_to, Actor, foreign_key: :attributed_to_id)
belongs_to(:physical_address, Address, on_replace: :nilify) belongs_to(:physical_address, Address, on_replace: :nilify)
belongs_to(:picture, Picture, on_replace: :update) belongs_to(:picture, Media, on_replace: :update)
has_many(:tracks, Track) has_many(:tracks, Track)
has_many(:sessions, Session) has_many(:sessions, Session)
has_many(:mentions, Mention) has_many(:mentions, Mention)
@ -118,6 +119,7 @@ defmodule Mobilizon.Events.Event do
many_to_many(:contacts, Actor, join_through: "event_contacts", on_replace: :delete) many_to_many(:contacts, Actor, join_through: "event_contacts", on_replace: :delete)
many_to_many(:tags, Tag, join_through: "events_tags", on_replace: :delete) many_to_many(:tags, Tag, join_through: "events_tags", on_replace: :delete)
many_to_many(:participants, Actor, join_through: Participant) many_to_many(:participants, Actor, join_through: Participant)
many_to_many(:media, Media, join_through: "events_medias", on_replace: :delete)
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@ -150,6 +152,7 @@ defmodule Mobilizon.Events.Event do
changeset changeset
|> cast_embed(:options) |> cast_embed(:options)
|> put_assoc(:contacts, Map.get(attrs, :contacts, [])) |> put_assoc(:contacts, Map.get(attrs, :contacts, []))
|> put_assoc(:media, Map.get(attrs, :media, []))
|> put_tags(attrs) |> put_tags(attrs)
|> put_address(attrs) |> put_address(attrs)
|> put_picture(attrs) |> put_picture(attrs)
@ -241,9 +244,9 @@ defmodule Mobilizon.Events.Event do
# In case the provided picture is an existing one # In case the provided picture is an existing one
@spec put_picture(Changeset.t(), map) :: Changeset.t() @spec put_picture(Changeset.t(), map) :: Changeset.t()
defp put_picture(%Changeset{} = changeset, %{picture: %{picture_id: id} = _picture}) do defp put_picture(%Changeset{} = changeset, %{picture: %{media_id: id} = _picture}) do
case Media.get_picture!(id) do case Medias.get_media!(id) do
%Picture{} = picture -> %Media{} = picture ->
put_assoc(changeset, :picture, picture) put_assoc(changeset, :picture, picture)
_ -> _ ->

View File

@ -84,7 +84,8 @@ defmodule Mobilizon.Events do
:participants, :participants,
:physical_address, :physical_address,
:picture, :picture,
:contacts :contacts,
:media
] ]
@doc """ @doc """
@ -295,7 +296,7 @@ defmodule Mobilizon.Events do
@spec update_event(Event.t(), map) :: {:ok, Event.t()} | {:error, Changeset.t()} @spec update_event(Event.t(), map) :: {:ok, Event.t()} | {:error, Changeset.t()}
def update_event(%Event{draft: old_draft} = old_event, attrs) do def update_event(%Event{draft: old_draft} = old_event, attrs) do
with %Changeset{changes: changes} = changeset <- with %Changeset{changes: changes} = changeset <-
Event.update_changeset(Repo.preload(old_event, :tags), attrs), Event.update_changeset(Repo.preload(old_event, [:tags, :media]), attrs),
{:ok, %{update: %Event{} = new_event}} <- {:ok, %{update: %Event{} = new_event}} <-
Multi.new() Multi.new()
|> Multi.update(:update, changeset) |> Multi.update(:update, changeset)

View File

@ -1,150 +0,0 @@
defmodule Mobilizon.Media do
@moduledoc """
The Media context.
"""
import Ecto.Query
alias Ecto.Multi
alias Mobilizon.Actors.Actor
alias Mobilizon.Media.{File, Picture}
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.User
alias Mobilizon.Web.Upload
@doc """
Gets a single picture.
"""
@spec get_picture(integer | String.t()) :: Picture.t() | nil
def get_picture(id), do: Repo.get(Picture, id)
@doc """
Gets a single picture.
Raises `Ecto.NoResultsError` if the picture does not exist.
"""
@spec get_picture!(integer | String.t()) :: Picture.t()
def get_picture!(id), do: Repo.get!(Picture, id)
@doc """
Get a picture by its URL.
"""
@spec get_picture_by_url(String.t()) :: Picture.t() | nil
def get_picture_by_url(url) do
url
|> picture_by_url_query()
|> Repo.one()
end
@doc """
List the paginated picture for an actor
"""
@spec pictures_for_actor(integer | String.t(), integer | nil, integer | nil) :: Page.t()
def pictures_for_actor(actor_id, page, limit) do
actor_id
|> pictures_for_actor_query()
|> Page.build_page(page, limit)
end
@doc """
List the paginated picture for user
"""
@spec pictures_for_user(integer | String.t(), integer | nil, integer | nil) :: Page.t()
def pictures_for_user(user_id, page, limit) do
user_id
|> pictures_for_user_query()
|> Page.build_page(page, limit)
end
@doc """
Calculate the sum of media size used by the user
"""
@spec media_size_for_actor(integer | String.t()) :: integer()
def media_size_for_actor(actor_id) do
actor_id
|> pictures_for_actor_query()
|> select([:file])
|> Repo.all()
|> Enum.map(& &1.file.size)
|> Enum.sum()
end
@doc """
Calculate the sum of media size used by the user
"""
@spec media_size_for_user(integer | String.t()) :: integer()
def media_size_for_user(user_id) do
user_id
|> pictures_for_user_query()
|> select([:file])
|> Repo.all()
|> Enum.map(& &1.file.size)
|> Enum.sum()
end
@doc """
Creates a picture.
"""
@spec create_picture(map) :: {:ok, Picture.t()} | {:error, Ecto.Changeset.t()}
def create_picture(attrs \\ %{}) do
%Picture{}
|> Picture.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a picture.
"""
@spec update_picture(Picture.t(), map) :: {:ok, Picture.t()} | {:error, Ecto.Changeset.t()}
def update_picture(%Picture{} = picture, attrs) do
picture
|> Picture.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a picture.
"""
@spec delete_picture(Picture.t()) :: {:ok, Picture.t()} | {:error, Ecto.Changeset.t()}
def delete_picture(%Picture{} = picture) do
transaction =
Multi.new()
|> Multi.delete(:picture, picture)
|> Multi.run(:remove, fn _repo, %{picture: %Picture{file: %File{url: url}}} ->
Upload.remove(url)
end)
|> Repo.transaction()
case transaction do
{:ok, %{picture: %Picture{} = picture}} ->
{:ok, picture}
{:error, :remove, error, _} ->
{:error, error}
end
end
@spec picture_by_url_query(String.t()) :: Ecto.Query.t()
defp picture_by_url_query(url) do
from(
p in Picture,
where: fragment("? @> ?", p.file, ~s|{"url": "#{url}"}|)
)
end
@spec pictures_for_actor_query(integer() | String.t()) :: Ecto.Query.t()
defp pictures_for_actor_query(actor_id) do
Picture
|> join(:inner, [p], a in Actor, on: p.actor_id == a.id)
|> where([_p, a], a.id == ^actor_id)
end
@spec pictures_for_user_query(integer() | String.t()) :: Ecto.Query.t()
defp pictures_for_user_query(user_id) do
Picture
|> join(:inner, [p], a in Actor, on: p.actor_id == a.id)
|> join(:inner, [_p, a], u in User, on: a.user_id == u.id)
|> where([_p, _a, u], u.id == ^user_id)
end
end

View File

@ -1,32 +0,0 @@
defmodule Mobilizon.Media.Picture do
@moduledoc """
Represents a picture entity.
"""
use Ecto.Schema
import Ecto.Changeset, only: [cast: 3, cast_embed: 2]
alias Mobilizon.Actors.Actor
alias Mobilizon.Media.File
@type t :: %__MODULE__{
file: File.t(),
actor: Actor.t()
}
schema "pictures" do
embeds_one(:file, File, on_replace: :update)
belongs_to(:actor, Actor)
timestamps()
end
@doc false
@spec changeset(t, map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = picture, attrs) do
picture
|> cast(attrs, [:actor_id])
|> cast_embed(:file)
end
end

View File

@ -1,4 +1,4 @@
defmodule Mobilizon.Media.File do defmodule Mobilizon.Medias.File do
@moduledoc """ @moduledoc """
Represents a file entity. Represents a file entity.
""" """

View File

@ -0,0 +1,40 @@
defmodule Mobilizon.Medias.Media do
@moduledoc """
Represents a media entity.
"""
use Ecto.Schema
import Ecto.Changeset, only: [cast: 3, cast_embed: 2]
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event
alias Mobilizon.Medias.File
alias Mobilizon.Posts.Post
@type t :: %__MODULE__{
file: File.t(),
actor: Actor.t()
}
schema "medias" do
embeds_one(:file, File, on_replace: :update)
belongs_to(:actor, Actor)
has_many(:event_picture, Event, foreign_key: :picture_id)
many_to_many(:events, Event, join_through: "events_medias")
has_many(:posts_picture, Post, foreign_key: :picture_id)
many_to_many(:posts, Post, join_through: "posts_medias")
many_to_many(:comments, Comment, join_through: "comments_medias")
timestamps()
end
@doc false
@spec changeset(t, map) :: Ecto.Changeset.t()
def changeset(%__MODULE__{} = media, attrs) do
media
|> cast(attrs, [:actor_id])
|> cast_embed(:file)
end
end

View File

@ -0,0 +1,184 @@
defmodule Mobilizon.Medias do
@moduledoc """
The Media context.
"""
import Ecto.Query
alias Ecto.Multi
alias Mobilizon.Actors.Actor
alias Mobilizon.Medias.{File, Media}
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.User
alias Mobilizon.Web.Upload
require Logger
@doc """
Gets a single media.
"""
@spec get_media(integer | String.t()) :: Media.t() | nil
def get_media(id), do: Repo.get(Media, id)
@doc """
Gets a single media.
Raises `Ecto.NoResultsError` if the media does not exist.
"""
@spec get_media!(integer | String.t()) :: Media.t()
def get_media!(id), do: Repo.get!(Media, id)
@doc """
Get a media by its URL.
"""
@spec get_media_by_url(String.t()) :: Media.t() | nil
def get_media_by_url(url) do
url
|> media_by_url_query()
|> limit(1)
|> Repo.one()
end
@doc """
Get an unattached media by it's URL
"""
def get_unattached_media_by_url(url) do
url
|> media_by_url_query()
|> join(:left, [m], e in assoc(m, :events))
|> join(:left, [m], ep in assoc(m, :event_picture))
|> join(:left, [m], p in assoc(m, :posts))
|> join(:left, [m], pp in assoc(m, :posts_picture))
|> join(:left, [m], c in assoc(m, :comments))
|> where([_m, e], is_nil(e.id))
|> where([_m, _e, ep], is_nil(ep.id))
|> where([_m, _e, _ep, p], is_nil(p.id))
|> where([_m, _e, _ep, _p, pp], is_nil(pp.id))
|> where([_m, _e, _ep, _p, _pp, c], is_nil(c.id))
|> limit(1)
|> Repo.one()
end
@doc """
List the paginated media for an actor
"""
@spec medias_for_actor(integer | String.t(), integer | nil, integer | nil) :: Page.t()
def medias_for_actor(actor_id, page, limit) do
actor_id
|> medias_for_actor_query()
|> Page.build_page(page, limit)
end
@doc """
List the paginated media for user
"""
@spec medias_for_user(integer | String.t(), integer | nil, integer | nil) :: Page.t()
def medias_for_user(user_id, page, limit) do
user_id
|> medias_for_user_query()
|> Page.build_page(page, limit)
end
@doc """
Calculate the sum of media size used by the user
"""
@spec media_size_for_actor(integer | String.t()) :: integer()
def media_size_for_actor(actor_id) do
actor_id
|> medias_for_actor_query()
|> select([:file])
|> Repo.all()
|> Enum.map(& &1.file.size)
|> Enum.filter(& &1)
|> Enum.sum()
end
@doc """
Calculate the sum of media size used by the user
"""
@spec media_size_for_user(integer | String.t()) :: integer()
def media_size_for_user(user_id) do
user_id
|> medias_for_user_query()
|> select([:file])
|> Repo.all()
|> Enum.map(& &1.file.size)
|> Enum.sum()
end
@doc """
Creates a media.
"""
@spec create_media(map) :: {:ok, Media.t()} | {:error, Ecto.Changeset.t()}
def create_media(attrs \\ %{}) do
%Media{}
|> Media.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a media.
"""
@spec update_media(Media.t(), map) :: {:ok, Media.t()} | {:error, Ecto.Changeset.t()}
def update_media(%Media{} = media, attrs) do
media
|> Media.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a media.
"""
@spec delete_media(Media.t()) :: {:ok, Media.t()} | {:error, Ecto.Changeset.t()}
def delete_media(%Media{} = media, opts \\ []) do
transaction =
Multi.new()
|> Multi.delete(:media, media)
|> Multi.run(:remove, fn _repo, %{media: %Media{file: %File{url: url}} = media} ->
case Upload.remove(url) do
{:error, err} ->
if err =~ "doesn't exist" and Keyword.get(opts, :ignore_file_not_found, false) do
Logger.info("Deleting media and ignoring absent file.")
{:ok, media}
else
{:error, err}
end
{:ok, media} ->
{:ok, media}
end
end)
|> Repo.transaction()
case transaction do
{:ok, %{media: %Media{} = media}} ->
{:ok, media}
{:error, :remove, error, _} ->
{:error, error}
end
end
@spec media_by_url_query(String.t()) :: Ecto.Query.t()
defp media_by_url_query(url) do
from(
p in Media,
where: fragment("? @> ?", p.file, ~s|{"url": "#{url}"}|)
)
end
@spec medias_for_actor_query(integer() | String.t()) :: Ecto.Query.t()
defp medias_for_actor_query(actor_id) do
Media
|> join(:inner, [p], a in Actor, on: p.actor_id == a.id)
|> where([_p, a], a.id == ^actor_id)
end
@spec medias_for_user_query(integer() | String.t()) :: Ecto.Query.t()
defp medias_for_user_query(user_id) do
Media
|> join(:inner, [p], a in Actor, on: p.actor_id == a.id)
|> join(:inner, [_p, a], u in User, on: a.user_id == u.id)
|> where([_p, _a, u], u.id == ^user_id)
end
end

View File

@ -22,8 +22,8 @@ defmodule Mobilizon.Posts.Post do
alias Ecto.Changeset alias Ecto.Changeset
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Events.Tag alias Mobilizon.Events.Tag
alias Mobilizon.Media alias Mobilizon.Medias
alias Mobilizon.Media.Picture alias Mobilizon.Medias.Media
alias Mobilizon.Posts.Post.TitleSlug alias Mobilizon.Posts.Post.TitleSlug
alias Mobilizon.Posts.PostVisibility alias Mobilizon.Posts.PostVisibility
alias Mobilizon.Web.Endpoint alias Mobilizon.Web.Endpoint
@ -41,7 +41,8 @@ defmodule Mobilizon.Posts.Post do
publish_at: DateTime.t(), publish_at: DateTime.t(),
author: Actor.t(), author: Actor.t(),
attributed_to: Actor.t(), attributed_to: Actor.t(),
picture: Picture.t(), picture: Media.t(),
media: [Media.t()],
tags: [Tag.t()] tags: [Tag.t()]
} }
@ -60,6 +61,7 @@ defmodule Mobilizon.Posts.Post do
belongs_to(:attributed_to, Actor) belongs_to(:attributed_to, Actor)
belongs_to(:picture, Picture, on_replace: :update) belongs_to(:picture, Picture, on_replace: :update)
many_to_many(:tags, Tag, join_through: "posts_tags", on_replace: :delete) many_to_many(:tags, Tag, join_through: "posts_tags", on_replace: :delete)
many_to_many(:media, Media, join_through: "posts_medias", on_replace: :delete)
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end
@ -82,6 +84,7 @@ defmodule Mobilizon.Posts.Post do
post post
|> cast(attrs, @attrs) |> cast(attrs, @attrs)
|> maybe_generate_id() |> maybe_generate_id()
|> put_assoc(:media, Map.get(attrs, :media, []))
|> put_tags(attrs) |> put_tags(attrs)
|> maybe_put_publish_date() |> maybe_put_publish_date()
|> put_picture(attrs) |> put_picture(attrs)
@ -146,8 +149,8 @@ defmodule Mobilizon.Posts.Post do
# In case the provided picture is an existing one # In case the provided picture is an existing one
@spec put_picture(Changeset.t(), map) :: Changeset.t() @spec put_picture(Changeset.t(), map) :: Changeset.t()
defp put_picture(%Changeset{} = changeset, %{picture: %{picture_id: id} = _picture}) do defp put_picture(%Changeset{} = changeset, %{picture: %{picture_id: id} = _picture}) do
case Media.get_picture!(id) do case Medias.get_media!(id) do
%Picture{} = picture -> %Media{} = picture ->
put_assoc(changeset, :picture, picture) put_assoc(changeset, :picture, picture)
_ -> _ ->

View File

@ -103,7 +103,7 @@ defmodule Mobilizon.Posts do
@spec update_post(Post.t(), map) :: {:ok, Post.t()} | {:error, Ecto.Changeset.t()} @spec update_post(Post.t(), map) :: {:ok, Post.t()} | {:error, Ecto.Changeset.t()}
def update_post(%Post{} = post, attrs) do def update_post(%Post{} = post, attrs) do
post post
|> Repo.preload(:tags) |> Repo.preload([:tags, :media])
|> Post.changeset(attrs) |> Post.changeset(attrs)
|> Repo.update() |> Repo.update()
end end

View File

@ -0,0 +1,60 @@
defmodule Mobilizon.Service.CleanOrphanMedia do
@moduledoc """
Service to clean orphan media
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Medias
alias Mobilizon.Medias.Media
alias Mobilizon.Storage.Repo
import Ecto.Query
@grace_period Mobilizon.Config.get([:instance, :orphan_upload_grace_period_hours], 48)
@doc """
Clean orphan media
Remove media that is not attached to an entity, such as media uploads that were never used in entities.
Options:
* `grace_period` how old in hours can the media be before it's taken into account for deletion
* `dry_run` just return the media that would have been deleted, don't actually delete it
"""
@spec clean(Keyword.t()) :: {:ok, list(Media.t())} | {:error, String.t()}
def clean(opts \\ []) do
medias = find_media(opts)
if Keyword.get(opts, :dry_run, false) do
{:ok, medias}
else
Enum.each(medias, fn media ->
Medias.delete_media(media, ignore_file_not_found: true)
end)
{:ok, medias}
end
end
@spec find_media(Keyword.t()) :: list(Media.t())
defp find_media(opts) do
grace_period = Keyword.get(opts, :grace_period, @grace_period)
expiration_date = DateTime.add(DateTime.utc_now(), grace_period * -3600)
Media
|> where([m], m.inserted_at < ^expiration_date)
|> join(:inner, [m], a in Actor)
|> where([_m, a], is_nil(a.domain))
|> join(:left, [m], e in assoc(m, :events))
|> join(:left, [m], ep in assoc(m, :event_picture))
|> join(:left, [m], p in assoc(m, :posts))
|> join(:left, [m], pp in assoc(m, :posts_picture))
|> join(:left, [m], c in assoc(m, :comments))
|> where([_m, _a, e], is_nil(e.id))
|> where([_m, _a, _e, ep], is_nil(ep.id))
|> where([_m, _a, _e, _ep, p], is_nil(p.id))
|> where([_m, _a, _e, _ep, _p, pp], is_nil(pp.id))
|> where([_m, _a, _e, _ep, _p, _pp, c], is_nil(c.id))
|> distinct(true)
|> Repo.all()
end
end

View File

@ -0,0 +1,31 @@
defmodule Mobilizon.Service.Workers.CleanOrphanMediaWorker do
@moduledoc """
Worker to clean orphan media
"""
use Oban.Worker, queue: "background"
alias Mobilizon.Service.CleanOrphanMedia
@grace_period Mobilizon.Config.get([:instance, :orphan_upload_grace_period_hours], 48)
@impl Oban.Worker
def perform(%Job{}) do
if Mobilizon.Config.get!([:instance, :remove_orphan_uploads]) and should_perform?() do
CleanOrphanMedia.clean()
end
end
@spec should_perform? :: boolean()
defp should_perform? do
case Cachex.get(:key_value, "last_media_cleanup") do
{:ok, %DateTime{} = last_media_cleanup} ->
DateTime.compare(
last_media_cleanup,
DateTime.add(DateTime.utc_now(), @grace_period * -3600)
) == :lt
_ ->
true
end
end
end

View File

@ -72,7 +72,10 @@ defmodule Mobilizon.Web.Plugs.UploadedMedia do
conn conn
else else
conn conn
|> send_resp(404, "Not found") |> delete_resp_header("content-disposition")
|> put_status(404)
|> Phoenix.Controller.put_view(Mobilizon.Web.ErrorView)
|> Phoenix.Controller.render("404.html")
|> halt() |> halt()
end end
end end

View File

@ -1,6 +1,6 @@
defmodule Mobilizon.Web.Upload.Filter.Optimize do defmodule Mobilizon.Web.Upload.Filter.Optimize do
@moduledoc """ @moduledoc """
Handle picture optimizations Handle media optimizations
""" """
@behaviour Mobilizon.Web.Upload.Filter @behaviour Mobilizon.Web.Upload.Filter

12
mix.exs
View File

@ -236,9 +236,9 @@ defmodule Mobilizon.Mixfile do
Mobilizon.Events.Tag.TitleSlug, Mobilizon.Events.Tag.TitleSlug,
Mobilizon.Events.Tag.TitleSlug.Type, Mobilizon.Events.Tag.TitleSlug.Type,
Mobilizon.Events.TagRelation, Mobilizon.Events.TagRelation,
Mobilizon.Media, Mobilizon.Medias,
Mobilizon.Media.File, Mobilizon.Medias.File,
Mobilizon.Media.Picture, Mobilizon.Medias.Media,
Mobilizon.Mention, Mobilizon.Mention,
Mobilizon.Reports, Mobilizon.Reports,
Mobilizon.Reports.Note, Mobilizon.Reports.Note,
@ -328,7 +328,7 @@ defmodule Mobilizon.Mixfile do
Mobilizon.GraphQL.Resolvers.Group, Mobilizon.GraphQL.Resolvers.Group,
Mobilizon.GraphQL.Resolvers.Member, Mobilizon.GraphQL.Resolvers.Member,
Mobilizon.GraphQL.Resolvers.Person, Mobilizon.GraphQL.Resolvers.Person,
Mobilizon.GraphQL.Resolvers.Picture, Mobilizon.GraphQL.Resolvers.Media,
Mobilizon.GraphQL.Resolvers.Report, Mobilizon.GraphQL.Resolvers.Report,
Mobilizon.GraphQL.Resolvers.Search, Mobilizon.GraphQL.Resolvers.Search,
Mobilizon.GraphQL.Resolvers.Tag, Mobilizon.GraphQL.Resolvers.Tag,
@ -347,7 +347,7 @@ defmodule Mobilizon.Mixfile do
Mobilizon.GraphQL.Schema.EventType, Mobilizon.GraphQL.Schema.EventType,
Mobilizon.GraphQL.Schema.Events.FeedTokenType, Mobilizon.GraphQL.Schema.Events.FeedTokenType,
Mobilizon.GraphQL.Schema.Events.ParticipantType, Mobilizon.GraphQL.Schema.Events.ParticipantType,
Mobilizon.GraphQL.Schema.PictureType, Mobilizon.GraphQL.Schema.MediaType,
Mobilizon.GraphQL.Schema.ReportType, Mobilizon.GraphQL.Schema.ReportType,
Mobilizon.GraphQL.Schema.SearchType, Mobilizon.GraphQL.Schema.SearchType,
Mobilizon.GraphQL.Schema.SortType, Mobilizon.GraphQL.Schema.SortType,
@ -374,7 +374,7 @@ defmodule Mobilizon.Mixfile do
Mobilizon.Federation.ActivityStream.Converter.Flag, Mobilizon.Federation.ActivityStream.Converter.Flag,
Mobilizon.Federation.ActivityStream.Converter.Follower, Mobilizon.Federation.ActivityStream.Converter.Follower,
Mobilizon.Federation.ActivityStream.Converter.Participant, Mobilizon.Federation.ActivityStream.Converter.Participant,
Mobilizon.Federation.ActivityStream.Converter.Picture, Mobilizon.Federation.ActivityStream.Converter.Media,
Mobilizon.Federation.ActivityStream.Converter.Tombstone, Mobilizon.Federation.ActivityStream.Converter.Tombstone,
Mobilizon.Federation.ActivityStream.Converter.Utils, Mobilizon.Federation.ActivityStream.Converter.Utils,
Mobilizon.Federation.HTTPSignatures.Signature, Mobilizon.Federation.HTTPSignatures.Signature,

View File

@ -0,0 +1,22 @@
defmodule Mobilizon.Storage.Repo.Migrations.AddMediaTables do
use Ecto.Migration
def change do
rename(table(:pictures), to: table(:medias))
create table(:events_medias, primary_key: false) do
add(:event_id, references(:events, on_delete: :delete_all), primary_key: true)
add(:media_id, references(:medias, on_delete: :delete_all), primary_key: true)
end
create table(:posts_medias, primary_key: false) do
add(:post_id, references(:posts, on_delete: :delete_all, type: :uuid), primary_key: true)
add(:media_id, references(:medias, on_delete: :delete_all), primary_key: true)
end
create table(:comments_medias, primary_key: false) do
add(:comment_id, references(:comments, on_delete: :delete_all), primary_key: true)
add(:media_id, references(:medias, on_delete: :delete_all), primary_key: true)
end
end
end

View File

@ -526,7 +526,7 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
organizer_actor_id: "#{actor.id}", organizer_actor_id: "#{actor.id}",
category: "birthday", category: "birthday",
picture: { picture: {
picture: { media: {
name: "picture for my event", name: "picture for my event",
alt: "A very sunny landscape", alt: "A very sunny landscape",
file: "event.jpg", file: "event.jpg",
@ -569,13 +569,13 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
actor: actor, actor: actor,
user: user user: user
} do } do
picture = %{name: "my pic", alt: "represents something", file: "picture.png"} media = %{name: "my pic", alt: "represents something", file: "picture.png"}
mutation = """ mutation = """
mutation { uploadPicture( mutation { uploadMedia (
name: "#{picture.name}", name: "#{media.name}",
alt: "#{picture.alt}", alt: "#{media.alt}",
file: "#{picture.file}" file: "#{media.file}"
) { ) {
id, id,
url, url,
@ -586,9 +586,9 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
map = %{ map = %{
"query" => mutation, "query" => mutation,
picture.file => %Plug.Upload{ media.file => %Plug.Upload{
path: "test/fixtures/picture.png", path: "test/fixtures/picture.png",
filename: picture.file filename: media.file
} }
} }
@ -601,8 +601,8 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
map map
) )
assert json_response(res, 200)["data"]["uploadPicture"]["name"] == picture.name assert json_response(res, 200)["data"]["uploadMedia"]["name"] == media.name
picture_id = json_response(res, 200)["data"]["uploadPicture"]["id"] media_id = json_response(res, 200)["data"]["uploadMedia"]["id"]
mutation = """ mutation = """
mutation { mutation {
@ -615,7 +615,7 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
organizer_actor_id: "#{actor.id}", organizer_actor_id: "#{actor.id}",
category: "birthday", category: "birthday",
picture: { picture: {
picture_id: "#{picture_id}" media_id: "#{media_id}"
} }
) { ) {
title, title,
@ -635,7 +635,7 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
assert json_response(res, 200)["data"]["createEvent"]["title"] == "come to my event" assert json_response(res, 200)["data"]["createEvent"]["title"] == "come to my event"
assert json_response(res, 200)["data"]["createEvent"]["picture"]["name"] == picture.name assert json_response(res, 200)["data"]["createEvent"]["picture"]["name"] == media.name
assert json_response(res, 200)["data"]["createEvent"]["picture"]["url"] assert json_response(res, 200)["data"]["createEvent"]["picture"]["url"]
end end
@ -943,7 +943,7 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
event_id: #{event.id}, event_id: #{event.id},
category: "birthday", category: "birthday",
picture: { picture: {
picture: { media: {
name: "picture for my event", name: "picture for my event",
alt: "A very sunny landscape", alt: "A very sunny landscape",
file: "event.jpg", file: "event.jpg",
@ -1349,7 +1349,7 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
test "delete_event/3 should check the event can be deleted by the user", %{ test "delete_event/3 should check the event can be deleted by the user", %{
conn: conn, conn: conn,
user: user, user: user,
actor: actor actor: _actor
} do } do
actor2 = insert(:actor) actor2 = insert(:actor)
event = insert(:event, organizer_actor: actor2) event = insert(:event, organizer_actor: actor2)

View File

@ -218,8 +218,8 @@ defmodule Mobilizon.Web.Resolvers.GroupTest do
$id: ID! $id: ID!
$name: String $name: String
$summary: String $summary: String
$avatar: PictureInput $avatar: MediaInput
$banner: PictureInput $banner: MediaInput
$visibility: GroupVisibility $visibility: GroupVisibility
$physicalAddress: AddressInput $physicalAddress: AddressInput
) { ) {

View File

@ -1,17 +1,17 @@
defmodule Mobilizon.GraphQL.Resolvers.PictureTest do defmodule Mobilizon.GraphQL.Resolvers.MediaTest do
use Mobilizon.Web.ConnCase use Mobilizon.Web.ConnCase
use Bamboo.Test use Bamboo.Test
import Mobilizon.Factory import Mobilizon.Factory
alias Mobilizon.Media.Picture alias Mobilizon.Medias.Media
alias Mobilizon.GraphQL.AbsintheHelpers alias Mobilizon.GraphQL.AbsintheHelpers
alias Mobilizon.Web.Endpoint alias Mobilizon.Web.Endpoint
@default_picture_details %{name: "my pic", alt: "represents something", file: "picture.png"} @default_media_details %{name: "my pic", alt: "represents something", file: "picture.png"}
@default_picture_path "test/fixtures/picture.png" @default_media_path "test/fixtures/picture.png"
setup %{conn: conn} do setup %{conn: conn} do
user = insert(:user) user = insert(:user)
@ -20,9 +20,9 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
{:ok, conn: conn, user: user, actor: actor} {:ok, conn: conn, user: user, actor: actor}
end end
@picture_query """ @media_query """
query Picture($id: ID!) { query Media($id: ID!) {
picture(id: $id) { media(id: $id) {
id id
name, name,
alt, alt,
@ -33,9 +33,9 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
} }
""" """
@upload_picture_mutation """ @upload_media_mutation """
mutation UploadPicture($name: String!, $alt: String, $file: Upload!) { mutation UploadMedia($name: String!, $alt: String, $file: Upload!) {
uploadPicture( uploadMedia(
name: $name name: $name
alt: $alt alt: $alt
file: $file file: $file
@ -48,44 +48,44 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
} }
""" """
describe "Resolver: Get picture" do describe "Resolver: Get media" do
test "picture/3 returns the information on a picture", %{conn: conn} do test "media/3 returns the information on a media", %{conn: conn} do
%Picture{id: id} = picture = insert(:picture) %Media{id: id} = media = insert(:media)
res = res =
conn conn
|> AbsintheHelpers.graphql_query(query: @picture_query, variables: %{id: id}) |> AbsintheHelpers.graphql_query(query: @media_query, variables: %{id: id})
assert res["data"]["picture"]["name"] == picture.file.name assert res["data"]["media"]["name"] == media.file.name
assert res["data"]["picture"]["content_type"] == assert res["data"]["media"]["content_type"] ==
picture.file.content_type media.file.content_type
assert res["data"]["picture"]["size"] == 13_120 assert res["data"]["media"]["size"] == 13_120
assert res["data"]["picture"]["url"] =~ Endpoint.url() assert res["data"]["media"]["url"] =~ Endpoint.url()
end end
test "picture/3 returns nothing on a non-existent picture", %{conn: conn} do test "media/3 returns nothing on a non-existent media", %{conn: conn} do
res = res =
conn conn
|> AbsintheHelpers.graphql_query(query: @picture_query, variables: %{id: 3}) |> AbsintheHelpers.graphql_query(query: @media_query, variables: %{id: 3})
assert hd(res["errors"])["message"] == "Resource not found" assert hd(res["errors"])["message"] == "Resource not found"
assert hd(res["errors"])["status_code"] == 404 assert hd(res["errors"])["status_code"] == 404
end end
end end
describe "Resolver: Upload picture" do describe "Resolver: Upload media" do
test "upload_picture/3 uploads a new picture", %{conn: conn, user: user} do test "upload_media/3 uploads a new media", %{conn: conn, user: user} do
picture = %{name: "my pic", alt: "represents something", file: "picture.png"} media = %{name: "my pic", alt: "represents something", file: "picture.png"}
map = %{ map = %{
"query" => @upload_picture_mutation, "query" => @upload_media_mutation,
"variables" => picture, "variables" => media,
picture.file => %Plug.Upload{ media.file => %Plug.Upload{
path: "test/fixtures/picture.png", path: "test/fixtures/picture.png",
filename: picture.file filename: media.file
} }
} }
@ -99,21 +99,21 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
) )
|> json_response(200) |> json_response(200)
assert res["data"]["uploadPicture"]["name"] == picture.name assert res["data"]["uploadMedia"]["name"] == media.name
assert res["data"]["uploadPicture"]["content_type"] == "image/png" assert res["data"]["uploadMedia"]["content_type"] == "image/png"
assert res["data"]["uploadPicture"]["size"] == 10_097 assert res["data"]["uploadMedia"]["size"] == 10_097
assert res["data"]["uploadPicture"]["url"] assert res["data"]["uploadMedia"]["url"]
end end
test "upload_picture/3 forbids uploading if no auth", %{conn: conn} do test "upload_media/3 forbids uploading if no auth", %{conn: conn} do
picture = %{name: "my pic", alt: "represents something", file: "picture.png"} media = %{name: "my pic", alt: "represents something", file: "picture.png"}
map = %{ map = %{
"query" => @upload_picture_mutation, "query" => @upload_media_mutation,
"variables" => picture, "variables" => media,
picture.file => %Plug.Upload{ media.file => %Plug.Upload{
path: "test/fixtures/picture.png", path: "test/fixtures/picture.png",
filename: picture.file filename: media.file
} }
} }
@ -130,43 +130,43 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
end end
end end
describe "Resolver: Remove picture" do describe "Resolver: Remove media" do
@remove_picture_mutation """ @remove_media_mutation """
mutation RemovePicture($id: ID!) { mutation RemoveMedia($id: ID!) {
removePicture(id: $id) { removeMedia(id: $id) {
id id
} }
} }
""" """
test "Removes a previously uploaded picture", %{conn: conn, user: user, actor: actor} do test "Removes a previously uploaded media", %{conn: conn, user: user, actor: actor} do
%Picture{id: picture_id} = insert(:picture, actor: actor) %Media{id: media_id} = insert(:media, actor: actor)
res = res =
conn conn
|> auth_conn(user) |> auth_conn(user)
|> AbsintheHelpers.graphql_query( |> AbsintheHelpers.graphql_query(
query: @remove_picture_mutation, query: @remove_media_mutation,
variables: %{id: picture_id} variables: %{id: media_id}
) )
assert is_nil(res["errors"]) assert is_nil(res["errors"])
assert res["data"]["removePicture"]["id"] == to_string(picture_id) assert res["data"]["removeMedia"]["id"] == to_string(media_id)
res = res =
conn conn
|> AbsintheHelpers.graphql_query(query: @picture_query, variables: %{id: picture_id}) |> AbsintheHelpers.graphql_query(query: @media_query, variables: %{id: media_id})
assert hd(res["errors"])["message"] == "Resource not found" assert hd(res["errors"])["message"] == "Resource not found"
assert hd(res["errors"])["status_code"] == 404 assert hd(res["errors"])["status_code"] == 404
end end
test "Removes nothing if picture is not found", %{conn: conn, user: user} do test "Removes nothing if media is not found", %{conn: conn, user: user} do
res = res =
conn conn
|> auth_conn(user) |> auth_conn(user)
|> AbsintheHelpers.graphql_query( |> AbsintheHelpers.graphql_query(
query: @remove_picture_mutation, query: @remove_media_mutation,
variables: %{id: 400} variables: %{id: 400}
) )
@ -174,14 +174,14 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
assert hd(res["errors"])["status_code"] == 404 assert hd(res["errors"])["status_code"] == 404
end end
test "Removes nothing if picture if not logged-in", %{conn: conn, actor: actor} do test "Removes nothing if media if not logged-in", %{conn: conn, actor: actor} do
%Picture{id: picture_id} = insert(:picture, actor: actor) %Media{id: media_id} = insert(:media, actor: actor)
res = res =
conn conn
|> AbsintheHelpers.graphql_query( |> AbsintheHelpers.graphql_query(
query: @remove_picture_mutation, query: @remove_media_mutation,
variables: %{id: picture_id} variables: %{id: media_id}
) )
assert hd(res["errors"])["message"] == "You need to be logged in" assert hd(res["errors"])["message"] == "You need to be logged in"
@ -210,8 +210,8 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
assert res["data"]["loggedPerson"]["mediaSize"] == 0 assert res["data"]["loggedPerson"]["mediaSize"] == 0
res = upload_picture(conn, user) res = upload_media(conn, user)
assert res["data"]["uploadPicture"]["size"] == 10_097 assert res["data"]["uploadMedia"]["size"] == 10_097
res = res =
conn conn
@ -221,14 +221,14 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
assert res["data"]["loggedPerson"]["mediaSize"] == 10_097 assert res["data"]["loggedPerson"]["mediaSize"] == 10_097
res = res =
upload_picture( upload_media(
conn, conn,
user, user,
"test/fixtures/image.jpg", "test/fixtures/image.jpg",
Map.put(@default_picture_details, :file, "image.jpg") Map.put(@default_media_details, :file, "image.jpg")
) )
assert res["data"]["uploadPicture"]["size"] == 13_227 assert res["data"]["uploadMedia"]["size"] == 13_227
res = res =
conn conn
@ -266,7 +266,7 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
assert is_nil(res["errors"]) assert is_nil(res["errors"])
assert hd(res["data"]["persons"]["elements"])["mediaSize"] == 0 assert hd(res["data"]["persons"]["elements"])["mediaSize"] == 0
upload_picture(conn, user) upload_media(conn, user)
res = res =
conn conn
@ -355,8 +355,8 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
assert res["errors"] == nil assert res["errors"] == nil
assert res["data"]["loggedUser"]["mediaSize"] == 0 assert res["data"]["loggedUser"]["mediaSize"] == 0
res = upload_picture(conn, user) res = upload_media(conn, user)
assert res["data"]["uploadPicture"]["size"] == 10_097 assert res["data"]["uploadMedia"]["size"] == 10_097
res = res =
conn conn
@ -366,14 +366,14 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
assert res["data"]["loggedUser"]["mediaSize"] == 10_097 assert res["data"]["loggedUser"]["mediaSize"] == 10_097
res = res =
upload_picture( upload_media(
conn, conn,
user, user,
"test/fixtures/image.jpg", "test/fixtures/image.jpg",
Map.put(@default_picture_details, :file, "image.jpg") Map.put(@default_media_details, :file, "image.jpg")
) )
assert res["data"]["uploadPicture"]["size"] == 13_227 assert res["data"]["uploadMedia"]["size"] == 13_227
res = res =
conn conn
@ -393,14 +393,14 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
assert is_nil(res["errors"]) assert is_nil(res["errors"])
res = res =
upload_picture( upload_media(
conn, conn,
user, user,
"test/fixtures/image.jpg", "test/fixtures/image.jpg",
Map.put(@default_picture_details, :file, "image.jpg") Map.put(@default_media_details, :file, "image.jpg")
) )
assert res["data"]["uploadPicture"]["size"] == 13_227 assert res["data"]["uploadMedia"]["size"] == 13_227
res = res =
conn conn
@ -438,9 +438,9 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
assert is_nil(res["errors"]) assert is_nil(res["errors"])
assert hd(res["data"]["users"]["elements"])["mediaSize"] == 0 assert hd(res["data"]["users"]["elements"])["mediaSize"] == 0
res = upload_picture(conn, user) res = upload_media(conn, user)
assert is_nil(res["errors"]) assert is_nil(res["errors"])
assert res["data"]["uploadPicture"]["size"] == 10_097 assert res["data"]["uploadMedia"]["size"] == 10_097
res = res =
conn conn
@ -463,19 +463,19 @@ defmodule Mobilizon.GraphQL.Resolvers.PictureTest do
end end
end end
@spec upload_picture(Plug.Conn.t(), Mobilizon.Users.User.t(), String.t(), map()) :: map() @spec upload_media(Plug.Conn.t(), Mobilizon.Users.User.t(), String.t(), map()) :: map()
defp upload_picture( defp upload_media(
conn, conn,
user, user,
picture_path \\ @default_picture_path, media_path \\ @default_media_path,
picture_details \\ @default_picture_details media_details \\ @default_media_details
) do ) do
map = %{ map = %{
"query" => @upload_picture_mutation, "query" => @upload_media_mutation,
"variables" => picture_details, "variables" => media_details,
picture_details.file => %Plug.Upload{ media_details.file => %Plug.Upload{
path: picture_path, path: media_path,
filename: picture_details.file filename: media_details.file
} }
} }

View File

@ -205,7 +205,7 @@ defmodule Mobilizon.GraphQL.Resolvers.PersonTest do
name: "secret person", name: "secret person",
summary: "no-one will know who I am", summary: "no-one will know who I am",
banner: { banner: {
picture: { media: {
file: "landscape.jpg", file: "landscape.jpg",
name: "irish landscape", name: "irish landscape",
alt: "The beautiful atlantic way" alt: "The beautiful atlantic way"
@ -274,7 +274,7 @@ defmodule Mobilizon.GraphQL.Resolvers.PersonTest do
name: "riri updated", name: "riri updated",
summary: "summary updated", summary: "summary updated",
banner: { banner: {
picture: { media: {
file: "landscape.jpg", file: "landscape.jpg",
name: "irish landscape", name: "irish landscape",
alt: "The beautiful atlantic way" alt: "The beautiful atlantic way"

View File

@ -9,7 +9,7 @@ defmodule Mobilizon.ActorsTest do
alias Mobilizon.Actors.{Actor, Bot, Follower, Member} alias Mobilizon.Actors.{Actor, Bot, Follower, Member}
alias Mobilizon.Discussions.Comment alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Media.File, as: FileModel alias Mobilizon.Medias.File, as: FileModel
alias Mobilizon.Service.Workers alias Mobilizon.Service.Workers
alias Mobilizon.Storage.Page alias Mobilizon.Storage.Page

View File

@ -58,7 +58,8 @@ defmodule Mobilizon.DiscussionsTest do
%Comment{} = comment = insert(:comment) %Comment{} = comment = insert(:comment)
assert {:error, %Ecto.Changeset{}} = Discussions.update_comment(comment, @invalid_attrs) assert {:error, %Ecto.Changeset{}} = Discussions.update_comment(comment, @invalid_attrs)
%Comment{} = comment_fetched = Discussions.get_comment!(comment.id) %Comment{} = comment_fetched = Discussions.get_comment!(comment.id)
assert comment = comment_fetched assert comment.text == comment_fetched.text
assert comment.url == comment_fetched.url
end end
test "delete_comment/1 deletes the comment" do test "delete_comment/1 deletes the comment" do

View File

@ -292,7 +292,7 @@ defmodule Mobilizon.EventsTest do
tag1: %Tag{id: tag1_id} = tag1, tag1: %Tag{id: tag1_id} = tag1,
tag2: %Tag{id: tag2_id} = tag2 tag2: %Tag{id: tag2_id} = tag2
} do } do
assert {:ok, %TagRelation{} = tag_relation} = assert {:ok, %TagRelation{}} =
Events.create_tag_relation(%{tag_id: tag1_id, link_id: tag2_id}) Events.create_tag_relation(%{tag_id: tag1_id, link_id: tag2_id})
assert Events.are_tags_linked(tag1, tag2) assert Events.are_tags_linked(tag1, tag2)

View File

@ -3,13 +3,13 @@ defmodule Mobilizon.MediaTest do
import Mobilizon.Factory import Mobilizon.Factory
alias Mobilizon.{Config, Media} alias Mobilizon.{Config, Medias}
alias Mobilizon.Web.Upload.Uploader alias Mobilizon.Web.Upload.Uploader
describe "media" do describe "media" do
setup [:ensure_local_uploader] setup [:ensure_local_uploader]
alias Mobilizon.Media.Picture alias Mobilizon.Medias.Media
@valid_attrs %{ @valid_attrs %{
file: %{ file: %{
@ -24,39 +24,42 @@ defmodule Mobilizon.MediaTest do
} }
} }
test "get_picture!/1 returns the picture with given id" do test "get_media!/1 returns the media with given id" do
picture = insert(:picture) media = insert(:media)
assert Media.get_picture!(picture.id).id == picture.id assert Medias.get_media!(media.id).id == media.id
end end
test "create_picture/1 with valid data creates a picture" do test "create_media/1 with valid data creates a media" do
assert {:ok, %Picture{} = picture} = assert {:ok, %Media{} = media} =
Media.create_picture(Map.put(@valid_attrs, :actor_id, insert(:actor).id)) Medias.create_media(Map.put(@valid_attrs, :actor_id, insert(:actor).id))
assert picture.file.name == "something old" assert media.file.name == "something old"
end end
test "update_picture/2 with valid data updates the picture" do test "update_media/2 with valid data updates the media" do
picture = insert(:picture) media = insert(:media)
assert {:ok, %Picture{} = picture} = assert {:ok, %Media{} = media} =
Media.update_picture(picture, Map.put(@update_attrs, :actor_id, insert(:actor).id)) Medias.update_media(
media,
Map.put(@update_attrs, :actor_id, insert(:actor).id)
)
assert picture.file.name == "something new" assert media.file.name == "something new"
end end
test "delete_picture/1 deletes the picture" do test "delete_media/1 deletes the media" do
picture = insert(:picture) media = insert(:media)
%URI{path: "/media/" <> path} = URI.parse(picture.file.url) %URI{path: "/media/" <> path} = URI.parse(media.file.url)
assert File.exists?( assert File.exists?(
Config.get!([Uploader.Local, :uploads]) <> Config.get!([Uploader.Local, :uploads]) <>
"/" <> path "/" <> path
) )
assert {:ok, %Picture{}} = Media.delete_picture(picture) assert {:ok, %Media{}} = Medias.delete_media(media)
assert_raise Ecto.NoResultsError, fn -> Media.get_picture!(picture.id) end assert_raise Ecto.NoResultsError, fn -> Medias.get_media!(media.id) end
refute File.exists?( refute File.exists?(
Config.get!([Uploader.Local, :uploads]) <> Config.get!([Uploader.Local, :uploads]) <>

View File

@ -70,7 +70,7 @@ defmodule Mobilizon.PostsTest do
%Post{} = post = insert(:post) %Post{} = post = insert(:post)
assert {:error, %Ecto.Changeset{}} = Posts.update_post(post, @invalid_attrs) assert {:error, %Ecto.Changeset{}} = Posts.update_post(post, @invalid_attrs)
%Post{} = post_fetched = Posts.get_post(post.id) %Post{} = post_fetched = Posts.get_post(post.id)
assert post = post_fetched assert post.body == post_fetched.body
end end
test "delete_post/1 deletes the post" do test "delete_post/1 deletes the post" do

View File

@ -0,0 +1,56 @@
defmodule Mix.Tasks.Mobilizon.Media.CleanOrphanTest do
use Mobilizon.DataCase
import Mobilizon.Factory
alias Mobilizon.Medias
alias Mobilizon.Medias.Media
alias Mobilizon.Service.CleanOrphanMedia
describe "clean orphan media" do
test "with default values" do
{:ok, old, _} = DateTime.from_iso8601("2020-11-20T17:35:23+01:00")
%Media{id: media_id} = insert(:media, inserted_at: old)
%Media{id: media_2_id} = insert(:media)
refute is_nil(Medias.get_media(media_id))
refute is_nil(Medias.get_media(media_2_id))
assert {:ok, [found_media]} = CleanOrphanMedia.clean()
assert found_media.id == media_id
assert is_nil(Medias.get_media(media_id))
refute is_nil(Medias.get_media(media_2_id))
end
test "as dry-run" do
{:ok, old, _} = DateTime.from_iso8601("2020-11-20T17:35:23+01:00")
%Media{id: media_id} = insert(:media, inserted_at: old)
%Media{id: media_2_id} = insert(:media)
refute is_nil(Medias.get_media(media_id))
refute is_nil(Medias.get_media(media_2_id))
assert {:ok, [found_media]} = CleanOrphanMedia.clean(dry_run: true)
assert found_media.id == media_id
refute is_nil(Medias.get_media(media_id))
refute is_nil(Medias.get_media(media_2_id))
end
test "with custom grace period" do
date = DateTime.utc_now() |> DateTime.add(24 * -3600)
%Media{id: media_id} = insert(:media, inserted_at: date)
%Media{id: media_2_id} = insert(:media)
refute is_nil(Medias.get_media(media_id))
refute is_nil(Medias.get_media(media_2_id))
assert {:ok, [found_media]} = CleanOrphanMedia.clean(grace_period: 12)
assert found_media.id == media_id
assert is_nil(Medias.get_media(media_id))
refute is_nil(Medias.get_media(media_2_id))
end
end
end

View File

@ -148,6 +148,7 @@ defmodule Mobilizon.Factory do
event: build(:event), event: build(:event),
uuid: uuid, uuid: uuid,
mentions: [], mentions: [],
media: [],
attributed_to: nil, attributed_to: nil,
local: true, local: true,
deleted_at: nil, deleted_at: nil,
@ -179,13 +180,14 @@ defmodule Mobilizon.Factory do
local: true, local: true,
publish_at: DateTime.utc_now(), publish_at: DateTime.utc_now(),
url: Routes.page_url(Endpoint, :event, uuid), url: Routes.page_url(Endpoint, :event, uuid),
picture: insert(:picture), picture: insert(:media),
uuid: uuid, uuid: uuid,
join_options: :free, join_options: :free,
options: %{}, options: %{},
participant_stats: %{}, participant_stats: %{},
status: :confirmed, status: :confirmed,
contacts: [] contacts: [],
media: []
} }
end end
@ -269,16 +271,16 @@ defmodule Mobilizon.Factory do
size: 13_227 size: 13_227
} = data } = data
%Mobilizon.Media.File{ %Mobilizon.Medias.File{
name: "My Picture", name: "My Media",
url: url, url: url,
content_type: "image/png", content_type: "image/png",
size: 13_120 size: 13_120
} }
end end
def picture_factory do def media_factory do
%Mobilizon.Media.Picture{ %Mobilizon.Medias.Media{
file: build(:file), file: build(:file),
actor: build(:actor) actor: build(:actor)
} }
@ -372,6 +374,7 @@ defmodule Mobilizon.Factory do
tags: build_list(3, :tag), tags: build_list(3, :tag),
visibility: :public, visibility: :public,
publish_at: DateTime.utc_now(), publish_at: DateTime.utc_now(),
media: [],
url: Routes.page_url(Endpoint, :post, uuid) url: Routes.page_url(Endpoint, :post, uuid)
} }
end end

View File

@ -0,0 +1,124 @@
defmodule Mix.Tasks.Mobilizon.Media.CleanOrphanTest do
use Mobilizon.DataCase
import Mock
import Mobilizon.Factory
alias Mix.Tasks.Mobilizon.Media.CleanOrphan
alias Mobilizon.Service.CleanOrphanMedia
Mix.shell(Mix.Shell.Process)
describe "with default options" do
test "nothing returned" do
with_mock CleanOrphanMedia, clean: fn [dry_run: false, grace_period: 48] -> {:ok, []} end do
CleanOrphan.run([])
assert_received {:mix_shell, :info, [output_received]}
assert output_received == "No files were deleted"
end
end
test "media returned" do
media1 = insert(:media)
media2 = insert(:media)
with_mock CleanOrphanMedia,
clean: fn [dry_run: false, grace_period: 48] -> {:ok, [media1, media2]} end do
CleanOrphan.run([])
assert_received {:mix_shell, :info, [output_received]}
assert output_received == "2 files have been deleted"
end
end
end
describe "with dry-run option" do
test "with nothing returned" do
with_mock CleanOrphanMedia,
clean: fn [dry_run: true, grace_period: 48] -> {:ok, []} end do
CleanOrphan.run(["--dry-run"])
assert_received {:mix_shell, :info, [output_received]}
assert output_received == "No files would have been deleted"
end
end
test "with media returned" do
media1 = insert(:media)
media2 = insert(:media)
with_mock CleanOrphanMedia,
clean: fn [dry_run: true, grace_period: 48] -> {:ok, [media1, media2]} end do
CleanOrphan.run(["--dry-run"])
assert_received {:mix_shell, :info, [output_received]}
assert output_received == "List of files that would have been deleted"
assert_received {:mix_shell, :info, [output_received]}
assert output_received ==
"ID: #{media1.id}, Actor: #{media1.actor_id}, URL: #{media1.file.url}"
assert_received {:mix_shell, :info, [output_received]}
assert output_received ==
"ID: #{media2.id}, Actor: #{media2.actor_id}, URL: #{media2.file.url}"
assert_received {:mix_shell, :info, [output_received]}
assert output_received == "2 files would have been deleted"
end
end
end
describe "with verbose option" do
test "with nothing returned" do
with_mock CleanOrphanMedia,
clean: fn [dry_run: false, grace_period: 48] -> {:ok, []} end do
CleanOrphan.run(["--verbose"])
assert_received {:mix_shell, :info, [output_received]}
assert output_received == "No files were deleted"
end
end
test "with media returned" do
media1 = insert(:media)
media2 = insert(:media)
with_mock CleanOrphanMedia,
clean: fn [dry_run: false, grace_period: 48] -> {:ok, [media1, media2]} end do
CleanOrphan.run(["--verbose"])
assert_received {:mix_shell, :info, [output_received]}
assert output_received == "List of files that have been deleted"
assert_received {:mix_shell, :info, [output_received]}
assert output_received ==
"ID: #{media1.id}, Actor: #{media1.actor_id}, URL: #{media1.file.url}"
assert_received {:mix_shell, :info, [output_received]}
assert output_received ==
"ID: #{media2.id}, Actor: #{media2.actor_id}, URL: #{media2.file.url}"
assert_received {:mix_shell, :info, [output_received]}
assert output_received == "2 files have been deleted"
end
end
end
describe "with days option" do
test "with nothing returned" do
with_mock CleanOrphanMedia,
clean: fn [dry_run: false, grace_period: 120] -> {:ok, []} end do
CleanOrphan.run(["--days", "5"])
assert_received {:mix_shell, :info, [output_received]}
assert output_received == "No files were deleted"
end
end
end
describe "returns an error" do
test "for some reason" do
with_mock CleanOrphanMedia,
clean: fn [dry_run: false, grace_period: 48] -> {:error, "Some error"} end do
CleanOrphan.run([])
assert_received {:mix_shell, :error, [output_received]}
assert output_received == "Error while cleaning orphan media files"
end
end
end
end