e921128c770c393348351e8705f65bf052471cc4
[anni] / lib / pleroma / web / api_spec / operations / status_operation.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.ApiSpec.StatusOperation do
6   alias OpenApiSpex.Operation
7   alias OpenApiSpex.Schema
8   alias Pleroma.Web.ApiSpec.AccountOperation
9   alias Pleroma.Web.ApiSpec.Schemas.Account
10   alias Pleroma.Web.ApiSpec.Schemas.ApiError
11   alias Pleroma.Web.ApiSpec.Schemas.Attachment
12   alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
13   alias Pleroma.Web.ApiSpec.Schemas.Emoji
14   alias Pleroma.Web.ApiSpec.Schemas.FlakeID
15   alias Pleroma.Web.ApiSpec.Schemas.Poll
16   alias Pleroma.Web.ApiSpec.Schemas.ScheduledStatus
17   alias Pleroma.Web.ApiSpec.Schemas.Status
18   alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
19
20   import Pleroma.Web.ApiSpec.Helpers
21
22   def open_api_operation(action) do
23     operation = String.to_existing_atom("#{action}_operation")
24     apply(__MODULE__, operation, [])
25   end
26
27   def index_operation do
28     %Operation{
29       tags: ["Retrieve status information"],
30       summary: "Multiple statuses",
31       security: [%{"oAuth" => ["read:statuses"]}],
32       parameters: [
33         Operation.parameter(
34           :ids,
35           :query,
36           %Schema{type: :array, items: FlakeID},
37           "Array of status IDs"
38         ),
39         Operation.parameter(
40           :with_muted,
41           :query,
42           BooleanLike,
43           "Include reactions from muted acccounts."
44         )
45       ],
46       operationId: "StatusController.index",
47       responses: %{
48         200 => Operation.response("Array of Status", "application/json", array_of_statuses())
49       }
50     }
51   end
52
53   def create_operation do
54     %Operation{
55       tags: ["Status actions"],
56       summary: "Publish new status",
57       security: [%{"oAuth" => ["write:statuses"]}],
58       description: "Post a new status",
59       operationId: "StatusController.create",
60       requestBody: request_body("Parameters", create_request(), required: true),
61       responses: %{
62         200 =>
63           Operation.response(
64             "Status. When `scheduled_at` is present, ScheduledStatus is returned instead",
65             "application/json",
66             %Schema{anyOf: [Status, ScheduledStatus]}
67           ),
68         422 => Operation.response("Bad Request / MRF Rejection", "application/json", ApiError)
69       }
70     }
71   end
72
73   def show_operation do
74     %Operation{
75       tags: ["Retrieve status information"],
76       summary: "Status",
77       description: "View information about a status",
78       operationId: "StatusController.show",
79       security: [%{"oAuth" => ["read:statuses"]}],
80       parameters: [
81         id_param(),
82         Operation.parameter(
83           :with_muted,
84           :query,
85           BooleanLike,
86           "Include reactions from muted acccounts."
87         )
88       ],
89       responses: %{
90         200 => status_response(),
91         404 => Operation.response("Not Found", "application/json", ApiError)
92       }
93     }
94   end
95
96   def delete_operation do
97     %Operation{
98       tags: ["Status actions"],
99       summary: "Delete",
100       security: [%{"oAuth" => ["write:statuses"]}],
101       description: "Delete one of your own statuses",
102       operationId: "StatusController.delete",
103       parameters: [id_param()],
104       responses: %{
105         200 => status_response(),
106         403 => Operation.response("Forbidden", "application/json", ApiError),
107         404 => Operation.response("Not Found", "application/json", ApiError)
108       }
109     }
110   end
111
112   def reblog_operation do
113     %Operation{
114       tags: ["Status actions"],
115       summary: "Reblog",
116       security: [%{"oAuth" => ["write:statuses"]}],
117       description: "Share a status",
118       operationId: "StatusController.reblog",
119       parameters: [id_param()],
120       requestBody:
121         request_body("Parameters", %Schema{
122           type: :object,
123           properties: %{
124             visibility: %Schema{allOf: [VisibilityScope]}
125           }
126         }),
127       responses: %{
128         200 => status_response(),
129         404 => Operation.response("Not Found", "application/json", ApiError)
130       }
131     }
132   end
133
134   def unreblog_operation do
135     %Operation{
136       tags: ["Status actions"],
137       summary: "Undo reblog",
138       security: [%{"oAuth" => ["write:statuses"]}],
139       description: "Undo a reshare of a status",
140       operationId: "StatusController.unreblog",
141       parameters: [id_param()],
142       responses: %{
143         200 => status_response(),
144         404 => Operation.response("Not Found", "application/json", ApiError)
145       }
146     }
147   end
148
149   def favourite_operation do
150     %Operation{
151       tags: ["Status actions"],
152       summary: "Favourite",
153       security: [%{"oAuth" => ["write:favourites"]}],
154       description: "Add a status to your favourites list",
155       operationId: "StatusController.favourite",
156       parameters: [id_param()],
157       responses: %{
158         200 => status_response(),
159         404 => Operation.response("Not Found", "application/json", ApiError)
160       }
161     }
162   end
163
164   def unfavourite_operation do
165     %Operation{
166       tags: ["Status actions"],
167       summary: "Undo favourite",
168       security: [%{"oAuth" => ["write:favourites"]}],
169       description: "Remove a status from your favourites list",
170       operationId: "StatusController.unfavourite",
171       parameters: [id_param()],
172       responses: %{
173         200 => status_response(),
174         404 => Operation.response("Not Found", "application/json", ApiError)
175       }
176     }
177   end
178
179   def pin_operation do
180     %Operation{
181       tags: ["Status actions"],
182       summary: "Pin to profile",
183       security: [%{"oAuth" => ["write:accounts"]}],
184       description: "Feature one of your own public statuses at the top of your profile",
185       operationId: "StatusController.pin",
186       parameters: [id_param()],
187       responses: %{
188         200 => status_response(),
189         400 =>
190           Operation.response("Bad Request", "application/json", %Schema{
191             allOf: [ApiError],
192             title: "Unprocessable Entity",
193             example: %{
194               "error" => "You have already pinned the maximum number of statuses"
195             }
196           }),
197         404 =>
198           Operation.response("Not found", "application/json", %Schema{
199             allOf: [ApiError],
200             title: "Unprocessable Entity",
201             example: %{
202               "error" => "Record not found"
203             }
204           }),
205         422 =>
206           Operation.response(
207             "Unprocessable Entity",
208             "application/json",
209             %Schema{
210               allOf: [ApiError],
211               title: "Unprocessable Entity",
212               example: %{
213                 "error" => "Someone else's status cannot be pinned"
214               }
215             }
216           )
217       }
218     }
219   end
220
221   def unpin_operation do
222     %Operation{
223       tags: ["Status actions"],
224       summary: "Unpin from profile",
225       security: [%{"oAuth" => ["write:accounts"]}],
226       description: "Unfeature a status from the top of your profile",
227       operationId: "StatusController.unpin",
228       parameters: [id_param()],
229       responses: %{
230         200 => status_response(),
231         400 =>
232           Operation.response("Bad Request", "application/json", %Schema{
233             allOf: [ApiError],
234             title: "Unprocessable Entity",
235             example: %{
236               "error" => "You have already pinned the maximum number of statuses"
237             }
238           }),
239         404 =>
240           Operation.response("Not found", "application/json", %Schema{
241             allOf: [ApiError],
242             title: "Unprocessable Entity",
243             example: %{
244               "error" => "Record not found"
245             }
246           })
247       }
248     }
249   end
250
251   def bookmark_operation do
252     %Operation{
253       tags: ["Status actions"],
254       summary: "Bookmark",
255       security: [%{"oAuth" => ["write:bookmarks"]}],
256       description: "Privately bookmark a status",
257       operationId: "StatusController.bookmark",
258       parameters: [id_param()],
259       responses: %{
260         200 => status_response()
261       }
262     }
263   end
264
265   def unbookmark_operation do
266     %Operation{
267       tags: ["Status actions"],
268       summary: "Undo bookmark",
269       security: [%{"oAuth" => ["write:bookmarks"]}],
270       description: "Remove a status from your private bookmarks",
271       operationId: "StatusController.unbookmark",
272       parameters: [id_param()],
273       responses: %{
274         200 => status_response()
275       }
276     }
277   end
278
279   def mute_conversation_operation do
280     %Operation{
281       tags: ["Status actions"],
282       summary: "Mute conversation",
283       security: [%{"oAuth" => ["write:mutes"]}],
284       description: "Do not receive notifications for the thread that this status is part of.",
285       operationId: "StatusController.mute_conversation",
286       requestBody:
287         request_body("Parameters", %Schema{
288           type: :object,
289           properties: %{
290             expires_in: %Schema{
291               type: :integer,
292               nullable: true,
293               description: "Expire the mute in `expires_in` seconds. Default 0 for infinity",
294               default: 0
295             }
296           }
297         }),
298       parameters: [
299         id_param(),
300         Operation.parameter(
301           :expires_in,
302           :query,
303           %Schema{type: :integer, default: 0},
304           "Expire the mute in `expires_in` seconds. Default 0 for infinity"
305         )
306       ],
307       responses: %{
308         200 => status_response(),
309         400 => Operation.response("Error", "application/json", ApiError)
310       }
311     }
312   end
313
314   def unmute_conversation_operation do
315     %Operation{
316       tags: ["Status actions"],
317       summary: "Unmute conversation",
318       security: [%{"oAuth" => ["write:mutes"]}],
319       description:
320         "Start receiving notifications again for the thread that this status is part of",
321       operationId: "StatusController.unmute_conversation",
322       parameters: [id_param()],
323       responses: %{
324         200 => status_response(),
325         400 => Operation.response("Error", "application/json", ApiError)
326       }
327     }
328   end
329
330   def card_operation do
331     %Operation{
332       tags: ["Retrieve status information"],
333       deprecated: true,
334       summary: "Preview card",
335       description: "Deprecated in favor of card property inlined on Status entity",
336       operationId: "StatusController.card",
337       parameters: [id_param()],
338       security: [%{"oAuth" => ["read:statuses"]}],
339       responses: %{
340         200 =>
341           Operation.response("Card", "application/json", %Schema{
342             type: :object,
343             nullable: true,
344             properties: %{
345               type: %Schema{type: :string, enum: ["link", "photo", "video", "rich"]},
346               provider_name: %Schema{type: :string, nullable: true},
347               provider_url: %Schema{type: :string, format: :uri},
348               url: %Schema{type: :string, format: :uri},
349               image: %Schema{type: :string, nullable: true, format: :uri},
350               title: %Schema{type: :string},
351               description: %Schema{type: :string}
352             }
353           })
354       }
355     }
356   end
357
358   def favourited_by_operation do
359     %Operation{
360       tags: ["Retrieve status information"],
361       summary: "Favourited by",
362       description: "View who favourited a given status",
363       operationId: "StatusController.favourited_by",
364       security: [%{"oAuth" => ["read:accounts"]}],
365       parameters: [id_param()],
366       responses: %{
367         200 =>
368           Operation.response(
369             "Array of Accounts",
370             "application/json",
371             AccountOperation.array_of_accounts()
372           ),
373         404 => Operation.response("Not Found", "application/json", ApiError)
374       }
375     }
376   end
377
378   def reblogged_by_operation do
379     %Operation{
380       tags: ["Retrieve status information"],
381       summary: "Reblogged by",
382       description: "View who reblogged a given status",
383       operationId: "StatusController.reblogged_by",
384       security: [%{"oAuth" => ["read:accounts"]}],
385       parameters: [id_param()],
386       responses: %{
387         200 =>
388           Operation.response(
389             "Array of Accounts",
390             "application/json",
391             AccountOperation.array_of_accounts()
392           ),
393         404 => Operation.response("Not Found", "application/json", ApiError)
394       }
395     }
396   end
397
398   def context_operation do
399     %Operation{
400       tags: ["Retrieve status information"],
401       summary: "Parent and child statuses",
402       description: "View statuses above and below this status in the thread",
403       operationId: "StatusController.context",
404       security: [%{"oAuth" => ["read:statuses"]}],
405       parameters: [id_param()],
406       responses: %{
407         200 => Operation.response("Context", "application/json", context())
408       }
409     }
410   end
411
412   def favourites_operation do
413     %Operation{
414       tags: ["Timelines"],
415       summary: "Favourited statuses",
416       description:
417         "Statuses the user has favourited. Please note that you have to use the link headers to paginate this. You can not build the query parameters yourself.",
418       operationId: "StatusController.favourites",
419       parameters: pagination_params(),
420       security: [%{"oAuth" => ["read:favourites"]}],
421       responses: %{
422         200 => Operation.response("Array of Statuses", "application/json", array_of_statuses())
423       }
424     }
425   end
426
427   def bookmarks_operation do
428     %Operation{
429       tags: ["Timelines"],
430       summary: "Bookmarked statuses",
431       description: "Statuses the user has bookmarked",
432       operationId: "StatusController.bookmarks",
433       parameters: pagination_params(),
434       security: [%{"oAuth" => ["read:bookmarks"]}],
435       responses: %{
436         200 => Operation.response("Array of Statuses", "application/json", array_of_statuses())
437       }
438     }
439   end
440
441   def show_history_operation do
442     %Operation{
443       tags: ["Retrieve status history"],
444       summary: "Status history",
445       description: "View history of a status",
446       operationId: "StatusController.show_history",
447       security: [%{"oAuth" => ["read:statuses"]}],
448       parameters: [
449         id_param()
450       ],
451       responses: %{
452         200 => status_history_response(),
453         404 => Operation.response("Not Found", "application/json", ApiError)
454       }
455     }
456   end
457
458   def show_source_operation do
459     %Operation{
460       tags: ["Retrieve status source"],
461       summary: "Status source",
462       description: "View source of a status",
463       operationId: "StatusController.show_source",
464       security: [%{"oAuth" => ["read:statuses"]}],
465       parameters: [
466         id_param()
467       ],
468       responses: %{
469         200 => status_source_response(),
470         404 => Operation.response("Not Found", "application/json", ApiError)
471       }
472     }
473   end
474
475   def update_operation do
476     %Operation{
477       tags: ["Update status"],
478       summary: "Update status",
479       description: "Change the content of a status",
480       operationId: "StatusController.update",
481       security: [%{"oAuth" => ["write:statuses"]}],
482       parameters: [
483         id_param()
484       ],
485       requestBody: request_body("Parameters", update_request(), required: true),
486       responses: %{
487         200 => status_response(),
488         403 => Operation.response("Forbidden", "application/json", ApiError),
489         404 => Operation.response("Not Found", "application/json", ApiError)
490       }
491     }
492   end
493
494   def array_of_statuses do
495     %Schema{type: :array, items: Status, example: [Status.schema().example]}
496   end
497
498   defp create_request do
499     %Schema{
500       title: "StatusCreateRequest",
501       type: :object,
502       properties: %{
503         status: %Schema{
504           type: :string,
505           nullable: true,
506           description:
507             "Text content of the status. If `media_ids` is provided, this becomes optional. Attaching a `poll` is optional while `status` is provided."
508         },
509         media_ids: %Schema{
510           nullable: true,
511           type: :array,
512           items: %Schema{type: :string},
513           description: "Array of Attachment ids to be attached as media."
514         },
515         poll: poll_params(),
516         in_reply_to_id: %Schema{
517           nullable: true,
518           allOf: [FlakeID],
519           description: "ID of the status being replied to, if status is a reply"
520         },
521         sensitive: %Schema{
522           allOf: [BooleanLike],
523           nullable: true,
524           description: "Mark status and attached media as sensitive?"
525         },
526         spoiler_text: %Schema{
527           type: :string,
528           nullable: true,
529           description:
530             "Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field."
531         },
532         scheduled_at: %Schema{
533           type: :string,
534           format: :"date-time",
535           nullable: true,
536           description:
537             "ISO 8601 Datetime at which to schedule a status. Providing this paramter will cause ScheduledStatus to be returned instead of Status. Must be at least 5 minutes in the future."
538         },
539         language: %Schema{
540           type: :string,
541           nullable: true,
542           description: "ISO 639 language code for this status."
543         },
544         # Pleroma-specific properties:
545         preview: %Schema{
546           allOf: [BooleanLike],
547           nullable: true,
548           description:
549             "If set to `true` the post won't be actually posted, but the status entitiy would still be rendered back. This could be useful for previewing rich text/custom emoji, for example"
550         },
551         content_type: %Schema{
552           type: :string,
553           nullable: true,
554           description:
555             "The MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint."
556         },
557         to: %Schema{
558           type: :array,
559           nullable: true,
560           items: %Schema{type: :string},
561           description:
562             "A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for for post visibility are not affected by this and will still apply"
563         },
564         visibility: %Schema{
565           nullable: true,
566           anyOf: [
567             VisibilityScope,
568             %Schema{type: :string, description: "`list:LIST_ID`", example: "LIST:123"}
569           ],
570           description:
571             "Visibility of the posted status. Besides standard MastoAPI values (`direct`, `private`, `unlisted` or `public`) it can be used to address a List by setting it to `list:LIST_ID`"
572         },
573         expires_in: %Schema{
574           nullable: true,
575           type: :integer,
576           description:
577             "The number of seconds the posted activity should expire in. When a posted activity expires it will be deleted from the server, and a delete request for it will be federated. This needs to be longer than an hour."
578         },
579         in_reply_to_conversation_id: %Schema{
580           nullable: true,
581           type: :string,
582           description:
583             "Will reply to a given conversation, addressing only the people who are part of the recipient set of that conversation. Sets the visibility to `direct`."
584         }
585       },
586       example: %{
587         "status" => "What time is it?",
588         "sensitive" => "false",
589         "poll" => %{
590           "options" => ["Cofe", "Adventure"],
591           "expires_in" => 420
592         }
593       }
594     }
595   end
596
597   defp update_request do
598     %Schema{
599       title: "StatusUpdateRequest",
600       type: :object,
601       properties: %{
602         status: %Schema{
603           type: :string,
604           nullable: true,
605           description:
606             "Text content of the status. If `media_ids` is provided, this becomes optional. Attaching a `poll` is optional while `status` is provided."
607         },
608         media_ids: %Schema{
609           nullable: true,
610           type: :array,
611           items: %Schema{type: :string},
612           description: "Array of Attachment ids to be attached as media."
613         },
614         poll: poll_params(),
615         sensitive: %Schema{
616           allOf: [BooleanLike],
617           nullable: true,
618           description: "Mark status and attached media as sensitive?"
619         },
620         spoiler_text: %Schema{
621           type: :string,
622           nullable: true,
623           description:
624             "Text to be shown as a warning or subject before the actual content. Statuses are generally collapsed behind this field."
625         },
626         content_type: %Schema{
627           type: :string,
628           nullable: true,
629           description:
630             "The MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint."
631         },
632         to: %Schema{
633           type: :array,
634           nullable: true,
635           items: %Schema{type: :string},
636           description:
637             "A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for for post visibility are not affected by this and will still apply"
638         }
639       },
640       example: %{
641         "status" => "What time is it?",
642         "sensitive" => "false",
643         "poll" => %{
644           "options" => ["Cofe", "Adventure"],
645           "expires_in" => 420
646         }
647       }
648     }
649   end
650
651   def poll_params do
652     %Schema{
653       nullable: true,
654       type: :object,
655       required: [:options, :expires_in],
656       properties: %{
657         options: %Schema{
658           type: :array,
659           items: %Schema{type: :string},
660           description: "Array of possible answers. Must be provided with `poll[expires_in]`."
661         },
662         expires_in: %Schema{
663           type: :integer,
664           nullable: true,
665           description:
666             "Duration the poll should be open, in seconds. Must be provided with `poll[options]`"
667         },
668         multiple: %Schema{
669           allOf: [BooleanLike],
670           nullable: true,
671           description: "Allow multiple choices?"
672         },
673         hide_totals: %Schema{
674           allOf: [BooleanLike],
675           nullable: true,
676           description: "Hide vote counts until the poll ends?"
677         }
678       }
679     }
680   end
681
682   def id_param do
683     Operation.parameter(:id, :path, FlakeID, "Status ID",
684       example: "9umDrYheeY451cQnEe",
685       required: true
686     )
687   end
688
689   defp status_response do
690     Operation.response("Status", "application/json", Status)
691   end
692
693   defp status_history_response do
694     Operation.response(
695       "Status History",
696       "application/json",
697       %Schema{
698         title: "Status history",
699         description: "Response schema for history of a status",
700         type: :array,
701         items: %Schema{
702           type: :object,
703           properties: %{
704             account: %Schema{
705               allOf: [Account],
706               description: "The account that authored this status"
707             },
708             content: %Schema{
709               type: :string,
710               format: :html,
711               description: "HTML-encoded status content"
712             },
713             sensitive: %Schema{
714               type: :boolean,
715               description: "Is this status marked as sensitive content?"
716             },
717             spoiler_text: %Schema{
718               type: :string,
719               description:
720                 "Subject or summary line, below which status content is collapsed until expanded"
721             },
722             created_at: %Schema{
723               type: :string,
724               format: "date-time",
725               description: "The date when this status was created"
726             },
727             media_attachments: %Schema{
728               type: :array,
729               items: Attachment,
730               description: "Media that is attached to this status"
731             },
732             emojis: %Schema{
733               type: :array,
734               items: Emoji,
735               description: "Custom emoji to be used when rendering status content"
736             },
737             poll: %Schema{
738               allOf: [Poll],
739               nullable: true,
740               description: "The poll attached to the status"
741             }
742           }
743         }
744       }
745     )
746   end
747
748   defp status_source_response do
749     Operation.response(
750       "Status Source",
751       "application/json",
752       %Schema{
753         type: :object,
754         properties: %{
755           id: FlakeID,
756           text: %Schema{
757             type: :string,
758             description: "Raw source of status content"
759           },
760           spoiler_text: %Schema{
761             type: :string,
762             description:
763               "Subject or summary line, below which status content is collapsed until expanded"
764           },
765           content_type: %Schema{
766             type: :string,
767             description: "The content type of the source"
768           }
769         }
770       }
771     )
772   end
773
774   defp context do
775     %Schema{
776       title: "StatusContext",
777       description:
778         "Represents the tree around a given status. Used for reconstructing threads of statuses.",
779       type: :object,
780       required: [:ancestors, :descendants],
781       properties: %{
782         ancestors: array_of_statuses(),
783         descendants: array_of_statuses()
784       },
785       example: %{
786         "ancestors" => [Status.schema().example],
787         "descendants" => [Status.schema().example]
788       }
789     }
790   end
791 end