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
**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
**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,
avatar_upload_limit: 2_000_000,
banner_upload_limit: 4_000_000,
remove_orphan_uploads: true,
orphan_upload_grace_period_hours: 48,
email_from: "noreply@localhost",
email_reply_to: "noreply@localhost"
@ -250,6 +252,8 @@ config :mobilizon, Oban,
crontab: [
{"@hourly", Mobilizon.Service.Workers.BuildSiteMap, 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,

View file

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

View file

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

View file

@ -60,14 +60,14 @@ figure.image {
</style>
<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";
@Component
export default class PictureUpload extends Vue {
@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" })
accept!: string;
@ -100,7 +100,7 @@ export default class PictureUpload extends Vue {
}
@Watch("defaultImage")
onDefaultImageChange(defaultImage: IPicture): void {
onDefaultImageChange(defaultImage: IMedia): void {
console.log("onDefaultImageChange", defaultImage);
this.imageSrc = defaultImage ? defaultImage.url : null;
}

View file

@ -421,7 +421,7 @@ export const CREATE_PERSON = gql`
$preferredUsername: String!
$name: String!
$summary: String
$avatar: PictureInput
$avatar: MediaInput
) {
createPerson(
preferredUsername: $preferredUsername
@ -442,7 +442,7 @@ export const CREATE_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) {
id
preferredUsername

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
import { ITag } from "./tag.model";
import { IPicture } from "./picture.model";
import { IMedia } from "./media.model";
import { IActor } from "./actor";
export enum PostVisibility {
@ -17,7 +17,7 @@ export interface IPost {
title: string;
body: string;
tags?: ITag[];
picture?: IPicture | null;
picture?: IMedia | null;
draft: boolean;
visibility: PostVisibility;
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);
const response = await fetch(obj.url);
@ -14,7 +14,7 @@ export function buildFileVariable(file: File | null, name: string, alt?: string)
return {
[name]: {
picture: {
media: {
name: file.name,
alt: alt || file.name,
file,

View file

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

View file

@ -377,7 +377,7 @@ import {
import { IPerson, Person, displayNameAndUsername } from "../../types/actor";
import { TAGS } from "../../graphql/tags";
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 "intersection-observer";
import { CONFIG } from "../../graphql/config";
@ -517,7 +517,7 @@ export default class EditEvent extends Vue {
);
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;
if (!(this.isUpdate || this.isDuplicate)) {
this.initializeEvent();
@ -775,11 +775,11 @@ export default class EditEvent extends Vue {
try {
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 newPictureFileContent = await readFileAsync(this.pictureFile as File);
if (oldPictureFileContent === newPictureFileContent) {
res.picture = { pictureId: this.event.picture.id };
res.picture = { mediaId: this.event.picture.id };
}
}
} catch (e) {

View file

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

View file

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

View file

@ -10,7 +10,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
alias Mobilizon.Actors
alias Mobilizon.Actors.Actor
alias Mobilizon.Media.Picture
alias Mobilizon.Medias.Media
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.{Activity, Federator, Relay}
@ -333,50 +333,50 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
Return AS Link data from
* a `Plug.Upload` struct, stored an returned
* a `Picture`, directly returned
* a map containing picture information, stored, saved and returned
* a `Media`, directly 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
case Mobilizon.Web.Upload.store(picture, opts) do
{:ok, picture} ->
picture
def make_media_data(%Plug.Upload{} = media, opts) do
case Mobilizon.Web.Upload.store(media, opts) do
{:ok, media} ->
media
_ ->
nil
end
end
def make_picture_data(%Picture{} = picture) do
Converter.Picture.model_to_as(picture)
def make_media_data(%Media{} = media) do
Converter.Media.model_to_as(media)
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}} <-
Mobilizon.Web.Upload.store(picture.file),
{:picture_exists, nil} <- {:picture_exists, Mobilizon.Media.get_picture_by_url(url)},
{:ok, %Picture{file: _file} = picture} <-
Mobilizon.Media.create_picture(%{
Mobilizon.Web.Upload.store(media.file),
{:media_exists, nil} <- {:media_exists, Mobilizon.Medias.get_media_by_url(url)},
{:ok, %Media{file: _file} = media} <-
Mobilizon.Medias.create_media(%{
"file" => %{
"url" => url,
"name" => picture.name,
"name" => media.name,
"content_type" => content_type,
"size" => size
},
"actor_id" => picture.actor_id
"actor_id" => media.actor_id
}) do
Converter.Picture.model_to_as(picture)
Converter.Media.model_to_as(media)
else
{:picture_exists, %Picture{file: _file} = picture} ->
Converter.Picture.model_to_as(picture)
{:media_exists, %Media{file: _file} = media} ->
Converter.Media.model_to_as(media)
err ->
err
end
end
def make_picture_data(nil), do: nil
def make_media_data(nil), do: nil
@doc """
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.Address
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.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,
only: [
@ -55,10 +55,10 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
picture_id =
with true <- length(attachments) > 0,
{:ok, %Picture{id: picture_id}} <-
{:ok, %Media{id: picture_id}} <-
attachments
|> hd()
|> PictureConverter.find_or_create_picture(actor_id) do
|> MediaConverter.find_or_create_media(actor_id) do
picture_id
else
_err ->
@ -239,7 +239,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Event do
res,
"attachment",
[],
&(&1 ++ [PictureConverter.model_to_as(event.picture)])
&(&1 ++ [MediaConverter.model_to_as(event.picture)])
)
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
@moduledoc """
API for Comments.
API for discussions and comments.
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.Comment
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.Activity
alias Mobilizon.GraphQL.API.Utils
@doc """
Create a comment
Creates a comment from an actor
"""
@spec create_comment(map) :: {:ok, Activity.t(), Comment.t()} | any
def create_comment(args) do
args = extract_pictures_from_comment_body(args)
ActivityPub.create(:comment, args, true)
end
@doc """
Updates a comment
"""
@spec update_comment(Comment.t(), map()) :: {:ok, Activity.t(), Comment.t()} | any
def update_comment(%Comment{} = comment, args) do
args = extract_pictures_from_comment_body(args)
ActivityPub.update(comment, args, true)
end
@doc """
Deletes a comment
Deletes a comment from an actor
"""
@spec delete_comment(Comment.t(), Actor.t()) :: {:ok, Activity.t(), Comment.t()} | any
def delete_comment(%Comment{} = comment, %Actor{} = actor) do
ActivityPub.delete(comment, actor, true)
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

View file

@ -8,6 +8,7 @@ defmodule Mobilizon.GraphQL.API.Events do
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Federation.ActivityPub.{Activity, Utils}
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
@doc """
Create an event
@ -15,6 +16,7 @@ defmodule Mobilizon.GraphQL.API.Events do
@spec create_event(map) :: {:ok, Activity.t(), Event.t()} | any
def create_event(args) do
with organizer_actor <- Map.get(args, :organizer_actor),
args <- extract_pictures_from_event_body(args, organizer_actor),
args <-
Map.update(args, :picture, nil, fn picture ->
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
def update_event(args, %Event{} = event) do
with organizer_actor <- Map.get(args, :organizer_actor),
args <- extract_pictures_from_event_body(args, organizer_actor),
args <-
Map.update(args, :picture, nil, fn picture ->
process_picture(picture, organizer_actor)
@ -40,23 +43,32 @@ defmodule Mobilizon.GraphQL.API.Events do
@doc """
Trigger the deletion of an event
If the event is deleted by
"""
def delete_event(%Event{} = event, %Actor{} = actor, federate \\ true) do
ActivityPub.delete(event, actor, federate)
end
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:
picture
media
|> 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
}
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

View file

@ -3,7 +3,8 @@ defmodule Mobilizon.GraphQL.API.Utils do
Utils for API.
"""
alias Mobilizon.Config
alias Mobilizon.{Config, Medias}
alias Mobilizon.Medias.Media
alias Mobilizon.Service.Formatter
@doc """
@ -40,4 +41,41 @@ defmodule Mobilizon.GraphQL.API.Utils do
{:error, "Comment must be up to #{max_size} characters"}
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

View file

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

View file

@ -96,8 +96,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
# TODO Move me to somewhere cleaner
defp save_attached_pictures(args) do
Enum.reduce([:avatar, :banner], args, fn key, args ->
if Map.has_key?(args, key) && !is_nil(args[key][:picture]) do
pic = args[key][:picture]
if Map.has_key?(args, key) && !is_nil(args[key][:media]) do
pic = args[key][:media]
with {:ok, %{name: name, url: url, content_type: content_type, size: _size}} <-
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 """
Handles the picture-related GraphQL calls
Handles the media-related GraphQL calls
"""
alias Mobilizon.Actors.Actor
alias Mobilizon.{Media, Users}
alias Mobilizon.Media.Picture
alias Mobilizon.{Medias, Users}
alias Mobilizon.Medias.Media
alias Mobilizon.Users.User
import Mobilizon.Web.Gettext
@doc """
Get picture for an event
Get media for an event
See Mobilizon.Web.Resolvers.Event.create_event/3
"""
def picture(%{picture_id: picture_id} = _parent, _args, _resolution) do
with {:ok, picture} <- do_fetch_picture(picture_id), do: {:ok, picture}
def media(%{picture_id: media_id} = _parent, _args, _resolution) do
with {:ok, media} <- do_fetch_media(media_id), do: {:ok, media}
end
def picture(%{picture: picture} = _parent, _args, _resolution), do: {:ok, picture}
def picture(_parent, %{id: picture_id}, _resolution), do: do_fetch_picture(picture_id)
def picture(_parent, _args, _resolution), do: {:ok, nil}
def media(%{picture: media} = _parent, _args, _resolution), do: {:ok, media}
def media(_parent, %{id: media_id}, _resolution), do: do_fetch_media(media_id)
def media(_parent, _args, _resolution), do: {:ok, nil}
@spec do_fetch_picture(nil) :: {:error, nil}
defp do_fetch_picture(nil), do: {:error, nil}
def medias(%{media: medias}, _args, _resolution) do
{:ok, Enum.map(medias, &transform_media/1)}
end
@spec do_fetch_picture(String.t()) :: {:ok, Picture.t()} | {:error, :not_found}
defp do_fetch_picture(picture_id) do
case Media.get_picture(picture_id) do
%Picture{id: id, file: file} ->
{:ok,
%{
name: file.name,
url: file.url,
id: id,
content_type: file.content_type,
size: file.size
}}
@spec do_fetch_media(nil) :: {:error, nil}
defp do_fetch_media(nil), do: {:error, nil}
@spec do_fetch_media(String.t()) :: {:ok, Media.t()} | {:error, :not_found}
defp do_fetch_media(media_id) do
case Medias.get_media(media_id) do
%Media{} = media ->
{:ok, transform_media(media)}
nil ->
{:error, :not_found}
end
end
@spec upload_picture(map, map, map) :: {:ok, Picture.t()} | {:error, any}
def upload_picture(
@spec upload_media(map, map, map) :: {:ok, Media.t()} | {:error, any}
def upload_media(
_parent,
%{file: %Plug.Upload{} = file} = args,
%{context: %{current_user: %User{} = user}}
@ -57,16 +54,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
|> Map.put(:url, url)
|> Map.put(:size, size)
|> Map.put(:content_type, content_type),
{:ok, picture = %Picture{}} <-
Media.create_picture(%{"file" => args, "actor_id" => actor_id}) do
{:ok,
%{
name: picture.file.name,
url: picture.file.url,
id: picture.id,
content_type: picture.file.content_type,
size: picture.file.size
}}
{:ok, media = %Media{}} <-
Medias.create_media(%{"file" => args, "actor_id" => actor_id}) do
{:ok, transform_media(media)}
else
{:error, :mime_type_not_allowed} ->
{:error, dgettext("errors", "File doesn't have an allowed MIME type.")}
@ -76,28 +66,28 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
end
end
def upload_picture(_parent, _args, _resolution), do: {:error, :unauthenticated}
def upload_media(_parent, _args, _resolution), do: {:error, :unauthenticated}
@doc """
Remove a picture that the user owns
Remove a media that the user owns
"""
@spec remove_picture(map(), map(), map()) ::
{:ok, Picture.t()}
@spec remove_media(map(), map(), map()) ::
{:ok, Media.t()}
| {:error, :unauthorized}
| {:error, :unauthenticated}
| {:error, :not_found}
def remove_picture(_parent, %{id: picture_id}, %{context: %{current_user: %User{} = user}}) do
with {:picture, %Picture{actor_id: actor_id} = picture} <-
{:picture, Media.get_picture(picture_id)},
def remove_media(_parent, %{id: media_id}, %{context: %{current_user: %User{} = user}}) do
with {:media, %Media{actor_id: actor_id} = media} <-
{:media, Medias.get_media(media_id)},
{:is_owned, %Actor{} = _actor} <- User.owns_actor(user, actor_id) do
Media.delete_picture(picture)
Medias.delete_media(media)
else
{:picture, nil} -> {:error, :not_found}
{:media, nil} -> {:error, :not_found}
{:is_owned, _} -> {:error, :unauthorized}
end
end
def remove_picture(_parent, _args, _resolution), do: {:error, :unauthenticated}
def remove_media(_parent, _args, _resolution), do: {:error, :unauthenticated}
@doc """
Return the total media size for an actor
@ -108,7 +98,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
context: %{current_user: %User{} = user}
}) 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
{:error, :unauthorized}
end
@ -125,7 +115,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
context: %{current_user: %User{} = logged_user}
}) 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
{:error, :unauthorized}
end
@ -133,6 +123,17 @@ defmodule Mobilizon.GraphQL.Resolvers.Picture do
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()
defp can_get_actor_size?(%User{role: role} = user, actor_id) do
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
alias Mobilizon.Federation.ActivityPub
require Logger
alias Mobilizon.Web.{MediaProxy, Upload}
@ -137,6 +138,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
%{id: id} = args,
%{context: %{current_user: user}} = _resolution
) do
require Logger
args = Map.put(args, :user_id, user.id)
with {:find_actor, %Actor{} = actor} <-
@ -198,11 +200,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
defp save_attached_pictures(args) do
Enum.reduce([:avatar, :banner], args, fn key, args ->
if Map.has_key?(args, key) && !is_nil(args[key][:picture]) do
pic = args[key][:picture]
if Map.has_key?(args, key) && !is_nil(args[key][:media]) do
media = args[key][:media]
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})
end
else

View file

@ -116,6 +116,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
Map.update(args, :picture, nil, fn picture ->
process_picture(picture, group)
end),
args <- extract_pictures_from_post_body(args, actor_id),
{:ok, _, %Post{} = post} <-
ActivityPub.create(
:post,
@ -156,6 +157,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
Map.update(args, :picture, nil, fn picture ->
process_picture(picture, group)
end),
args <- extract_pictures_from_post_body(args, actor_id),
{:member, true} <- {:member, Actors.is_member?(actor_id, group_id)},
{:ok, _, %Post{} = post} <-
ActivityPub.update(post, args, true, %{"actor" => actor_url}) do
@ -210,15 +212,23 @@ defmodule Mobilizon.GraphQL.Resolvers.Post do
end
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:
picture
media
|> 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
}
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

View file

@ -529,7 +529,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
context: %{current_user: %User{id: logged_in_user_id}}
})
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,
%{

View file

@ -30,7 +30,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_types(Schema.Custom.Point)
import_types(Schema.UserType)
import_types(Schema.PictureType)
import_types(Schema.MediaType)
import_types(Schema.ActorInterface)
import_types(Schema.Actors.PersonType)
import_types(Schema.Actors.GroupType)
@ -145,7 +145,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:tag_queries)
import_fields(:address_queries)
import_fields(:config_queries)
import_fields(:picture_queries)
import_fields(:media_queries)
import_fields(:report_queries)
import_fields(:admin_queries)
import_fields(:todo_list_queries)
@ -168,7 +168,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:participant_mutations)
import_fields(:member_mutations)
import_fields(:feed_token_mutations)
import_fields(:picture_mutations)
import_fields(:media_mutations)
import_fields(:report_mutations)
import_fields(:admin_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(:avatar, :picture, description: "The actor's avatar picture")
field(:banner, :picture, description: "The actor's banner picture")
field(:avatar, :media, description: "The actor's avatar media")
field(:banner, :media, description: "The actor's banner media")
# These one should have a privacy setting
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.
"""
alias Mobilizon.GraphQL.Resolvers.Picture
alias Mobilizon.GraphQL.Resolvers.Media
use Absinthe.Schema.Notation
@desc """
@ -27,8 +27,8 @@ defmodule Mobilizon.GraphQL.Schema.Actors.ApplicationType do
field(:suspended, :boolean, description: "If the actor is suspended")
field(:avatar, :picture, description: "The actor's avatar picture")
field(:banner, :picture, description: "The actor's banner picture")
field(:avatar, :media, description: "The actor's avatar media")
field(:banner, :media, description: "The actor's banner media")
# These one should have a privacy setting
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(:media_size, :integer,
resolve: &Picture.actor_size/3,
resolve: &Media.actor_size/3,
description: "The total size of the media from this actor"
)
end

View file

@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
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
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(:avatar, :picture, description: "The actor's avatar picture")
field(:banner, :picture, description: "The actor's banner picture")
field(:avatar, :media, description: "The actor's avatar media")
field(:banner, :media, description: "The actor's banner media")
field(:physical_address, :address,
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(:media_size, :integer,
resolve: &Picture.actor_size/3,
resolve: &Media.actor_size/3,
description: "The total size of the media from this actor"
)
@ -198,14 +198,14 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
default_value: :public
)
arg(:avatar, :picture_input,
arg(:avatar, :media_input,
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:
"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")
@ -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."
)
arg(:avatar, :picture_input,
arg(:avatar, :media_input,
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:
"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")

View file

@ -7,7 +7,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Events
alias Mobilizon.GraphQL.Resolvers.{Person, Picture}
alias Mobilizon.GraphQL.Resolvers.{Media, Person}
alias Mobilizon.GraphQL.Schema
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(:avatar, :picture, description: "The actor's avatar picture")
field(:banner, :picture, description: "The actor's banner picture")
field(:avatar, :media, description: "The actor's avatar media")
field(:banner, :media, description: "The actor's banner media")
# These one should have a privacy setting
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(:media_size, :integer,
resolve: &Picture.actor_size/3,
resolve: &Media.actor_size/3,
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(:avatar, :picture_input,
arg(:avatar, :media_input,
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:
"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)
@ -171,14 +171,14 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
arg(:summary, :string, description: "The summary for this profile")
arg(:avatar, :picture_input,
arg(:avatar, :media_input,
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:
"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)
@ -200,14 +200,14 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
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(:avatar, :picture_input,
arg(:avatar, :media_input,
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:
"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)

View file

@ -43,7 +43,6 @@ defmodule Mobilizon.GraphQL.Schema.AddressType do
An address input
"""
input_object :address_input do
# Either a full picture object
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(: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]
alias Mobilizon.{Actors, Addresses, Discussions}
alias Mobilizon.GraphQL.Resolvers.{Event, Picture, Tag}
alias Mobilizon.GraphQL.Resolvers.{Event, Media, Tag}
alias Mobilizon.GraphQL.Schema
import_types(Schema.AddressType)
@ -31,9 +31,14 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
field(:visibility, :event_visibility, 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",
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")
@ -328,9 +333,9 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
description: "The list of tags associated to the event"
)
arg(:picture, :picture_input,
arg(:picture, :media_input,
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")
@ -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(:picture, :picture_input,
arg(:picture, :media_input,
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")

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
"""
use Absinthe.Schema.Notation
alias Mobilizon.GraphQL.Resolvers.{Picture, Post, Tag}
alias Mobilizon.GraphQL.Resolvers.{Media, Post, Tag}
@desc "A post"
object :post do
@ -25,9 +25,9 @@ defmodule Mobilizon.GraphQL.Schema.PostType do
description: "The post's tags"
)
field(:picture, :picture,
description: "The posts's picture",
resolve: &Picture.picture/3
field(:picture, :media,
description: "The posts's media",
resolve: &Media.media/3
)
end
@ -76,9 +76,9 @@ defmodule Mobilizon.GraphQL.Schema.PostType do
description: "The list of tags associated to the post"
)
arg(:picture, :picture_input,
arg(:picture, :media_input,
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)
@ -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(:picture, :picture_input,
arg(:picture, :media_input,
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)

View file

@ -7,7 +7,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
alias Mobilizon.Events
alias Mobilizon.GraphQL.Resolvers.{Picture, User}
alias Mobilizon.GraphQL.Resolvers.{Media, User}
alias Mobilizon.GraphQL.Schema
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"
)
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,
default_value: 1,
description: "The page in the paginated user media list"
@ -122,7 +122,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
end
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)"
)
end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -84,7 +84,8 @@ defmodule Mobilizon.Events do
:participants,
:physical_address,
:picture,
:contacts
:contacts,
:media
]
@doc """
@ -295,7 +296,7 @@ defmodule Mobilizon.Events do
@spec update_event(Event.t(), map) :: {:ok, Event.t()} | {:error, Changeset.t()}
def update_event(%Event{draft: old_draft} = old_event, attrs) do
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}} <-
Multi.new()
|> 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 """
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 Mobilizon.Actors.Actor
alias Mobilizon.Events.Tag
alias Mobilizon.Media
alias Mobilizon.Media.Picture
alias Mobilizon.Medias
alias Mobilizon.Medias.Media
alias Mobilizon.Posts.Post.TitleSlug
alias Mobilizon.Posts.PostVisibility
alias Mobilizon.Web.Endpoint
@ -41,7 +41,8 @@ defmodule Mobilizon.Posts.Post do
publish_at: DateTime.t(),
author: Actor.t(),
attributed_to: Actor.t(),
picture: Picture.t(),
picture: Media.t(),
media: [Media.t()],
tags: [Tag.t()]
}
@ -60,6 +61,7 @@ defmodule Mobilizon.Posts.Post do
belongs_to(:attributed_to, Actor)
belongs_to(:picture, Picture, on_replace: :update)
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)
end
@ -82,6 +84,7 @@ defmodule Mobilizon.Posts.Post do
post
|> cast(attrs, @attrs)
|> maybe_generate_id()
|> put_assoc(:media, Map.get(attrs, :media, []))
|> put_tags(attrs)
|> maybe_put_publish_date()
|> put_picture(attrs)
@ -146,8 +149,8 @@ defmodule Mobilizon.Posts.Post do
# In case the provided picture is an existing one
@spec put_picture(Changeset.t(), map) :: Changeset.t()
defp put_picture(%Changeset{} = changeset, %{picture: %{picture_id: id} = _picture}) do
case Media.get_picture!(id) do
%Picture{} = picture ->
case Medias.get_media!(id) do
%Media{} = 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()}
def update_post(%Post{} = post, attrs) do
post
|> Repo.preload(:tags)
|> Repo.preload([:tags, :media])
|> Post.changeset(attrs)
|> Repo.update()
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
else
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()
end
end

View file

@ -1,6 +1,6 @@
defmodule Mobilizon.Web.Upload.Filter.Optimize do
@moduledoc """
Handle picture optimizations
Handle media optimizations
"""
@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.Type,
Mobilizon.Events.TagRelation,
Mobilizon.Media,
Mobilizon.Media.File,
Mobilizon.Media.Picture,
Mobilizon.Medias,
Mobilizon.Medias.File,
Mobilizon.Medias.Media,
Mobilizon.Mention,
Mobilizon.Reports,
Mobilizon.Reports.Note,
@ -328,7 +328,7 @@ defmodule Mobilizon.Mixfile do
Mobilizon.GraphQL.Resolvers.Group,
Mobilizon.GraphQL.Resolvers.Member,
Mobilizon.GraphQL.Resolvers.Person,
Mobilizon.GraphQL.Resolvers.Picture,
Mobilizon.GraphQL.Resolvers.Media,
Mobilizon.GraphQL.Resolvers.Report,
Mobilizon.GraphQL.Resolvers.Search,
Mobilizon.GraphQL.Resolvers.Tag,
@ -347,7 +347,7 @@ defmodule Mobilizon.Mixfile do
Mobilizon.GraphQL.Schema.EventType,
Mobilizon.GraphQL.Schema.Events.FeedTokenType,
Mobilizon.GraphQL.Schema.Events.ParticipantType,
Mobilizon.GraphQL.Schema.PictureType,
Mobilizon.GraphQL.Schema.MediaType,
Mobilizon.GraphQL.Schema.ReportType,
Mobilizon.GraphQL.Schema.SearchType,
Mobilizon.GraphQL.Schema.SortType,
@ -374,7 +374,7 @@ defmodule Mobilizon.Mixfile do
Mobilizon.Federation.ActivityStream.Converter.Flag,
Mobilizon.Federation.ActivityStream.Converter.Follower,
Mobilizon.Federation.ActivityStream.Converter.Participant,
Mobilizon.Federation.ActivityStream.Converter.Picture,
Mobilizon.Federation.ActivityStream.Converter.Media,
Mobilizon.Federation.ActivityStream.Converter.Tombstone,
Mobilizon.Federation.ActivityStream.Converter.Utils,
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}",
category: "birthday",
picture: {
picture: {
media: {
name: "picture for my event",
alt: "A very sunny landscape",
file: "event.jpg",
@ -569,13 +569,13 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
actor: actor,
user: user
} do
picture = %{name: "my pic", alt: "represents something", file: "picture.png"}
media = %{name: "my pic", alt: "represents something", file: "picture.png"}
mutation = """
mutation { uploadPicture(
name: "#{picture.name}",
alt: "#{picture.alt}",
file: "#{picture.file}"
mutation { uploadMedia (
name: "#{media.name}",
alt: "#{media.alt}",
file: "#{media.file}"
) {
id,
url,
@ -586,9 +586,9 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
map = %{
"query" => mutation,
picture.file => %Plug.Upload{
media.file => %Plug.Upload{
path: "test/fixtures/picture.png",
filename: picture.file
filename: media.file
}
}
@ -601,8 +601,8 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
map
)
assert json_response(res, 200)["data"]["uploadPicture"]["name"] == picture.name
picture_id = json_response(res, 200)["data"]["uploadPicture"]["id"]
assert json_response(res, 200)["data"]["uploadMedia"]["name"] == media.name
media_id = json_response(res, 200)["data"]["uploadMedia"]["id"]
mutation = """
mutation {
@ -615,7 +615,7 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
organizer_actor_id: "#{actor.id}",
category: "birthday",
picture: {
picture_id: "#{picture_id}"
media_id: "#{media_id}"
}
) {
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"]["picture"]["name"] == picture.name
assert json_response(res, 200)["data"]["createEvent"]["picture"]["name"] == media.name
assert json_response(res, 200)["data"]["createEvent"]["picture"]["url"]
end
@ -943,7 +943,7 @@ defmodule Mobilizon.Web.Resolvers.EventTest do
event_id: #{event.id},
category: "birthday",
picture: {
picture: {
media: {
name: "picture for my event",
alt: "A very sunny landscape",
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", %{
conn: conn,
user: user,
actor: actor
actor: _actor
} do
actor2 = insert(:actor)
event = insert(:event, organizer_actor: actor2)

View file

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

View file

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

View file

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

View file

@ -58,7 +58,8 @@ defmodule Mobilizon.DiscussionsTest do
%Comment{} = comment = insert(:comment)
assert {:error, %Ecto.Changeset{}} = Discussions.update_comment(comment, @invalid_attrs)
%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
test "delete_comment/1 deletes the comment" do

View file

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

View file

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

View file

@ -70,7 +70,7 @@ defmodule Mobilizon.PostsTest do
%Post{} = post = insert(:post)
assert {:error, %Ecto.Changeset{}} = Posts.update_post(post, @invalid_attrs)
%Post{} = post_fetched = Posts.get_post(post.id)
assert post = post_fetched
assert post.body == post_fetched.body
end
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),
uuid: uuid,
mentions: [],
media: [],
attributed_to: nil,
local: true,
deleted_at: nil,
@ -179,13 +180,14 @@ defmodule Mobilizon.Factory do
local: true,
publish_at: DateTime.utc_now(),
url: Routes.page_url(Endpoint, :event, uuid),
picture: insert(:picture),
picture: insert(:media),
uuid: uuid,
join_options: :free,
options: %{},
participant_stats: %{},
status: :confirmed,
contacts: []
contacts: [],
media: []
}
end
@ -269,16 +271,16 @@ defmodule Mobilizon.Factory do
size: 13_227
} = data
%Mobilizon.Media.File{
name: "My Picture",
%Mobilizon.Medias.File{
name: "My Media",
url: url,
content_type: "image/png",
size: 13_120
}
end
def picture_factory do
%Mobilizon.Media.Picture{
def media_factory do
%Mobilizon.Medias.Media{
file: build(:file),
actor: build(:actor)
}
@ -372,6 +374,7 @@ defmodule Mobilizon.Factory do
tags: build_list(3, :tag),
visibility: :public,
publish_at: DateTime.utc_now(),
media: [],
url: Routes.page_url(Endpoint, :post, uuid)
}
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