aboutsummaryrefslogtreecommitdiffhomepage
path: root/server
diff options
context:
space:
mode:
Diffstat (limited to 'server')
-rw-r--r--server/controllers/activitypub/client.ts20
-rw-r--r--server/controllers/activitypub/utils.ts1
-rw-r--r--server/controllers/api/abuse.ts8
-rw-r--r--server/controllers/api/bulk.ts2
-rw-r--r--server/controllers/api/config.ts25
-rw-r--r--server/controllers/api/custom-page.ts47
-rw-r--r--server/controllers/api/index.ts2
-rw-r--r--server/controllers/api/jobs.ts2
-rw-r--r--server/controllers/api/oauth-clients.ts8
-rw-r--r--server/controllers/api/plugins.ts53
-rw-r--r--server/controllers/api/search.ts286
-rw-r--r--server/controllers/api/search/index.ts16
-rw-r--r--server/controllers/api/search/search-video-channels.ts150
-rw-r--r--server/controllers/api/search/search-video-playlists.ts129
-rw-r--r--server/controllers/api/search/search-videos.ts153
-rw-r--r--server/controllers/api/server/debug.ts3
-rw-r--r--server/controllers/api/server/follows.ts20
-rw-r--r--server/controllers/api/server/redundancy.ts6
-rw-r--r--server/controllers/api/server/server-blocklist.ts2
-rw-r--r--server/controllers/api/users/index.ts28
-rw-r--r--server/controllers/api/users/me.ts53
-rw-r--r--server/controllers/api/users/my-blocklist.ts2
-rw-r--r--server/controllers/api/users/my-history.ts2
-rw-r--r--server/controllers/api/users/my-notifications.ts14
-rw-r--r--server/controllers/api/users/my-subscriptions.ts2
-rw-r--r--server/controllers/api/users/token.ts15
-rw-r--r--server/controllers/api/video-channel.ts17
-rw-r--r--server/controllers/api/video-playlist.ts23
-rw-r--r--server/controllers/api/videos/blacklist.ts10
-rw-r--r--server/controllers/api/videos/comment.ts7
-rw-r--r--server/controllers/api/videos/import.ts189
-rw-r--r--server/controllers/api/videos/index.ts454
-rw-r--r--server/controllers/api/videos/live.ts13
-rw-r--r--server/controllers/api/videos/ownership.ts12
-rw-r--r--server/controllers/api/videos/update.ts193
-rw-r--r--server/controllers/api/videos/upload.ts278
-rw-r--r--server/controllers/api/videos/watching.ts11
-rw-r--r--server/controllers/bots.ts2
-rw-r--r--server/controllers/client.ts19
-rw-r--r--server/controllers/download.ts27
-rw-r--r--server/controllers/feeds.ts5
-rw-r--r--server/controllers/lazy-static.ts36
-rw-r--r--server/controllers/live.ts6
-rw-r--r--server/controllers/plugins.ts10
-rw-r--r--server/controllers/services.ts7
-rw-r--r--server/controllers/static.ts247
-rw-r--r--server/helpers/actor.ts16
-rw-r--r--server/helpers/audit-logger.ts2
-rw-r--r--server/helpers/core-utils.ts105
-rw-r--r--server/helpers/custom-validators/activitypub/playlist.ts7
-rw-r--r--server/helpers/custom-validators/misc.ts86
-rw-r--r--server/helpers/custom-validators/video-comments.ts81
-rw-r--r--server/helpers/custom-validators/video-imports.ts19
-rw-r--r--server/helpers/custom-validators/video-ownership.ts32
-rw-r--r--server/helpers/database-utils.ts31
-rw-r--r--server/helpers/express-utils.ts28
-rw-r--r--server/helpers/ffmpeg-utils.ts17
-rw-r--r--server/helpers/ffprobe-utils.ts3
-rw-r--r--server/helpers/image-utils.ts11
-rw-r--r--server/helpers/logger.ts5
-rw-r--r--server/helpers/markdown.ts8
-rw-r--r--server/helpers/promise-cache.ts21
-rw-r--r--server/helpers/requests.ts9
-rw-r--r--server/helpers/uuid.ts32
-rw-r--r--server/helpers/video.ts67
-rw-r--r--server/helpers/youtube-dl.ts553
-rw-r--r--server/initializers/checker-after-init.ts2
-rw-r--r--server/initializers/checker-before-init.ts2
-rw-r--r--server/initializers/config.ts1
-rw-r--r--server/initializers/constants.ts19
-rw-r--r--server/initializers/database.ts18
-rw-r--r--server/initializers/installer.ts2
-rw-r--r--server/initializers/migrations/0080-video-channels.ts4
-rw-r--r--server/initializers/migrations/0345-video-playlists.ts4
-rw-r--r--server/initializers/migrations/0560-user-feed-token.ts4
-rw-r--r--server/initializers/migrations/0650-actor-custom-pages.ts33
-rw-r--r--server/lib/activitypub/actor.ts594
-rw-r--r--server/lib/activitypub/actors/get.ts122
-rw-r--r--server/lib/activitypub/actors/image.ts94
-rw-r--r--server/lib/activitypub/actors/index.ts6
-rw-r--r--server/lib/activitypub/actors/keys.ts16
-rw-r--r--server/lib/activitypub/actors/refresh.ts81
-rw-r--r--server/lib/activitypub/actors/shared/creator.ts149
-rw-r--r--server/lib/activitypub/actors/shared/index.ts3
-rw-r--r--server/lib/activitypub/actors/shared/object-to-model-attributes.ts70
-rw-r--r--server/lib/activitypub/actors/shared/url-to-object.ts54
-rw-r--r--server/lib/activitypub/actors/updater.ts90
-rw-r--r--server/lib/activitypub/actors/webfinger.ts (renamed from server/helpers/webfinger.ts)16
-rw-r--r--server/lib/activitypub/audience.ts2
-rw-r--r--server/lib/activitypub/cache-file.ts91
-rw-r--r--server/lib/activitypub/crawl.ts7
-rw-r--r--server/lib/activitypub/follow.ts13
-rw-r--r--server/lib/activitypub/outbox.ts24
-rw-r--r--server/lib/activitypub/playlist.ts204
-rw-r--r--server/lib/activitypub/playlists/create-update.ts156
-rw-r--r--server/lib/activitypub/playlists/get.ts35
-rw-r--r--server/lib/activitypub/playlists/index.ts3
-rw-r--r--server/lib/activitypub/playlists/refresh.ts53
-rw-r--r--server/lib/activitypub/playlists/shared/index.ts2
-rw-r--r--server/lib/activitypub/playlists/shared/object-to-model-attributes.ts40
-rw-r--r--server/lib/activitypub/playlists/shared/url-to-object.ts47
-rw-r--r--server/lib/activitypub/process/process-accept.ts4
-rw-r--r--server/lib/activitypub/process/process-announce.ts4
-rw-r--r--server/lib/activitypub/process/process-create.ts12
-rw-r--r--server/lib/activitypub/process/process-delete.ts11
-rw-r--r--server/lib/activitypub/process/process-dislike.ts4
-rw-r--r--server/lib/activitypub/process/process-follow.ts21
-rw-r--r--server/lib/activitypub/process/process-like.ts4
-rw-r--r--server/lib/activitypub/process/process-reject.ts2
-rw-r--r--server/lib/activitypub/process/process-undo.ts16
-rw-r--r--server/lib/activitypub/process/process-update.ts104
-rw-r--r--server/lib/activitypub/process/process-view.ts13
-rw-r--r--server/lib/activitypub/process/process.ts14
-rw-r--r--server/lib/activitypub/send/send-delete.ts2
-rw-r--r--server/lib/activitypub/send/send-view.ts2
-rw-r--r--server/lib/activitypub/send/utils.ts16
-rw-r--r--server/lib/activitypub/share.ts40
-rw-r--r--server/lib/activitypub/video-comments.ts41
-rw-r--r--server/lib/activitypub/video-rates.ts64
-rw-r--r--server/lib/activitypub/videos.ts931
-rw-r--r--server/lib/activitypub/videos/federate.ts36
-rw-r--r--server/lib/activitypub/videos/get.ts113
-rw-r--r--server/lib/activitypub/videos/index.ts4
-rw-r--r--server/lib/activitypub/videos/refresh.ts68
-rw-r--r--server/lib/activitypub/videos/shared/abstract-builder.ts173
-rw-r--r--server/lib/activitypub/videos/shared/creator.ts88
-rw-r--r--server/lib/activitypub/videos/shared/index.ts6
-rw-r--r--server/lib/activitypub/videos/shared/object-to-model-attributes.ts256
-rw-r--r--server/lib/activitypub/videos/shared/trackers.ts43
-rw-r--r--server/lib/activitypub/videos/shared/url-to-object.ts25
-rw-r--r--server/lib/activitypub/videos/shared/video-sync-attributes.ts94
-rw-r--r--server/lib/activitypub/videos/updater.ts166
-rw-r--r--server/lib/auth/oauth-model.ts4
-rw-r--r--server/lib/client-html.ts48
-rw-r--r--server/lib/config.ts255
-rw-r--r--server/lib/job-queue/handlers/activitypub-follow.ts21
-rw-r--r--server/lib/job-queue/handlers/activitypub-http-broadcast.ts3
-rw-r--r--server/lib/job-queue/handlers/activitypub-http-fetcher.ts10
-rw-r--r--server/lib/job-queue/handlers/activitypub-http-unicast.ts2
-rw-r--r--server/lib/job-queue/handlers/activitypub-refresher.ts16
-rw-r--r--server/lib/job-queue/handlers/actor-keys.ts4
-rw-r--r--server/lib/job-queue/handlers/utils/activitypub-http-utils.ts8
-rw-r--r--server/lib/job-queue/handlers/video-file-import.ts6
-rw-r--r--server/lib/job-queue/handlers/video-import.ts11
-rw-r--r--server/lib/job-queue/handlers/video-live-ending.ts21
-rw-r--r--server/lib/job-queue/handlers/video-transcoding.ts53
-rw-r--r--server/lib/job-queue/handlers/video-views.ts4
-rw-r--r--server/lib/live-manager.ts621
-rw-r--r--server/lib/live/index.ts4
-rw-r--r--server/lib/live/live-manager.ts419
-rw-r--r--server/lib/live/live-quota-store.ts48
-rw-r--r--server/lib/live/live-segment-sha-store.ts64
-rw-r--r--server/lib/live/live-utils.ts23
-rw-r--r--server/lib/live/shared/index.ts1
-rw-r--r--server/lib/live/shared/muxing-session.ts346
-rw-r--r--server/lib/local-actor.ts (renamed from server/lib/actor-image.ts)38
-rw-r--r--server/lib/model-loaders/actor.ts17
-rw-r--r--server/lib/model-loaders/index.ts2
-rw-r--r--server/lib/model-loaders/video.ts73
-rw-r--r--server/lib/moderation.ts8
-rw-r--r--server/lib/notifier.ts4
-rw-r--r--server/lib/plugins/hooks.ts6
-rw-r--r--server/lib/plugins/plugin-helpers-builder.ts6
-rw-r--r--server/lib/plugins/plugin-index.ts20
-rw-r--r--server/lib/plugins/plugin-manager.ts41
-rw-r--r--server/lib/plugins/register-helpers.ts34
-rw-r--r--server/lib/redundancy.ts12
-rw-r--r--server/lib/schedulers/actor-follow-scheduler.ts4
-rw-r--r--server/lib/schedulers/auto-follow-index-instances.ts2
-rw-r--r--server/lib/schedulers/remove-old-history-scheduler.ts2
-rw-r--r--server/lib/schedulers/update-videos-scheduler.ts32
-rw-r--r--server/lib/schedulers/videos-redundancy-scheduler.ts4
-rw-r--r--server/lib/schedulers/youtube-dl-update-scheduler.ts6
-rw-r--r--server/lib/search.ts50
-rw-r--r--server/lib/server-config-manager.ts304
-rw-r--r--server/lib/signup.ts (renamed from server/helpers/signup.ts)2
-rw-r--r--server/lib/stat-manager.ts4
-rw-r--r--server/lib/thumbnail.ts32
-rw-r--r--server/lib/transcoding/video-transcoding-profiles.ts (renamed from server/lib/video-transcoding-profiles.ts)8
-rw-r--r--server/lib/transcoding/video-transcoding.ts (renamed from server/lib/video-transcoding.ts)42
-rw-r--r--server/lib/user.ts36
-rw-r--r--server/lib/video-blacklist.ts2
-rw-r--r--server/lib/video-channel.ts6
-rw-r--r--server/lib/video-comment.ts15
-rw-r--r--server/lib/video.ts6
-rw-r--r--server/middlewares/activitypub.ts53
-rw-r--r--server/middlewares/auth.ts17
-rw-r--r--server/middlewares/doc.ts16
-rw-r--r--server/middlewares/error.ts39
-rw-r--r--server/middlewares/index.ts2
-rw-r--r--server/middlewares/servers.ts5
-rw-r--r--server/middlewares/user-right.ts6
-rw-r--r--server/middlewares/validators/abuse.ts34
-rw-r--r--server/middlewares/validators/account.ts3
-rw-r--r--server/middlewares/validators/activitypub/activity.ts6
-rw-r--r--server/middlewares/validators/activitypub/pagination.ts4
-rw-r--r--server/middlewares/validators/activitypub/signature.ts7
-rw-r--r--server/middlewares/validators/actor-image.ts2
-rw-r--r--server/middlewares/validators/blocklist.ts44
-rw-r--r--server/middlewares/validators/bulk.ts11
-rw-r--r--server/middlewares/validators/config.ts16
-rw-r--r--server/middlewares/validators/feeds.ts31
-rw-r--r--server/middlewares/validators/follows.ts44
-rw-r--r--server/middlewares/validators/index.ts2
-rw-r--r--server/middlewares/validators/jobs.ts2
-rw-r--r--server/middlewares/validators/logs.ts14
-rw-r--r--server/middlewares/validators/oembed.ts79
-rw-r--r--server/middlewares/validators/pagination.ts4
-rw-r--r--server/middlewares/validators/plugins.ts56
-rw-r--r--server/middlewares/validators/redundancy.ts87
-rw-r--r--server/middlewares/validators/search.ts31
-rw-r--r--server/middlewares/validators/server.ts43
-rw-r--r--server/middlewares/validators/shared/abuses.ts (renamed from server/helpers/middlewares/abuses.ts)10
-rw-r--r--server/middlewares/validators/shared/accounts.ts (renamed from server/helpers/middlewares/accounts.ts)24
-rw-r--r--server/middlewares/validators/shared/index.ts (renamed from server/helpers/middlewares/index.ts)4
-rw-r--r--server/middlewares/validators/shared/utils.ts (renamed from server/middlewares/validators/utils.ts)31
-rw-r--r--server/middlewares/validators/shared/video-blacklists.ts (renamed from server/helpers/middlewares/video-blacklists.ts)12
-rw-r--r--server/middlewares/validators/shared/video-captions.ts (renamed from server/helpers/middlewares/video-captions.ts)11
-rw-r--r--server/middlewares/validators/shared/video-channels.ts (renamed from server/helpers/middlewares/video-channels.ts)11
-rw-r--r--server/middlewares/validators/shared/video-comments.ts73
-rw-r--r--server/middlewares/validators/shared/video-imports.ts22
-rw-r--r--server/middlewares/validators/shared/video-ownerships.ts24
-rw-r--r--server/middlewares/validators/shared/video-playlists.ts (renamed from server/helpers/middlewares/video-playlists.ts)14
-rw-r--r--server/middlewares/validators/shared/videos.ts (renamed from server/helpers/middlewares/videos.ts)70
-rw-r--r--server/middlewares/validators/sort.ts5
-rw-r--r--server/middlewares/validators/themes.ts18
-rw-r--r--server/middlewares/validators/user-history.ts6
-rw-r--r--server/middlewares/validators/user-notifications.ts6
-rw-r--r--server/middlewares/validators/user-subscriptions.ts17
-rw-r--r--server/middlewares/validators/users.ts126
-rw-r--r--server/middlewares/validators/videos/index.ts2
-rw-r--r--server/middlewares/validators/videos/video-blacklist.ts25
-rw-r--r--server/middlewares/validators/videos/video-captions.ts23
-rw-r--r--server/middlewares/validators/videos/video-channels.ts67
-rw-r--r--server/middlewares/validators/videos/video-comments.ts67
-rw-r--r--server/middlewares/validators/videos/video-imports.ts35
-rw-r--r--server/middlewares/validators/videos/video-live.ts91
-rw-r--r--server/middlewares/validators/videos/video-ownership-changes.ts121
-rw-r--r--server/middlewares/validators/videos/video-playlists.ts113
-rw-r--r--server/middlewares/validators/videos/video-rates.ts20
-rw-r--r--server/middlewares/validators/videos/video-shares.ts13
-rw-r--r--server/middlewares/validators/videos/video-watch.ts17
-rw-r--r--server/middlewares/validators/videos/videos.ts218
-rw-r--r--server/middlewares/validators/webfinger.ts15
-rw-r--r--server/models/abuse/abuse-message.ts3
-rw-r--r--server/models/abuse/abuse.ts4
-rw-r--r--server/models/abuse/video-abuse.ts3
-rw-r--r--server/models/abuse/video-comment-abuse.ts3
-rw-r--r--server/models/account/account-blocklist.ts5
-rw-r--r--server/models/account/account-video-rate.ts5
-rw-r--r--server/models/account/account.ts13
-rw-r--r--server/models/account/actor-custom-page.ts69
-rw-r--r--server/models/actor/actor-follow.ts (renamed from server/models/activitypub/actor-follow.ts)5
-rw-r--r--server/models/actor/actor-image.ts (renamed from server/models/account/actor-image.ts)7
-rw-r--r--server/models/actor/actor.ts (renamed from server/models/activitypub/actor.ts)16
-rw-r--r--server/models/application/application.ts5
-rw-r--r--server/models/oauth/oauth-client.ts3
-rw-r--r--server/models/oauth/oauth-token.ts7
-rw-r--r--server/models/redundancy/video-redundancy.ts8
-rw-r--r--server/models/server/plugin.ts7
-rw-r--r--server/models/server/server-blocklist.ts3
-rw-r--r--server/models/server/server.ts11
-rw-r--r--server/models/server/tracker.ts3
-rw-r--r--server/models/server/video-tracker.ts3
-rw-r--r--server/models/user/user-notification-setting.ts (renamed from server/models/account/user-notification-setting.ts)3
-rw-r--r--server/models/user/user-notification.ts (renamed from server/models/account/user-notification.ts)11
-rw-r--r--server/models/user/user-video-history.ts (renamed from server/models/account/user-video-history.ts)7
-rw-r--r--server/models/user/user.ts (renamed from server/models/account/user.ts)11
-rw-r--r--server/models/utils.ts11
-rw-r--r--server/models/video/formatter/video-format-utils.ts (renamed from server/models/video/video-format-utils.ts)78
-rw-r--r--server/models/video/schedule-video-update.ts29
-rw-r--r--server/models/video/sql/shared/abstract-videos-model-query-builder.ts300
-rw-r--r--server/models/video/sql/shared/abstract-videos-query-builder.ts26
-rw-r--r--server/models/video/sql/shared/video-file-query-builder.ts69
-rw-r--r--server/models/video/sql/shared/video-model-builder.ts333
-rw-r--r--server/models/video/sql/shared/video-tables.ts263
-rw-r--r--server/models/video/sql/video-model-get-query-builder.ts173
-rw-r--r--server/models/video/sql/videos-id-list-query-builder.ts616
-rw-r--r--server/models/video/sql/videos-model-list-query-builder.ts71
-rw-r--r--server/models/video/tag.ts3
-rw-r--r--server/models/video/thumbnail.ts3
-rw-r--r--server/models/video/video-blacklist.ts3
-rw-r--r--server/models/video/video-caption.ts30
-rw-r--r--server/models/video/video-change-ownership.ts3
-rw-r--r--server/models/video/video-channel.ts20
-rw-r--r--server/models/video/video-comment.ts21
-rw-r--r--server/models/video/video-file.ts3
-rw-r--r--server/models/video/video-import.ts7
-rw-r--r--server/models/video/video-live.ts3
-rw-r--r--server/models/video/video-playlist-element.ts6
-rw-r--r--server/models/video/video-playlist.ts100
-rw-r--r--server/models/video/video-query-builder.ts599
-rw-r--r--server/models/video/video-share.ts5
-rw-r--r--server/models/video/video-streaming-playlist.ts3
-rw-r--r--server/models/video/video-tag.ts3
-rw-r--r--server/models/video/video-view.ts5
-rw-r--r--server/models/video/video.ts498
-rw-r--r--server/tests/api/activitypub/client.ts98
-rw-r--r--server/tests/api/activitypub/helpers.ts37
-rw-r--r--server/tests/api/activitypub/security.ts7
-rw-r--r--server/tests/api/check-params/abuses.ts2
-rw-r--r--server/tests/api/check-params/config.ts3
-rw-r--r--server/tests/api/check-params/custom-pages.ts81
-rw-r--r--server/tests/api/check-params/index.ts1
-rw-r--r--server/tests/api/check-params/live.ts52
-rw-r--r--server/tests/api/check-params/plugins.ts12
-rw-r--r--server/tests/api/check-params/redundancy.ts38
-rw-r--r--server/tests/api/check-params/search.ts25
-rw-r--r--server/tests/api/check-params/users.ts22
-rw-r--r--server/tests/api/check-params/video-blacklist.ts12
-rw-r--r--server/tests/api/check-params/video-captions.ts47
-rw-r--r--server/tests/api/check-params/video-channels.ts14
-rw-r--r--server/tests/api/check-params/video-comments.ts31
-rw-r--r--server/tests/api/check-params/video-imports.ts18
-rw-r--r--server/tests/api/check-params/video-playlists.ts64
-rw-r--r--server/tests/api/check-params/videos-filter.ts2
-rw-r--r--server/tests/api/check-params/videos.ts126
-rw-r--r--server/tests/api/live/index.ts2
-rw-r--r--server/tests/api/live/live-permanent.ts4
-rw-r--r--server/tests/api/live/live-socket-messages.ts196
-rw-r--r--server/tests/api/live/live-views.ts130
-rw-r--r--server/tests/api/live/live.ts231
-rw-r--r--server/tests/api/moderation/blocklist.ts60
-rw-r--r--server/tests/api/notifications/comments-notifications.ts25
-rw-r--r--server/tests/api/notifications/moderation-notifications.ts30
-rw-r--r--server/tests/api/notifications/user-notifications.ts10
-rw-r--r--server/tests/api/search/index.ts4
-rw-r--r--server/tests/api/search/search-activitypub-video-channels.ts16
-rw-r--r--server/tests/api/search/search-activitypub-video-playlists.ts212
-rw-r--r--server/tests/api/search/search-activitypub-videos.ts35
-rw-r--r--server/tests/api/search/search-index.ts62
-rw-r--r--server/tests/api/search/search-playlists.ts128
-rw-r--r--server/tests/api/server/bulk.ts9
-rw-r--r--server/tests/api/server/config.ts5
-rw-r--r--server/tests/api/server/contact-form.ts4
-rw-r--r--server/tests/api/server/follow-constraints.ts16
-rw-r--r--server/tests/api/server/follows.ts50
-rw-r--r--server/tests/api/server/handle-down.ts14
-rw-r--r--server/tests/api/server/homepage.ts85
-rw-r--r--server/tests/api/server/index.ts1
-rw-r--r--server/tests/api/server/plugins.ts42
-rw-r--r--server/tests/api/server/services.ts104
-rw-r--r--server/tests/api/users/users-verification.ts5
-rw-r--r--server/tests/api/users/users.ts18
-rw-r--r--server/tests/api/videos/multiple-servers.ts23
-rw-r--r--server/tests/api/videos/resumable-upload.ts4
-rw-r--r--server/tests/api/videos/video-change-ownership.ts108
-rw-r--r--server/tests/api/videos/video-channels.ts18
-rw-r--r--server/tests/api/videos/video-comments.ts5
-rw-r--r--server/tests/api/videos/video-playlists.ts71
-rw-r--r--server/tests/api/videos/video-privacy.ts351
-rw-r--r--server/tests/api/videos/videos-filter.ts4
-rw-r--r--server/tests/api/videos/videos-overview.ts4
-rw-r--r--server/tests/cli/prune-storage.ts34
-rw-r--r--server/tests/client.ts449
-rw-r--r--server/tests/external-plugins/auto-block-videos.ts5
-rw-r--r--server/tests/external-plugins/auto-mute.ts5
-rw-r--r--server/tests/fixtures/ap-json/mastodon/bad-body-http-signature.json (renamed from server/tests/api/activitypub/json/mastodon/bad-body-http-signature.json)0
-rw-r--r--server/tests/fixtures/ap-json/mastodon/bad-http-signature.json (renamed from server/tests/api/activitypub/json/mastodon/bad-http-signature.json)0
-rw-r--r--server/tests/fixtures/ap-json/mastodon/bad-public-key.json (renamed from server/tests/api/activitypub/json/mastodon/bad-public-key.json)0
-rw-r--r--server/tests/fixtures/ap-json/mastodon/create-bad-signature.json (renamed from server/tests/api/activitypub/json/mastodon/create-bad-signature.json)0
-rw-r--r--server/tests/fixtures/ap-json/mastodon/create.json (renamed from server/tests/api/activitypub/json/mastodon/create.json)0
-rw-r--r--server/tests/fixtures/ap-json/mastodon/http-signature.json (renamed from server/tests/api/activitypub/json/mastodon/http-signature.json)0
-rw-r--r--server/tests/fixtures/ap-json/mastodon/public-key.json (renamed from server/tests/api/activitypub/json/mastodon/public-key.json)0
-rw-r--r--server/tests/fixtures/ap-json/peertube/announce-without-context.json (renamed from server/tests/api/activitypub/json/peertube/announce-without-context.json)0
-rw-r--r--server/tests/fixtures/ap-json/peertube/invalid-keys.json (renamed from server/tests/api/activitypub/json/peertube/invalid-keys.json)0
-rw-r--r--server/tests/fixtures/ap-json/peertube/keys.json (renamed from server/tests/api/activitypub/json/peertube/keys.json)0
-rw-r--r--server/tests/fixtures/peertube-plugin-test-broken/main.js12
-rw-r--r--server/tests/fixtures/peertube-plugin-test-broken/package.json (renamed from server/tests/fixtures/peertube-plugin-test-three/package.json)4
-rw-r--r--server/tests/fixtures/peertube-plugin-test-filter-translations/languages/fr.json (renamed from server/tests/fixtures/peertube-plugin-test-two/languages/fr.json)0
-rw-r--r--server/tests/fixtures/peertube-plugin-test-filter-translations/languages/it.json (renamed from server/tests/fixtures/peertube-plugin-test-two/languages/it.json)0
-rw-r--r--server/tests/fixtures/peertube-plugin-test-filter-translations/main.js (renamed from server/tests/fixtures/peertube-plugin-test-two/main.js)0
-rw-r--r--server/tests/fixtures/peertube-plugin-test-filter-translations/package.json (renamed from server/tests/fixtures/peertube-plugin-test-two/package.json)4
-rw-r--r--server/tests/fixtures/peertube-plugin-test-video-constants/main.js (renamed from server/tests/fixtures/peertube-plugin-test-three/main.js)2
-rw-r--r--server/tests/fixtures/peertube-plugin-test-video-constants/package.json20
-rw-r--r--server/tests/fixtures/peertube-plugin-test/main.js8
-rw-r--r--server/tests/plugins/action-hooks.ts40
-rw-r--r--server/tests/plugins/filter-hooks.ts32
-rw-r--r--server/tests/plugins/html-injection.ts2
-rw-r--r--server/tests/plugins/plugin-helpers.ts2
-rw-r--r--server/tests/plugins/translations.ts8
-rw-r--r--server/tests/plugins/video-constants.ts6
-rw-r--r--server/tests/register.ts3
-rw-r--r--server/tools/cli.ts10
-rw-r--r--server/tools/peertube-auth.ts4
-rw-r--r--server/tools/peertube-get-access-token.ts2
-rw-r--r--server/tools/peertube-import-videos.ts27
-rw-r--r--server/tools/peertube-plugins.ts14
-rw-r--r--server/tools/peertube-redundancy.ts7
-rw-r--r--server/tools/peertube-repl.ts7
-rw-r--r--server/tools/peertube-upload.ts2
-rw-r--r--server/tools/peertube-watch.ts8
-rw-r--r--server/tools/peertube.ts6
-rw-r--r--server/tools/test.ts2
-rw-r--r--server/tools/yarn.lock413
-rw-r--r--server/types/models/abuse/abuse-message.ts (renamed from server/types/models/moderation/abuse-message.ts)0
-rw-r--r--server/types/models/abuse/abuse.ts (renamed from server/types/models/moderation/abuse.ts)0
-rw-r--r--server/types/models/abuse/index.ts (renamed from server/types/models/moderation/index.ts)0
-rw-r--r--server/types/models/account/account.ts6
-rw-r--r--server/types/models/account/actor-custom-page.ts4
-rw-r--r--server/types/models/account/index.ts4
-rw-r--r--server/types/models/actor/actor-follow.ts (renamed from server/types/models/account/actor-follow.ts)2
-rw-r--r--server/types/models/actor/actor-image.ts (renamed from server/types/models/account/actor-image.ts)2
-rw-r--r--server/types/models/actor/actor.ts (renamed from server/types/models/account/actor.ts)5
-rw-r--r--server/types/models/actor/index.ts3
-rw-r--r--server/types/models/index.ts3
-rw-r--r--server/types/models/user/user-notification-setting.ts2
-rw-r--r--server/types/models/user/user-notification.ts8
-rw-r--r--server/types/models/user/user-video-history.ts2
-rw-r--r--server/types/models/user/user.ts2
-rw-r--r--server/types/models/video/schedule-video-update.ts8
-rw-r--r--server/types/models/video/video-channels.ts6
-rw-r--r--server/types/models/video/video-playlist.ts4
-rw-r--r--server/types/models/video/video-share.ts4
-rw-r--r--server/types/plugins/register-server-option.model.ts4
-rw-r--r--server/types/sequelize.ts5
-rw-r--r--server/typings/express/index.d.ts29
417 files changed, 12345 insertions, 8138 deletions
diff --git a/server/controllers/activitypub/client.ts b/server/controllers/activitypub/client.ts
index 1b4acc234..d7de1b9bd 100644
--- a/server/controllers/activitypub/client.ts
+++ b/server/controllers/activitypub/client.ts
@@ -30,8 +30,7 @@ import { videoFileRedundancyGetValidator, videoPlaylistRedundancyGetValidator }
30import { videoPlaylistElementAPGetValidator, videoPlaylistsGetValidator } from '../../middlewares/validators/videos/video-playlists' 30import { videoPlaylistElementAPGetValidator, videoPlaylistsGetValidator } from '../../middlewares/validators/videos/video-playlists'
31import { AccountModel } from '../../models/account/account' 31import { AccountModel } from '../../models/account/account'
32import { AccountVideoRateModel } from '../../models/account/account-video-rate' 32import { AccountVideoRateModel } from '../../models/account/account-video-rate'
33import { ActorFollowModel } from '../../models/activitypub/actor-follow' 33import { ActorFollowModel } from '../../models/actor/actor-follow'
34import { VideoModel } from '../../models/video/video'
35import { VideoCaptionModel } from '../../models/video/video-caption' 34import { VideoCaptionModel } from '../../models/video/video-caption'
36import { VideoCommentModel } from '../../models/video/video-comment' 35import { VideoCommentModel } from '../../models/video/video-comment'
37import { VideoPlaylistModel } from '../../models/video/video-playlist' 36import { VideoPlaylistModel } from '../../models/video/video-playlist'
@@ -44,7 +43,7 @@ activityPubClientRouter.use(cors())
44// Intercept ActivityPub client requests 43// Intercept ActivityPub client requests
45 44
46activityPubClientRouter.get( 45activityPubClientRouter.get(
47 [ '/accounts?/:name', '/accounts?/:name/video-channels' ], 46 [ '/accounts?/:name', '/accounts?/:name/video-channels', '/a/:name', '/a/:name/video-channels' ],
48 executeIfActivityPub, 47 executeIfActivityPub,
49 asyncMiddleware(localAccountValidator), 48 asyncMiddleware(localAccountValidator),
50 accountController 49 accountController
@@ -75,15 +74,16 @@ activityPubClientRouter.get('/accounts?/:name/dislikes/:videoId',
75 getAccountVideoRateFactory('dislike') 74 getAccountVideoRateFactory('dislike')
76) 75)
77 76
78activityPubClientRouter.get('/videos/watch/:id', 77activityPubClientRouter.get(
78 [ '/videos/watch/:id', '/w/:id' ],
79 executeIfActivityPub, 79 executeIfActivityPub,
80 asyncMiddleware(cacheRoute()(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS)), 80 asyncMiddleware(cacheRoute()(ROUTE_CACHE_LIFETIME.ACTIVITY_PUB.VIDEOS)),
81 asyncMiddleware(videosCustomGetValidator('only-video-with-rights')), 81 asyncMiddleware(videosCustomGetValidator('all')),
82 asyncMiddleware(videoController) 82 asyncMiddleware(videoController)
83) 83)
84activityPubClientRouter.get('/videos/watch/:id/activity', 84activityPubClientRouter.get('/videos/watch/:id/activity',
85 executeIfActivityPub, 85 executeIfActivityPub,
86 asyncMiddleware(videosCustomGetValidator('only-video-with-rights')), 86 asyncMiddleware(videosCustomGetValidator('all')),
87 asyncMiddleware(videoController) 87 asyncMiddleware(videoController)
88) 88)
89activityPubClientRouter.get('/videos/watch/:id/announces', 89activityPubClientRouter.get('/videos/watch/:id/announces',
@@ -123,7 +123,7 @@ activityPubClientRouter.get('/videos/watch/:videoId/comments/:commentId/activity
123) 123)
124 124
125activityPubClientRouter.get( 125activityPubClientRouter.get(
126 [ '/video-channels/:name', '/video-channels/:name/videos' ], 126 [ '/video-channels/:name', '/video-channels/:name/videos', '/c/:name', '/c/:name/videos' ],
127 executeIfActivityPub, 127 executeIfActivityPub,
128 asyncMiddleware(localVideoChannelValidator), 128 asyncMiddleware(localVideoChannelValidator),
129 videoChannelController 129 videoChannelController
@@ -155,7 +155,8 @@ activityPubClientRouter.get('/redundancy/streaming-playlists/:streamingPlaylistT
155 asyncMiddleware(videoRedundancyController) 155 asyncMiddleware(videoRedundancyController)
156) 156)
157 157
158activityPubClientRouter.get('/video-playlists/:playlistId', 158activityPubClientRouter.get(
159 [ '/video-playlists/:playlistId', '/videos/watch/playlist/:playlistId', '/w/p/:playlistId' ],
159 executeIfActivityPub, 160 executeIfActivityPub,
160 asyncMiddleware(videoPlaylistsGetValidator('all')), 161 asyncMiddleware(videoPlaylistsGetValidator('all')),
161 asyncMiddleware(videoPlaylistController) 162 asyncMiddleware(videoPlaylistController)
@@ -222,8 +223,7 @@ function getAccountVideoRateFactory (rateType: VideoRateType) {
222} 223}
223 224
224async function videoController (req: express.Request, res: express.Response) { 225async function videoController (req: express.Request, res: express.Response) {
225 // We need more attributes 226 const video = res.locals.videoAll
226 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(res.locals.onlyVideoWithRights.id)
227 227
228 if (redirectIfNotOwned(video.url, res)) return 228 if (redirectIfNotOwned(video.url, res)) return
229 229
diff --git a/server/controllers/activitypub/utils.ts b/server/controllers/activitypub/utils.ts
index 599cf48ab..19bdd58eb 100644
--- a/server/controllers/activitypub/utils.ts
+++ b/server/controllers/activitypub/utils.ts
@@ -3,7 +3,6 @@ import * as express from 'express'
3function activityPubResponse (data: any, res: express.Response) { 3function activityPubResponse (data: any, res: express.Response) {
4 return res.type('application/activity+json; charset=utf-8') 4 return res.type('application/activity+json; charset=utf-8')
5 .json(data) 5 .json(data)
6 .end()
7} 6}
8 7
9export { 8export {
diff --git a/server/controllers/api/abuse.ts b/server/controllers/api/abuse.ts
index 0ab74bdff..ba5b94840 100644
--- a/server/controllers/api/abuse.ts
+++ b/server/controllers/api/abuse.ts
@@ -24,6 +24,7 @@ import {
24 deleteAbuseMessageValidator, 24 deleteAbuseMessageValidator,
25 ensureUserHasRight, 25 ensureUserHasRight,
26 getAbuseValidator, 26 getAbuseValidator,
27 openapiOperationDoc,
27 paginationValidator, 28 paginationValidator,
28 setDefaultPagination, 29 setDefaultPagination,
29 setDefaultSort 30 setDefaultSort
@@ -33,6 +34,7 @@ import { AccountModel } from '../../models/account/account'
33const abuseRouter = express.Router() 34const abuseRouter = express.Router()
34 35
35abuseRouter.get('/', 36abuseRouter.get('/',
37 openapiOperationDoc({ operationId: 'getAbuses' }),
36 authenticate, 38 authenticate,
37 ensureUserHasRight(UserRight.MANAGE_ABUSES), 39 ensureUserHasRight(UserRight.MANAGE_ABUSES),
38 paginationValidator, 40 paginationValidator,
@@ -142,7 +144,7 @@ async function updateAbuse (req: express.Request, res: express.Response) {
142 144
143 // Do not send the delete to other instances, we updated OUR copy of this abuse 145 // Do not send the delete to other instances, we updated OUR copy of this abuse
144 146
145 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 147 return res.status(HttpStatusCode.NO_CONTENT_204).end()
146} 148}
147 149
148async function deleteAbuse (req: express.Request, res: express.Response) { 150async function deleteAbuse (req: express.Request, res: express.Response) {
@@ -154,7 +156,7 @@ async function deleteAbuse (req: express.Request, res: express.Response) {
154 156
155 // Do not send the delete to other instances, we delete OUR copy of this abuse 157 // Do not send the delete to other instances, we delete OUR copy of this abuse
156 158
157 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 159 return res.status(HttpStatusCode.NO_CONTENT_204).end()
158} 160}
159 161
160async function reportAbuse (req: express.Request, res: express.Response) { 162async function reportAbuse (req: express.Request, res: express.Response) {
@@ -244,5 +246,5 @@ async function deleteAbuseMessage (req: express.Request, res: express.Response)
244 return abuseMessage.destroy({ transaction: t }) 246 return abuseMessage.destroy({ transaction: t })
245 }) 247 })
246 248
247 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 249 return res.status(HttpStatusCode.NO_CONTENT_204).end()
248} 250}
diff --git a/server/controllers/api/bulk.ts b/server/controllers/api/bulk.ts
index 649351029..192daccde 100644
--- a/server/controllers/api/bulk.ts
+++ b/server/controllers/api/bulk.ts
@@ -34,7 +34,7 @@ async function bulkRemoveCommentsOf (req: express.Request, res: express.Response
34 const comments = await VideoCommentModel.listForBulkDelete(account, filter) 34 const comments = await VideoCommentModel.listForBulkDelete(account, filter)
35 35
36 // Don't wait result 36 // Don't wait result
37 res.sendStatus(HttpStatusCode.NO_CONTENT_204) 37 res.status(HttpStatusCode.NO_CONTENT_204).end()
38 38
39 for (const comment of comments) { 39 for (const comment of comments) {
40 await removeComment(comment) 40 await removeComment(comment)
diff --git a/server/controllers/api/config.ts b/server/controllers/api/config.ts
index 2ddb73519..9bd8c21c5 100644
--- a/server/controllers/api/config.ts
+++ b/server/controllers/api/config.ts
@@ -1,8 +1,8 @@
1import { ServerConfigManager } from '@server/lib/server-config-manager'
1import * as express from 'express' 2import * as express from 'express'
2import { remove, writeJSON } from 'fs-extra' 3import { remove, writeJSON } from 'fs-extra'
3import { snakeCase } from 'lodash' 4import { snakeCase } from 'lodash'
4import validator from 'validator' 5import validator from 'validator'
5import { getServerConfig } from '@server/lib/config'
6import { UserRight } from '../../../shared' 6import { UserRight } from '../../../shared'
7import { About } from '../../../shared/models/server/about.model' 7import { About } from '../../../shared/models/server/about.model'
8import { CustomConfig } from '../../../shared/models/server/custom-config.model' 8import { CustomConfig } from '../../../shared/models/server/custom-config.model'
@@ -10,37 +10,47 @@ import { auditLoggerFactory, CustomConfigAuditView, getAuditIdFromRes } from '..
10import { objectConverter } from '../../helpers/core-utils' 10import { objectConverter } from '../../helpers/core-utils'
11import { CONFIG, reloadConfig } from '../../initializers/config' 11import { CONFIG, reloadConfig } from '../../initializers/config'
12import { ClientHtml } from '../../lib/client-html' 12import { ClientHtml } from '../../lib/client-html'
13import { asyncMiddleware, authenticate, ensureUserHasRight } from '../../middlewares' 13import { asyncMiddleware, authenticate, ensureUserHasRight, openapiOperationDoc } from '../../middlewares'
14import { customConfigUpdateValidator } from '../../middlewares/validators/config' 14import { customConfigUpdateValidator } from '../../middlewares/validators/config'
15 15
16const configRouter = express.Router() 16const configRouter = express.Router()
17 17
18const auditLogger = auditLoggerFactory('config') 18const auditLogger = auditLoggerFactory('config')
19 19
20configRouter.get('/about', getAbout)
21configRouter.get('/', 20configRouter.get('/',
21 openapiOperationDoc({ operationId: 'getConfig' }),
22 asyncMiddleware(getConfig) 22 asyncMiddleware(getConfig)
23) 23)
24 24
25configRouter.get('/about',
26 openapiOperationDoc({ operationId: 'getAbout' }),
27 getAbout
28)
29
25configRouter.get('/custom', 30configRouter.get('/custom',
31 openapiOperationDoc({ operationId: 'getCustomConfig' }),
26 authenticate, 32 authenticate,
27 ensureUserHasRight(UserRight.MANAGE_CONFIGURATION), 33 ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
28 getCustomConfig 34 getCustomConfig
29) 35)
36
30configRouter.put('/custom', 37configRouter.put('/custom',
38 openapiOperationDoc({ operationId: 'putCustomConfig' }),
31 authenticate, 39 authenticate,
32 ensureUserHasRight(UserRight.MANAGE_CONFIGURATION), 40 ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
33 customConfigUpdateValidator, 41 customConfigUpdateValidator,
34 asyncMiddleware(updateCustomConfig) 42 asyncMiddleware(updateCustomConfig)
35) 43)
44
36configRouter.delete('/custom', 45configRouter.delete('/custom',
46 openapiOperationDoc({ operationId: 'delCustomConfig' }),
37 authenticate, 47 authenticate,
38 ensureUserHasRight(UserRight.MANAGE_CONFIGURATION), 48 ensureUserHasRight(UserRight.MANAGE_CONFIGURATION),
39 asyncMiddleware(deleteCustomConfig) 49 asyncMiddleware(deleteCustomConfig)
40) 50)
41 51
42async function getConfig (req: express.Request, res: express.Response) { 52async function getConfig (req: express.Request, res: express.Response) {
43 const json = await getServerConfig(req.ip) 53 const json = await ServerConfigManager.Instance.getServerConfig(req.ip)
44 54
45 return res.json(json) 55 return res.json(json)
46} 56}
@@ -67,13 +77,13 @@ function getAbout (req: express.Request, res: express.Response) {
67 } 77 }
68 } 78 }
69 79
70 return res.json(about).end() 80 return res.json(about)
71} 81}
72 82
73function getCustomConfig (req: express.Request, res: express.Response) { 83function getCustomConfig (req: express.Request, res: express.Response) {
74 const data = customConfig() 84 const data = customConfig()
75 85
76 return res.json(data).end() 86 return res.json(data)
77} 87}
78 88
79async function deleteCustomConfig (req: express.Request, res: express.Response) { 89async function deleteCustomConfig (req: express.Request, res: express.Response) {
@@ -171,7 +181,8 @@ function customConfig (): CustomConfig {
171 signup: { 181 signup: {
172 enabled: CONFIG.SIGNUP.ENABLED, 182 enabled: CONFIG.SIGNUP.ENABLED,
173 limit: CONFIG.SIGNUP.LIMIT, 183 limit: CONFIG.SIGNUP.LIMIT,
174 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION 184 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION,
185 minimumAge: CONFIG.SIGNUP.MINIMUM_AGE
175 }, 186 },
176 admin: { 187 admin: {
177 email: CONFIG.ADMIN.EMAIL 188 email: CONFIG.ADMIN.EMAIL
diff --git a/server/controllers/api/custom-page.ts b/server/controllers/api/custom-page.ts
new file mode 100644
index 000000000..c19f03c56
--- /dev/null
+++ b/server/controllers/api/custom-page.ts
@@ -0,0 +1,47 @@
1import * as express from 'express'
2import { ServerConfigManager } from '@server/lib/server-config-manager'
3import { ActorCustomPageModel } from '@server/models/account/actor-custom-page'
4import { HttpStatusCode } from '@shared/core-utils'
5import { UserRight } from '@shared/models'
6import { asyncMiddleware, authenticate, ensureUserHasRight } from '../../middlewares'
7
8const customPageRouter = express.Router()
9
10customPageRouter.get('/homepage/instance',
11 asyncMiddleware(getInstanceHomepage)
12)
13
14customPageRouter.put('/homepage/instance',
15 authenticate,
16 ensureUserHasRight(UserRight.MANAGE_INSTANCE_CUSTOM_PAGE),
17 asyncMiddleware(updateInstanceHomepage)
18)
19
20// ---------------------------------------------------------------------------
21
22export {
23 customPageRouter
24}
25
26// ---------------------------------------------------------------------------
27
28async function getInstanceHomepage (req: express.Request, res: express.Response) {
29 const page = await ActorCustomPageModel.loadInstanceHomepage()
30 if (!page) {
31 return res.fail({
32 status: HttpStatusCode.NOT_FOUND_404,
33 message: 'Instance homepage could not be found'
34 })
35 }
36
37 return res.json(page.toFormattedJSON())
38}
39
40async function updateInstanceHomepage (req: express.Request, res: express.Response) {
41 const content = req.body.content
42
43 await ActorCustomPageModel.updateInstanceHomepage(content)
44 ServerConfigManager.Instance.updateHomepageState(content)
45
46 return res.status(HttpStatusCode.NO_CONTENT_204).end()
47}
diff --git a/server/controllers/api/index.ts b/server/controllers/api/index.ts
index 7ade1df3a..28378654a 100644
--- a/server/controllers/api/index.ts
+++ b/server/controllers/api/index.ts
@@ -8,6 +8,7 @@ import { abuseRouter } from './abuse'
8import { accountsRouter } from './accounts' 8import { accountsRouter } from './accounts'
9import { bulkRouter } from './bulk' 9import { bulkRouter } from './bulk'
10import { configRouter } from './config' 10import { configRouter } from './config'
11import { customPageRouter } from './custom-page'
11import { jobsRouter } from './jobs' 12import { jobsRouter } from './jobs'
12import { oauthClientsRouter } from './oauth-clients' 13import { oauthClientsRouter } from './oauth-clients'
13import { overviewsRouter } from './overviews' 14import { overviewsRouter } from './overviews'
@@ -47,6 +48,7 @@ apiRouter.use('/jobs', jobsRouter)
47apiRouter.use('/search', searchRouter) 48apiRouter.use('/search', searchRouter)
48apiRouter.use('/overviews', overviewsRouter) 49apiRouter.use('/overviews', overviewsRouter)
49apiRouter.use('/plugins', pluginRouter) 50apiRouter.use('/plugins', pluginRouter)
51apiRouter.use('/custom-pages', customPageRouter)
50apiRouter.use('/ping', pong) 52apiRouter.use('/ping', pong)
51apiRouter.use('/*', badRequest) 53apiRouter.use('/*', badRequest)
52 54
diff --git a/server/controllers/api/jobs.ts b/server/controllers/api/jobs.ts
index d7cee1605..9e333322b 100644
--- a/server/controllers/api/jobs.ts
+++ b/server/controllers/api/jobs.ts
@@ -9,6 +9,7 @@ import {
9 authenticate, 9 authenticate,
10 ensureUserHasRight, 10 ensureUserHasRight,
11 jobsSortValidator, 11 jobsSortValidator,
12 openapiOperationDoc,
12 paginationValidatorBuilder, 13 paginationValidatorBuilder,
13 setDefaultPagination, 14 setDefaultPagination,
14 setDefaultSort 15 setDefaultSort
@@ -18,6 +19,7 @@ import { listJobsValidator } from '../../middlewares/validators/jobs'
18const jobsRouter = express.Router() 19const jobsRouter = express.Router()
19 20
20jobsRouter.get('/:state?', 21jobsRouter.get('/:state?',
22 openapiOperationDoc({ operationId: 'getJobs' }),
21 authenticate, 23 authenticate,
22 ensureUserHasRight(UserRight.MANAGE_JOBS), 24 ensureUserHasRight(UserRight.MANAGE_JOBS),
23 paginationValidatorBuilder([ 'jobs' ]), 25 paginationValidatorBuilder([ 'jobs' ]),
diff --git a/server/controllers/api/oauth-clients.ts b/server/controllers/api/oauth-clients.ts
index c21e2298d..15bbf5c4d 100644
--- a/server/controllers/api/oauth-clients.ts
+++ b/server/controllers/api/oauth-clients.ts
@@ -3,12 +3,13 @@ import { OAuthClientLocal } from '../../../shared'
3import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' 3import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
4import { logger } from '../../helpers/logger' 4import { logger } from '../../helpers/logger'
5import { CONFIG } from '../../initializers/config' 5import { CONFIG } from '../../initializers/config'
6import { asyncMiddleware } from '../../middlewares' 6import { asyncMiddleware, openapiOperationDoc } from '../../middlewares'
7import { OAuthClientModel } from '../../models/oauth/oauth-client' 7import { OAuthClientModel } from '../../models/oauth/oauth-client'
8 8
9const oauthClientsRouter = express.Router() 9const oauthClientsRouter = express.Router()
10 10
11oauthClientsRouter.get('/local', 11oauthClientsRouter.get('/local',
12 openapiOperationDoc({ operationId: 'getOAuthClient' }),
12 asyncMiddleware(getLocalClient) 13 asyncMiddleware(getLocalClient)
13) 14)
14 15
@@ -24,7 +25,10 @@ async function getLocalClient (req: express.Request, res: express.Response, next
24 // Don't make this check if this is a test instance 25 // Don't make this check if this is a test instance
25 if (process.env.NODE_ENV !== 'test' && req.get('host') !== headerHostShouldBe) { 26 if (process.env.NODE_ENV !== 'test' && req.get('host') !== headerHostShouldBe) {
26 logger.info('Getting client tokens for host %s is forbidden (expected %s).', req.get('host'), headerHostShouldBe) 27 logger.info('Getting client tokens for host %s is forbidden (expected %s).', req.get('host'), headerHostShouldBe)
27 return res.type('json').status(HttpStatusCode.FORBIDDEN_403).end() 28 return res.fail({
29 status: HttpStatusCode.FORBIDDEN_403,
30 message: `Getting client tokens for host ${req.get('host')} is forbidden`
31 })
28 } 32 }
29 33
30 const client = await OAuthClientModel.loadFirstClient() 34 const client = await OAuthClientModel.loadFirstClient()
diff --git a/server/controllers/api/plugins.ts b/server/controllers/api/plugins.ts
index a186de010..1e6a02c49 100644
--- a/server/controllers/api/plugins.ts
+++ b/server/controllers/api/plugins.ts
@@ -1,16 +1,19 @@
1import * as express from 'express' 1import * as express from 'express'
2import { getFormattedObjects } from '../../helpers/utils' 2import { logger } from '@server/helpers/logger'
3import { getFormattedObjects } from '@server/helpers/utils'
4import { listAvailablePluginsFromIndex } from '@server/lib/plugins/plugin-index'
5import { PluginManager } from '@server/lib/plugins/plugin-manager'
3import { 6import {
4 asyncMiddleware, 7 asyncMiddleware,
5 authenticate, 8 authenticate,
9 availablePluginsSortValidator,
6 ensureUserHasRight, 10 ensureUserHasRight,
11 openapiOperationDoc,
7 paginationValidator, 12 paginationValidator,
13 pluginsSortValidator,
8 setDefaultPagination, 14 setDefaultPagination,
9 setDefaultSort 15 setDefaultSort
10} from '../../middlewares' 16} from '@server/middlewares'
11import { availablePluginsSortValidator, pluginsSortValidator } from '../../middlewares/validators'
12import { PluginModel } from '../../models/server/plugin'
13import { UserRight } from '../../../shared/models/users'
14import { 17import {
15 existingPluginValidator, 18 existingPluginValidator,
16 installOrUpdatePluginValidator, 19 installOrUpdatePluginValidator,
@@ -18,20 +21,22 @@ import {
18 listPluginsValidator, 21 listPluginsValidator,
19 uninstallPluginValidator, 22 uninstallPluginValidator,
20 updatePluginSettingsValidator 23 updatePluginSettingsValidator
21} from '../../middlewares/validators/plugins' 24} from '@server/middlewares/validators/plugins'
22import { PluginManager } from '../../lib/plugins/plugin-manager' 25import { PluginModel } from '@server/models/server/plugin'
23import { InstallOrUpdatePlugin } from '../../../shared/models/plugins/install-plugin.model' 26import { HttpStatusCode } from '@shared/core-utils'
24import { ManagePlugin } from '../../../shared/models/plugins/manage-plugin.model' 27import {
25import { logger } from '../../helpers/logger' 28 InstallOrUpdatePlugin,
26import { listAvailablePluginsFromIndex } from '../../lib/plugins/plugin-index' 29 ManagePlugin,
27import { PeertubePluginIndexList } from '../../../shared/models/plugins/peertube-plugin-index-list.model' 30 PeertubePluginIndexList,
28import { RegisteredServerSettings } from '../../../shared/models/plugins/register-server-setting.model' 31 PublicServerSetting,
29import { PublicServerSetting } from '../../../shared/models/plugins/public-server.setting' 32 RegisteredServerSettings,
30import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' 33 UserRight
34} from '@shared/models'
31 35
32const pluginRouter = express.Router() 36const pluginRouter = express.Router()
33 37
34pluginRouter.get('/available', 38pluginRouter.get('/available',
39 openapiOperationDoc({ operationId: 'getAvailablePlugins' }),
35 authenticate, 40 authenticate,
36 ensureUserHasRight(UserRight.MANAGE_PLUGINS), 41 ensureUserHasRight(UserRight.MANAGE_PLUGINS),
37 listAvailablePluginsValidator, 42 listAvailablePluginsValidator,
@@ -43,6 +48,7 @@ pluginRouter.get('/available',
43) 48)
44 49
45pluginRouter.get('/', 50pluginRouter.get('/',
51 openapiOperationDoc({ operationId: 'getPlugins' }),
46 authenticate, 52 authenticate,
47 ensureUserHasRight(UserRight.MANAGE_PLUGINS), 53 ensureUserHasRight(UserRight.MANAGE_PLUGINS),
48 listPluginsValidator, 54 listPluginsValidator,
@@ -81,6 +87,7 @@ pluginRouter.get('/:npmName',
81) 87)
82 88
83pluginRouter.post('/install', 89pluginRouter.post('/install',
90 openapiOperationDoc({ operationId: 'addPlugin' }),
84 authenticate, 91 authenticate,
85 ensureUserHasRight(UserRight.MANAGE_PLUGINS), 92 ensureUserHasRight(UserRight.MANAGE_PLUGINS),
86 installOrUpdatePluginValidator, 93 installOrUpdatePluginValidator,
@@ -88,6 +95,7 @@ pluginRouter.post('/install',
88) 95)
89 96
90pluginRouter.post('/update', 97pluginRouter.post('/update',
98 openapiOperationDoc({ operationId: 'updatePlugin' }),
91 authenticate, 99 authenticate,
92 ensureUserHasRight(UserRight.MANAGE_PLUGINS), 100 ensureUserHasRight(UserRight.MANAGE_PLUGINS),
93 installOrUpdatePluginValidator, 101 installOrUpdatePluginValidator,
@@ -95,6 +103,7 @@ pluginRouter.post('/update',
95) 103)
96 104
97pluginRouter.post('/uninstall', 105pluginRouter.post('/uninstall',
106 openapiOperationDoc({ operationId: 'uninstallPlugin' }),
98 authenticate, 107 authenticate,
99 ensureUserHasRight(UserRight.MANAGE_PLUGINS), 108 ensureUserHasRight(UserRight.MANAGE_PLUGINS),
100 uninstallPluginValidator, 109 uninstallPluginValidator,
@@ -141,7 +150,7 @@ async function installPlugin (req: express.Request, res: express.Response) {
141 return res.json(plugin.toFormattedJSON()) 150 return res.json(plugin.toFormattedJSON())
142 } catch (err) { 151 } catch (err) {
143 logger.warn('Cannot install plugin %s.', toInstall, { err }) 152 logger.warn('Cannot install plugin %s.', toInstall, { err })
144 return res.sendStatus(HttpStatusCode.BAD_REQUEST_400) 153 return res.fail({ message: 'Cannot install plugin ' + toInstall })
145 } 154 }
146} 155}
147 156
@@ -156,7 +165,7 @@ async function updatePlugin (req: express.Request, res: express.Response) {
156 return res.json(plugin.toFormattedJSON()) 165 return res.json(plugin.toFormattedJSON())
157 } catch (err) { 166 } catch (err) {
158 logger.warn('Cannot update plugin %s.', toUpdate, { err }) 167 logger.warn('Cannot update plugin %s.', toUpdate, { err })
159 return res.sendStatus(HttpStatusCode.BAD_REQUEST_400) 168 return res.fail({ message: 'Cannot update plugin ' + toUpdate })
160 } 169 }
161} 170}
162 171
@@ -165,7 +174,7 @@ async function uninstallPlugin (req: express.Request, res: express.Response) {
165 174
166 await PluginManager.Instance.uninstall(body.npmName) 175 await PluginManager.Instance.uninstall(body.npmName)
167 176
168 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 177 return res.status(HttpStatusCode.NO_CONTENT_204).end()
169} 178}
170 179
171function getPublicPluginSettings (req: express.Request, res: express.Response) { 180function getPublicPluginSettings (req: express.Request, res: express.Response) {
@@ -194,7 +203,7 @@ async function updatePluginSettings (req: express.Request, res: express.Response
194 203
195 await PluginManager.Instance.onSettingsChanged(plugin.name, plugin.settings) 204 await PluginManager.Instance.onSettingsChanged(plugin.name, plugin.settings)
196 205
197 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 206 return res.status(HttpStatusCode.NO_CONTENT_204).end()
198} 207}
199 208
200async function listAvailablePlugins (req: express.Request, res: express.Response) { 209async function listAvailablePlugins (req: express.Request, res: express.Response) {
@@ -203,8 +212,10 @@ async function listAvailablePlugins (req: express.Request, res: express.Response
203 const resultList = await listAvailablePluginsFromIndex(query) 212 const resultList = await listAvailablePluginsFromIndex(query)
204 213
205 if (!resultList) { 214 if (!resultList) {
206 return res.status(HttpStatusCode.SERVICE_UNAVAILABLE_503) 215 return res.fail({
207 .json({ error: 'Plugin index unavailable. Please retry later' }) 216 status: HttpStatusCode.SERVICE_UNAVAILABLE_503,
217 message: 'Plugin index unavailable. Please retry later'
218 })
208 } 219 }
209 220
210 return res.json(resultList) 221 return res.json(resultList)
diff --git a/server/controllers/api/search.ts b/server/controllers/api/search.ts
deleted file mode 100644
index f0cdf3a89..000000000
--- a/server/controllers/api/search.ts
+++ /dev/null
@@ -1,286 +0,0 @@
1import * as express from 'express'
2import { sanitizeUrl } from '@server/helpers/core-utils'
3import { doJSONRequest } from '@server/helpers/requests'
4import { CONFIG } from '@server/initializers/config'
5import { getOrCreateVideoAndAccountAndChannel } from '@server/lib/activitypub/videos'
6import { Hooks } from '@server/lib/plugins/hooks'
7import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
8import { getServerActor } from '@server/models/application/application'
9import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
10import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
11import { ResultList, Video, VideoChannel } from '@shared/models'
12import { SearchTargetQuery } from '@shared/models/search/search-target-query.model'
13import { VideoChannelsSearchQuery, VideosSearchQuery } from '../../../shared/models/search'
14import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../helpers/express-utils'
15import { logger } from '../../helpers/logger'
16import { getFormattedObjects } from '../../helpers/utils'
17import { loadActorUrlOrGetFromWebfinger } from '../../helpers/webfinger'
18import { getOrCreateActorAndServerAndModel } from '../../lib/activitypub/actor'
19import {
20 asyncMiddleware,
21 commonVideosFiltersValidator,
22 optionalAuthenticate,
23 paginationValidator,
24 setDefaultPagination,
25 setDefaultSearchSort,
26 videoChannelsListSearchValidator,
27 videoChannelsSearchSortValidator,
28 videosSearchSortValidator,
29 videosSearchValidator
30} from '../../middlewares'
31import { VideoModel } from '../../models/video/video'
32import { VideoChannelModel } from '../../models/video/video-channel'
33import { MChannelAccountDefault, MVideoAccountLightBlacklistAllFiles } from '../../types/models'
34
35const searchRouter = express.Router()
36
37searchRouter.get('/videos',
38 paginationValidator,
39 setDefaultPagination,
40 videosSearchSortValidator,
41 setDefaultSearchSort,
42 optionalAuthenticate,
43 commonVideosFiltersValidator,
44 videosSearchValidator,
45 asyncMiddleware(searchVideos)
46)
47
48searchRouter.get('/video-channels',
49 paginationValidator,
50 setDefaultPagination,
51 videoChannelsSearchSortValidator,
52 setDefaultSearchSort,
53 optionalAuthenticate,
54 videoChannelsListSearchValidator,
55 asyncMiddleware(searchVideoChannels)
56)
57
58// ---------------------------------------------------------------------------
59
60export { searchRouter }
61
62// ---------------------------------------------------------------------------
63
64function searchVideoChannels (req: express.Request, res: express.Response) {
65 const query: VideoChannelsSearchQuery = req.query
66 const search = query.search
67
68 const isURISearch = search.startsWith('http://') || search.startsWith('https://')
69
70 const parts = search.split('@')
71
72 // Handle strings like @toto@example.com
73 if (parts.length === 3 && parts[0].length === 0) parts.shift()
74 const isWebfingerSearch = parts.length === 2 && parts.every(p => p && !p.includes(' '))
75
76 if (isURISearch || isWebfingerSearch) return searchVideoChannelURI(search, isWebfingerSearch, res)
77
78 // @username -> username to search in DB
79 if (query.search.startsWith('@')) query.search = query.search.replace(/^@/, '')
80
81 if (isSearchIndexSearch(query)) {
82 return searchVideoChannelsIndex(query, res)
83 }
84
85 return searchVideoChannelsDB(query, res)
86}
87
88async function searchVideoChannelsIndex (query: VideoChannelsSearchQuery, res: express.Response) {
89 const result = await buildMutedForSearchIndex(res)
90
91 const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-channels.index.list.params')
92
93 const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-channels'
94
95 try {
96 logger.debug('Doing video channels search index request on %s.', url, { body })
97
98 const { body: searchIndexResult } = await doJSONRequest<ResultList<VideoChannel>>(url, { method: 'POST', json: body })
99 const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-channels.index.list.result')
100
101 return res.json(jsonResult)
102 } catch (err) {
103 logger.warn('Cannot use search index to make video channels search.', { err })
104
105 return res.sendStatus(HttpStatusCode.INTERNAL_SERVER_ERROR_500)
106 }
107}
108
109async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) {
110 const serverActor = await getServerActor()
111
112 const apiOptions = await Hooks.wrapObject({
113 actorId: serverActor.id,
114 search: query.search,
115 start: query.start,
116 count: query.count,
117 sort: query.sort
118 }, 'filter:api.search.video-channels.local.list.params')
119
120 const resultList = await Hooks.wrapPromiseFun(
121 VideoChannelModel.searchForApi,
122 apiOptions,
123 'filter:api.search.video-channels.local.list.result'
124 )
125
126 return res.json(getFormattedObjects(resultList.data, resultList.total))
127}
128
129async function searchVideoChannelURI (search: string, isWebfingerSearch: boolean, res: express.Response) {
130 let videoChannel: MChannelAccountDefault
131 let uri = search
132
133 if (isWebfingerSearch) {
134 try {
135 uri = await loadActorUrlOrGetFromWebfinger(search)
136 } catch (err) {
137 logger.warn('Cannot load actor URL or get from webfinger.', { search, err })
138
139 return res.json({ total: 0, data: [] })
140 }
141 }
142
143 if (isUserAbleToSearchRemoteURI(res)) {
144 try {
145 const actor = await getOrCreateActorAndServerAndModel(uri, 'all', true, true)
146 videoChannel = actor.VideoChannel
147 } catch (err) {
148 logger.info('Cannot search remote video channel %s.', uri, { err })
149 }
150 } else {
151 videoChannel = await VideoChannelModel.loadByUrlAndPopulateAccount(uri)
152 }
153
154 return res.json({
155 total: videoChannel ? 1 : 0,
156 data: videoChannel ? [ videoChannel.toFormattedJSON() ] : []
157 })
158}
159
160function searchVideos (req: express.Request, res: express.Response) {
161 const query: VideosSearchQuery = req.query
162 const search = query.search
163
164 if (search && (search.startsWith('http://') || search.startsWith('https://'))) {
165 return searchVideoURI(search, res)
166 }
167
168 if (isSearchIndexSearch(query)) {
169 return searchVideosIndex(query, res)
170 }
171
172 return searchVideosDB(query, res)
173}
174
175async function searchVideosIndex (query: VideosSearchQuery, res: express.Response) {
176 const result = await buildMutedForSearchIndex(res)
177
178 let body: VideosSearchQuery = Object.assign(query, result)
179
180 // Use the default instance NSFW policy if not specified
181 if (!body.nsfw) {
182 const nsfwPolicy = res.locals.oauth
183 ? res.locals.oauth.token.User.nsfwPolicy
184 : CONFIG.INSTANCE.DEFAULT_NSFW_POLICY
185
186 body.nsfw = nsfwPolicy === 'do_not_list'
187 ? 'false'
188 : 'both'
189 }
190
191 body = await Hooks.wrapObject(body, 'filter:api.search.videos.index.list.params')
192
193 const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/videos'
194
195 try {
196 logger.debug('Doing videos search index request on %s.', url, { body })
197
198 const { body: searchIndexResult } = await doJSONRequest<ResultList<Video>>(url, { method: 'POST', json: body })
199 const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.videos.index.list.result')
200
201 return res.json(jsonResult)
202 } catch (err) {
203 logger.warn('Cannot use search index to make video search.', { err })
204
205 return res.sendStatus(HttpStatusCode.INTERNAL_SERVER_ERROR_500)
206 }
207}
208
209async function searchVideosDB (query: VideosSearchQuery, res: express.Response) {
210 const apiOptions = await Hooks.wrapObject(Object.assign(query, {
211 includeLocalVideos: true,
212 nsfw: buildNSFWFilter(res, query.nsfw),
213 filter: query.filter,
214 user: res.locals.oauth ? res.locals.oauth.token.User : undefined
215 }), 'filter:api.search.videos.local.list.params')
216
217 const resultList = await Hooks.wrapPromiseFun(
218 VideoModel.searchAndPopulateAccountAndServer,
219 apiOptions,
220 'filter:api.search.videos.local.list.result'
221 )
222
223 return res.json(getFormattedObjects(resultList.data, resultList.total))
224}
225
226async function searchVideoURI (url: string, res: express.Response) {
227 let video: MVideoAccountLightBlacklistAllFiles
228
229 // Check if we can fetch a remote video with the URL
230 if (isUserAbleToSearchRemoteURI(res)) {
231 try {
232 const syncParam = {
233 likes: false,
234 dislikes: false,
235 shares: false,
236 comments: false,
237 thumbnail: true,
238 refreshVideo: false
239 }
240
241 const result = await getOrCreateVideoAndAccountAndChannel({ videoObject: url, syncParam })
242 video = result ? result.video : undefined
243 } catch (err) {
244 logger.info('Cannot search remote video %s.', url, { err })
245 }
246 } else {
247 video = await VideoModel.loadByUrlAndPopulateAccount(url)
248 }
249
250 return res.json({
251 total: video ? 1 : 0,
252 data: video ? [ video.toFormattedJSON() ] : []
253 })
254}
255
256function isSearchIndexSearch (query: SearchTargetQuery) {
257 if (query.searchTarget === 'search-index') return true
258
259 const searchIndexConfig = CONFIG.SEARCH.SEARCH_INDEX
260
261 if (searchIndexConfig.ENABLED !== true) return false
262
263 if (searchIndexConfig.DISABLE_LOCAL_SEARCH) return true
264 if (searchIndexConfig.IS_DEFAULT_SEARCH && !query.searchTarget) return true
265
266 return false
267}
268
269async function buildMutedForSearchIndex (res: express.Response) {
270 const serverActor = await getServerActor()
271 const accountIds = [ serverActor.Account.id ]
272
273 if (res.locals.oauth) {
274 accountIds.push(res.locals.oauth.token.User.Account.id)
275 }
276
277 const [ blockedHosts, blockedAccounts ] = await Promise.all([
278 ServerBlocklistModel.listHostsBlockedBy(accountIds),
279 AccountBlocklistModel.listHandlesBlockedBy(accountIds)
280 ])
281
282 return {
283 blockedHosts,
284 blockedAccounts
285 }
286}
diff --git a/server/controllers/api/search/index.ts b/server/controllers/api/search/index.ts
new file mode 100644
index 000000000..67adbb307
--- /dev/null
+++ b/server/controllers/api/search/index.ts
@@ -0,0 +1,16 @@
1import * as express from 'express'
2import { searchChannelsRouter } from './search-video-channels'
3import { searchPlaylistsRouter } from './search-video-playlists'
4import { searchVideosRouter } from './search-videos'
5
6const searchRouter = express.Router()
7
8searchRouter.use('/', searchVideosRouter)
9searchRouter.use('/', searchChannelsRouter)
10searchRouter.use('/', searchPlaylistsRouter)
11
12// ---------------------------------------------------------------------------
13
14export {
15 searchRouter
16}
diff --git a/server/controllers/api/search/search-video-channels.ts b/server/controllers/api/search/search-video-channels.ts
new file mode 100644
index 000000000..16beeed60
--- /dev/null
+++ b/server/controllers/api/search/search-video-channels.ts
@@ -0,0 +1,150 @@
1import * as express from 'express'
2import { sanitizeUrl } from '@server/helpers/core-utils'
3import { doJSONRequest } from '@server/helpers/requests'
4import { CONFIG } from '@server/initializers/config'
5import { WEBSERVER } from '@server/initializers/constants'
6import { Hooks } from '@server/lib/plugins/hooks'
7import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search'
8import { getServerActor } from '@server/models/application/application'
9import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
10import { ResultList, VideoChannel } from '@shared/models'
11import { VideoChannelsSearchQuery } from '../../../../shared/models/search'
12import { isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils'
13import { logger } from '../../../helpers/logger'
14import { getFormattedObjects } from '../../../helpers/utils'
15import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../../../lib/activitypub/actors'
16import {
17 asyncMiddleware,
18 openapiOperationDoc,
19 optionalAuthenticate,
20 paginationValidator,
21 setDefaultPagination,
22 setDefaultSearchSort,
23 videoChannelsListSearchValidator,
24 videoChannelsSearchSortValidator
25} from '../../../middlewares'
26import { VideoChannelModel } from '../../../models/video/video-channel'
27import { MChannelAccountDefault } from '../../../types/models'
28
29const searchChannelsRouter = express.Router()
30
31searchChannelsRouter.get('/video-channels',
32 openapiOperationDoc({ operationId: 'searchChannels' }),
33 paginationValidator,
34 setDefaultPagination,
35 videoChannelsSearchSortValidator,
36 setDefaultSearchSort,
37 optionalAuthenticate,
38 videoChannelsListSearchValidator,
39 asyncMiddleware(searchVideoChannels)
40)
41
42// ---------------------------------------------------------------------------
43
44export { searchChannelsRouter }
45
46// ---------------------------------------------------------------------------
47
48function searchVideoChannels (req: express.Request, res: express.Response) {
49 const query: VideoChannelsSearchQuery = req.query
50 const search = query.search
51
52 const parts = search.split('@')
53
54 // Handle strings like @toto@example.com
55 if (parts.length === 3 && parts[0].length === 0) parts.shift()
56 const isWebfingerSearch = parts.length === 2 && parts.every(p => p && !p.includes(' '))
57
58 if (isURISearch(search) || isWebfingerSearch) return searchVideoChannelURI(search, isWebfingerSearch, res)
59
60 // @username -> username to search in DB
61 if (query.search.startsWith('@')) query.search = query.search.replace(/^@/, '')
62
63 if (isSearchIndexSearch(query)) {
64 return searchVideoChannelsIndex(query, res)
65 }
66
67 return searchVideoChannelsDB(query, res)
68}
69
70async function searchVideoChannelsIndex (query: VideoChannelsSearchQuery, res: express.Response) {
71 const result = await buildMutedForSearchIndex(res)
72
73 const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-channels.index.list.params')
74
75 const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-channels'
76
77 try {
78 logger.debug('Doing video channels search index request on %s.', url, { body })
79
80 const { body: searchIndexResult } = await doJSONRequest<ResultList<VideoChannel>>(url, { method: 'POST', json: body })
81 const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-channels.index.list.result')
82
83 return res.json(jsonResult)
84 } catch (err) {
85 logger.warn('Cannot use search index to make video channels search.', { err })
86
87 return res.fail({
88 status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
89 message: 'Cannot use search index to make video channels search'
90 })
91 }
92}
93
94async function searchVideoChannelsDB (query: VideoChannelsSearchQuery, res: express.Response) {
95 const serverActor = await getServerActor()
96
97 const apiOptions = await Hooks.wrapObject({
98 actorId: serverActor.id,
99 search: query.search,
100 start: query.start,
101 count: query.count,
102 sort: query.sort
103 }, 'filter:api.search.video-channels.local.list.params')
104
105 const resultList = await Hooks.wrapPromiseFun(
106 VideoChannelModel.searchForApi,
107 apiOptions,
108 'filter:api.search.video-channels.local.list.result'
109 )
110
111 return res.json(getFormattedObjects(resultList.data, resultList.total))
112}
113
114async function searchVideoChannelURI (search: string, isWebfingerSearch: boolean, res: express.Response) {
115 let videoChannel: MChannelAccountDefault
116 let uri = search
117
118 if (isWebfingerSearch) {
119 try {
120 uri = await loadActorUrlOrGetFromWebfinger(search)
121 } catch (err) {
122 logger.warn('Cannot load actor URL or get from webfinger.', { search, err })
123
124 return res.json({ total: 0, data: [] })
125 }
126 }
127
128 if (isUserAbleToSearchRemoteURI(res)) {
129 try {
130 const actor = await getOrCreateAPActor(uri, 'all', true, true)
131 videoChannel = actor.VideoChannel
132 } catch (err) {
133 logger.info('Cannot search remote video channel %s.', uri, { err })
134 }
135 } else {
136 videoChannel = await VideoChannelModel.loadByUrlAndPopulateAccount(sanitizeLocalUrl(uri))
137 }
138
139 return res.json({
140 total: videoChannel ? 1 : 0,
141 data: videoChannel ? [ videoChannel.toFormattedJSON() ] : []
142 })
143}
144
145function sanitizeLocalUrl (url: string) {
146 if (!url) return ''
147
148 // Handle alternative channel URLs
149 return url.replace(new RegExp('^' + WEBSERVER.URL + '/c/'), WEBSERVER.URL + '/video-channels/')
150}
diff --git a/server/controllers/api/search/search-video-playlists.ts b/server/controllers/api/search/search-video-playlists.ts
new file mode 100644
index 000000000..b231ff1e2
--- /dev/null
+++ b/server/controllers/api/search/search-video-playlists.ts
@@ -0,0 +1,129 @@
1import * as express from 'express'
2import { sanitizeUrl } from '@server/helpers/core-utils'
3import { isUserAbleToSearchRemoteURI } from '@server/helpers/express-utils'
4import { logger } from '@server/helpers/logger'
5import { doJSONRequest } from '@server/helpers/requests'
6import { getFormattedObjects } from '@server/helpers/utils'
7import { CONFIG } from '@server/initializers/config'
8import { getOrCreateAPVideoPlaylist } from '@server/lib/activitypub/playlists/get'
9import { Hooks } from '@server/lib/plugins/hooks'
10import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search'
11import { getServerActor } from '@server/models/application/application'
12import { VideoPlaylistModel } from '@server/models/video/video-playlist'
13import { MVideoPlaylistFullSummary } from '@server/types/models'
14import { HttpStatusCode } from '@shared/core-utils'
15import { ResultList, VideoPlaylist, VideoPlaylistsSearchQuery } from '@shared/models'
16import {
17 asyncMiddleware,
18 openapiOperationDoc,
19 optionalAuthenticate,
20 paginationValidator,
21 setDefaultPagination,
22 setDefaultSearchSort,
23 videoPlaylistsListSearchValidator,
24 videoPlaylistsSearchSortValidator
25} from '../../../middlewares'
26import { WEBSERVER } from '@server/initializers/constants'
27
28const searchPlaylistsRouter = express.Router()
29
30searchPlaylistsRouter.get('/video-playlists',
31 openapiOperationDoc({ operationId: 'searchPlaylists' }),
32 paginationValidator,
33 setDefaultPagination,
34 videoPlaylistsSearchSortValidator,
35 setDefaultSearchSort,
36 optionalAuthenticate,
37 videoPlaylistsListSearchValidator,
38 asyncMiddleware(searchVideoPlaylists)
39)
40
41// ---------------------------------------------------------------------------
42
43export { searchPlaylistsRouter }
44
45// ---------------------------------------------------------------------------
46
47function searchVideoPlaylists (req: express.Request, res: express.Response) {
48 const query: VideoPlaylistsSearchQuery = req.query
49 const search = query.search
50
51 if (isURISearch(search)) return searchVideoPlaylistsURI(search, res)
52
53 if (isSearchIndexSearch(query)) {
54 return searchVideoPlaylistsIndex(query, res)
55 }
56
57 return searchVideoPlaylistsDB(query, res)
58}
59
60async function searchVideoPlaylistsIndex (query: VideoPlaylistsSearchQuery, res: express.Response) {
61 const result = await buildMutedForSearchIndex(res)
62
63 const body = await Hooks.wrapObject(Object.assign(query, result), 'filter:api.search.video-playlists.index.list.params')
64
65 const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/video-playlists'
66
67 try {
68 logger.debug('Doing video playlists search index request on %s.', url, { body })
69
70 const { body: searchIndexResult } = await doJSONRequest<ResultList<VideoPlaylist>>(url, { method: 'POST', json: body })
71 const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.video-playlists.index.list.result')
72
73 return res.json(jsonResult)
74 } catch (err) {
75 logger.warn('Cannot use search index to make video playlists search.', { err })
76
77 return res.fail({
78 status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
79 message: 'Cannot use search index to make video playlists search'
80 })
81 }
82}
83
84async function searchVideoPlaylistsDB (query: VideoPlaylistsSearchQuery, res: express.Response) {
85 const serverActor = await getServerActor()
86
87 const apiOptions = await Hooks.wrapObject({
88 followerActorId: serverActor.id,
89 search: query.search,
90 start: query.start,
91 count: query.count,
92 sort: query.sort
93 }, 'filter:api.search.video-playlists.local.list.params')
94
95 const resultList = await Hooks.wrapPromiseFun(
96 VideoPlaylistModel.searchForApi,
97 apiOptions,
98 'filter:api.search.video-playlists.local.list.result'
99 )
100
101 return res.json(getFormattedObjects(resultList.data, resultList.total))
102}
103
104async function searchVideoPlaylistsURI (search: string, res: express.Response) {
105 let videoPlaylist: MVideoPlaylistFullSummary
106
107 if (isUserAbleToSearchRemoteURI(res)) {
108 try {
109 videoPlaylist = await getOrCreateAPVideoPlaylist(search)
110 } catch (err) {
111 logger.info('Cannot search remote video playlist %s.', search, { err })
112 }
113 } else {
114 videoPlaylist = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(sanitizeLocalUrl(search))
115 }
116
117 return res.json({
118 total: videoPlaylist ? 1 : 0,
119 data: videoPlaylist ? [ videoPlaylist.toFormattedJSON() ] : []
120 })
121}
122
123function sanitizeLocalUrl (url: string) {
124 if (!url) return ''
125
126 // Handle alternative channel URLs
127 return url.replace(new RegExp('^' + WEBSERVER.URL + '/videos/watch/playlist/'), WEBSERVER.URL + '/video-playlists/')
128 .replace(new RegExp('^' + WEBSERVER.URL + '/w/p/'), WEBSERVER.URL + '/video-playlists/')
129}
diff --git a/server/controllers/api/search/search-videos.ts b/server/controllers/api/search/search-videos.ts
new file mode 100644
index 000000000..b626baa28
--- /dev/null
+++ b/server/controllers/api/search/search-videos.ts
@@ -0,0 +1,153 @@
1import * as express from 'express'
2import { sanitizeUrl } from '@server/helpers/core-utils'
3import { doJSONRequest } from '@server/helpers/requests'
4import { CONFIG } from '@server/initializers/config'
5import { WEBSERVER } from '@server/initializers/constants'
6import { getOrCreateAPVideo } from '@server/lib/activitypub/videos'
7import { Hooks } from '@server/lib/plugins/hooks'
8import { buildMutedForSearchIndex, isSearchIndexSearch, isURISearch } from '@server/lib/search'
9import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
10import { ResultList, Video } from '@shared/models'
11import { VideosSearchQuery } from '../../../../shared/models/search'
12import { buildNSFWFilter, isUserAbleToSearchRemoteURI } from '../../../helpers/express-utils'
13import { logger } from '../../../helpers/logger'
14import { getFormattedObjects } from '../../../helpers/utils'
15import {
16 asyncMiddleware,
17 commonVideosFiltersValidator,
18 openapiOperationDoc,
19 optionalAuthenticate,
20 paginationValidator,
21 setDefaultPagination,
22 setDefaultSearchSort,
23 videosSearchSortValidator,
24 videosSearchValidator
25} from '../../../middlewares'
26import { VideoModel } from '../../../models/video/video'
27import { MVideoAccountLightBlacklistAllFiles } from '../../../types/models'
28
29const searchVideosRouter = express.Router()
30
31searchVideosRouter.get('/videos',
32 openapiOperationDoc({ operationId: 'searchVideos' }),
33 paginationValidator,
34 setDefaultPagination,
35 videosSearchSortValidator,
36 setDefaultSearchSort,
37 optionalAuthenticate,
38 commonVideosFiltersValidator,
39 videosSearchValidator,
40 asyncMiddleware(searchVideos)
41)
42
43// ---------------------------------------------------------------------------
44
45export { searchVideosRouter }
46
47// ---------------------------------------------------------------------------
48
49function searchVideos (req: express.Request, res: express.Response) {
50 const query: VideosSearchQuery = req.query
51 const search = query.search
52
53 if (isURISearch(search)) {
54 return searchVideoURI(search, res)
55 }
56
57 if (isSearchIndexSearch(query)) {
58 return searchVideosIndex(query, res)
59 }
60
61 return searchVideosDB(query, res)
62}
63
64async function searchVideosIndex (query: VideosSearchQuery, res: express.Response) {
65 const result = await buildMutedForSearchIndex(res)
66
67 let body: VideosSearchQuery = Object.assign(query, result)
68
69 // Use the default instance NSFW policy if not specified
70 if (!body.nsfw) {
71 const nsfwPolicy = res.locals.oauth
72 ? res.locals.oauth.token.User.nsfwPolicy
73 : CONFIG.INSTANCE.DEFAULT_NSFW_POLICY
74
75 body.nsfw = nsfwPolicy === 'do_not_list'
76 ? 'false'
77 : 'both'
78 }
79
80 body = await Hooks.wrapObject(body, 'filter:api.search.videos.index.list.params')
81
82 const url = sanitizeUrl(CONFIG.SEARCH.SEARCH_INDEX.URL) + '/api/v1/search/videos'
83
84 try {
85 logger.debug('Doing videos search index request on %s.', url, { body })
86
87 const { body: searchIndexResult } = await doJSONRequest<ResultList<Video>>(url, { method: 'POST', json: body })
88 const jsonResult = await Hooks.wrapObject(searchIndexResult, 'filter:api.search.videos.index.list.result')
89
90 return res.json(jsonResult)
91 } catch (err) {
92 logger.warn('Cannot use search index to make video search.', { err })
93
94 return res.fail({
95 status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
96 message: 'Cannot use search index to make video search'
97 })
98 }
99}
100
101async function searchVideosDB (query: VideosSearchQuery, res: express.Response) {
102 const apiOptions = await Hooks.wrapObject(Object.assign(query, {
103 includeLocalVideos: true,
104 nsfw: buildNSFWFilter(res, query.nsfw),
105 filter: query.filter,
106 user: res.locals.oauth ? res.locals.oauth.token.User : undefined
107 }), 'filter:api.search.videos.local.list.params')
108
109 const resultList = await Hooks.wrapPromiseFun(
110 VideoModel.searchAndPopulateAccountAndServer,
111 apiOptions,
112 'filter:api.search.videos.local.list.result'
113 )
114
115 return res.json(getFormattedObjects(resultList.data, resultList.total))
116}
117
118async function searchVideoURI (url: string, res: express.Response) {
119 let video: MVideoAccountLightBlacklistAllFiles
120
121 // Check if we can fetch a remote video with the URL
122 if (isUserAbleToSearchRemoteURI(res)) {
123 try {
124 const syncParam = {
125 likes: false,
126 dislikes: false,
127 shares: false,
128 comments: false,
129 thumbnail: true,
130 refreshVideo: false
131 }
132
133 const result = await getOrCreateAPVideo({ videoObject: url, syncParam })
134 video = result ? result.video : undefined
135 } catch (err) {
136 logger.info('Cannot search remote video %s.', url, { err })
137 }
138 } else {
139 video = await VideoModel.loadByUrlAndPopulateAccount(sanitizeLocalUrl(url))
140 }
141
142 return res.json({
143 total: video ? 1 : 0,
144 data: video ? [ video.toFormattedJSON() ] : []
145 })
146}
147
148function sanitizeLocalUrl (url: string) {
149 if (!url) return ''
150
151 // Handle alternative video URLs
152 return url.replace(new RegExp('^' + WEBSERVER.URL + '/w/'), WEBSERVER.URL + '/videos/watch/')
153}
diff --git a/server/controllers/api/server/debug.ts b/server/controllers/api/server/debug.ts
index ff0d9ca3c..a6e9147f3 100644
--- a/server/controllers/api/server/debug.ts
+++ b/server/controllers/api/server/debug.ts
@@ -1,5 +1,6 @@
1import { InboxManager } from '@server/lib/activitypub/inbox-manager' 1import { InboxManager } from '@server/lib/activitypub/inbox-manager'
2import { RemoveDanglingResumableUploadsScheduler } from '@server/lib/schedulers/remove-dangling-resumable-uploads-scheduler' 2import { RemoveDanglingResumableUploadsScheduler } from '@server/lib/schedulers/remove-dangling-resumable-uploads-scheduler'
3import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
3import { SendDebugCommand } from '@shared/models' 4import { SendDebugCommand } from '@shared/models'
4import * as express from 'express' 5import * as express from 'express'
5import { UserRight } from '../../../../shared/models/users' 6import { UserRight } from '../../../../shared/models/users'
@@ -41,5 +42,5 @@ async function runCommand (req: express.Request, res: express.Response) {
41 await RemoveDanglingResumableUploadsScheduler.Instance.execute() 42 await RemoveDanglingResumableUploadsScheduler.Instance.execute()
42 } 43 }
43 44
44 return res.sendStatus(204) 45 return res.status(HttpStatusCode.NO_CONTENT_204).end()
45} 46}
diff --git a/server/controllers/api/server/follows.ts b/server/controllers/api/server/follows.ts
index 80025bc5b..12357a2ca 100644
--- a/server/controllers/api/server/follows.ts
+++ b/server/controllers/api/server/follows.ts
@@ -1,9 +1,15 @@
1import * as express from 'express' 1import * as express from 'express'
2import { getServerActor } from '@server/models/application/application'
3import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
2import { UserRight } from '../../../../shared/models/users' 4import { UserRight } from '../../../../shared/models/users'
3import { logger } from '../../../helpers/logger' 5import { logger } from '../../../helpers/logger'
4import { getFormattedObjects } from '../../../helpers/utils' 6import { getFormattedObjects } from '../../../helpers/utils'
5import { SERVER_ACTOR_NAME } from '../../../initializers/constants' 7import { SERVER_ACTOR_NAME } from '../../../initializers/constants'
8import { sequelizeTypescript } from '../../../initializers/database'
9import { autoFollowBackIfNeeded } from '../../../lib/activitypub/follow'
6import { sendAccept, sendReject, sendUndoFollow } from '../../../lib/activitypub/send' 10import { sendAccept, sendReject, sendUndoFollow } from '../../../lib/activitypub/send'
11import { JobQueue } from '../../../lib/job-queue'
12import { removeRedundanciesOfServer } from '../../../lib/redundancy'
7import { 13import {
8 asyncMiddleware, 14 asyncMiddleware,
9 authenticate, 15 authenticate,
@@ -19,16 +25,10 @@ import {
19 followingSortValidator, 25 followingSortValidator,
20 followValidator, 26 followValidator,
21 getFollowerValidator, 27 getFollowerValidator,
22 removeFollowingValidator, 28 listFollowsValidator,
23 listFollowsValidator 29 removeFollowingValidator
24} from '../../../middlewares/validators' 30} from '../../../middlewares/validators'
25import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 31import { ActorFollowModel } from '../../../models/actor/actor-follow'
26import { JobQueue } from '../../../lib/job-queue'
27import { removeRedundanciesOfServer } from '../../../lib/redundancy'
28import { sequelizeTypescript } from '../../../initializers/database'
29import { autoFollowBackIfNeeded } from '../../../lib/activitypub/follow'
30import { getServerActor } from '@server/models/application/application'
31import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
32 32
33const serverFollowsRouter = express.Router() 33const serverFollowsRouter = express.Router()
34serverFollowsRouter.get('/following', 34serverFollowsRouter.get('/following',
@@ -176,7 +176,7 @@ async function removeOrRejectFollower (req: express.Request, res: express.Respon
176async function acceptFollower (req: express.Request, res: express.Response) { 176async function acceptFollower (req: express.Request, res: express.Response) {
177 const follow = res.locals.follow 177 const follow = res.locals.follow
178 178
179 await sendAccept(follow) 179 sendAccept(follow)
180 180
181 follow.state = 'accepted' 181 follow.state = 'accepted'
182 await follow.save() 182 await follow.save()
diff --git a/server/controllers/api/server/redundancy.ts b/server/controllers/api/server/redundancy.ts
index 7c13dc21b..bc593ad43 100644
--- a/server/controllers/api/server/redundancy.ts
+++ b/server/controllers/api/server/redundancy.ts
@@ -90,13 +90,13 @@ async function addVideoRedundancy (req: express.Request, res: express.Response)
90 payload 90 payload
91 }) 91 })
92 92
93 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 93 return res.status(HttpStatusCode.NO_CONTENT_204).end()
94} 94}
95 95
96async function removeVideoRedundancyController (req: express.Request, res: express.Response) { 96async function removeVideoRedundancyController (req: express.Request, res: express.Response) {
97 await removeVideoRedundancy(res.locals.videoRedundancy) 97 await removeVideoRedundancy(res.locals.videoRedundancy)
98 98
99 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 99 return res.status(HttpStatusCode.NO_CONTENT_204).end()
100} 100}
101 101
102async function updateRedundancy (req: express.Request, res: express.Response) { 102async function updateRedundancy (req: express.Request, res: express.Response) {
@@ -110,5 +110,5 @@ async function updateRedundancy (req: express.Request, res: express.Response) {
110 removeRedundanciesOfServer(server.id) 110 removeRedundanciesOfServer(server.id)
111 .catch(err => logger.error('Cannot remove redundancy of %s.', server.host, { err })) 111 .catch(err => logger.error('Cannot remove redundancy of %s.', server.host, { err }))
112 112
113 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 113 return res.status(HttpStatusCode.NO_CONTENT_204).end()
114} 114}
diff --git a/server/controllers/api/server/server-blocklist.ts b/server/controllers/api/server/server-blocklist.ts
index 6e341c0fb..a86bc7d19 100644
--- a/server/controllers/api/server/server-blocklist.ts
+++ b/server/controllers/api/server/server-blocklist.ts
@@ -1,7 +1,7 @@
1import 'multer' 1import 'multer'
2import * as express from 'express' 2import * as express from 'express'
3import { logger } from '@server/helpers/logger' 3import { logger } from '@server/helpers/logger'
4import { UserNotificationModel } from '@server/models/account/user-notification' 4import { UserNotificationModel } from '@server/models/user/user-notification'
5import { getServerActor } from '@server/models/application/application' 5import { getServerActor } from '@server/models/application/application'
6import { UserRight } from '../../../../shared/models/users' 6import { UserRight } from '../../../../shared/models/users'
7import { getFormattedObjects } from '../../../helpers/utils' 7import { getFormattedObjects } from '../../../helpers/utils'
diff --git a/server/controllers/api/users/index.ts b/server/controllers/api/users/index.ts
index e2b1ea7cd..d907b49bf 100644
--- a/server/controllers/api/users/index.ts
+++ b/server/controllers/api/users/index.ts
@@ -45,7 +45,7 @@ import {
45 usersResetPasswordValidator, 45 usersResetPasswordValidator,
46 usersVerifyEmailValidator 46 usersVerifyEmailValidator
47} from '../../../middlewares/validators' 47} from '../../../middlewares/validators'
48import { UserModel } from '../../../models/account/user' 48import { UserModel } from '../../../models/user/user'
49import { meRouter } from './me' 49import { meRouter } from './me'
50import { myAbusesRouter } from './my-abuses' 50import { myAbusesRouter } from './my-abuses'
51import { myBlocklistRouter } from './my-blocklist' 51import { myBlocklistRouter } from './my-blocklist'
@@ -314,7 +314,7 @@ async function removeUser (req: express.Request, res: express.Response) {
314 314
315 Hooks.runAction('action:api.user.deleted', { user }) 315 Hooks.runAction('action:api.user.deleted', { user })
316 316
317 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 317 return res.status(HttpStatusCode.NO_CONTENT_204).end()
318} 318}
319 319
320async function updateUser (req: express.Request, res: express.Response) { 320async function updateUser (req: express.Request, res: express.Response) {
@@ -323,14 +323,20 @@ async function updateUser (req: express.Request, res: express.Response) {
323 const oldUserAuditView = new UserAuditView(userToUpdate.toFormattedJSON()) 323 const oldUserAuditView = new UserAuditView(userToUpdate.toFormattedJSON())
324 const roleChanged = body.role !== undefined && body.role !== userToUpdate.role 324 const roleChanged = body.role !== undefined && body.role !== userToUpdate.role
325 325
326 if (body.password !== undefined) userToUpdate.password = body.password 326 const keysToUpdate: (keyof UserUpdate)[] = [
327 if (body.email !== undefined) userToUpdate.email = body.email 327 'password',
328 if (body.emailVerified !== undefined) userToUpdate.emailVerified = body.emailVerified 328 'email',
329 if (body.videoQuota !== undefined) userToUpdate.videoQuota = body.videoQuota 329 'emailVerified',
330 if (body.videoQuotaDaily !== undefined) userToUpdate.videoQuotaDaily = body.videoQuotaDaily 330 'videoQuota',
331 if (body.role !== undefined) userToUpdate.role = body.role 331 'videoQuotaDaily',
332 if (body.adminFlags !== undefined) userToUpdate.adminFlags = body.adminFlags 332 'role',
333 if (body.pluginAuth !== undefined) userToUpdate.pluginAuth = body.pluginAuth 333 'adminFlags',
334 'pluginAuth'
335 ]
336
337 for (const key of keysToUpdate) {
338 if (body[key] !== undefined) userToUpdate.set(key, body[key])
339 }
334 340
335 const user = await userToUpdate.save() 341 const user = await userToUpdate.save()
336 342
@@ -343,7 +349,7 @@ async function updateUser (req: express.Request, res: express.Response) {
343 349
344 // Don't need to send this update to followers, these attributes are not federated 350 // Don't need to send this update to followers, these attributes are not federated
345 351
346 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 352 return res.status(HttpStatusCode.NO_CONTENT_204).end()
347} 353}
348 354
349async function askResetUserPassword (req: express.Request, res: express.Response) { 355async function askResetUserPassword (req: express.Request, res: express.Response) {
diff --git a/server/controllers/api/users/me.ts b/server/controllers/api/users/me.ts
index 0763d1900..1f2b2f9dd 100644
--- a/server/controllers/api/users/me.ts
+++ b/server/controllers/api/users/me.ts
@@ -11,7 +11,7 @@ import { CONFIG } from '../../../initializers/config'
11import { MIMETYPES } from '../../../initializers/constants' 11import { MIMETYPES } from '../../../initializers/constants'
12import { sequelizeTypescript } from '../../../initializers/database' 12import { sequelizeTypescript } from '../../../initializers/database'
13import { sendUpdateActor } from '../../../lib/activitypub/send' 13import { sendUpdateActor } from '../../../lib/activitypub/send'
14import { deleteLocalActorImageFile, updateLocalActorImageFile } from '../../../lib/actor-image' 14import { deleteLocalActorImageFile, updateLocalActorImageFile } from '../../../lib/local-actor'
15import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user' 15import { getOriginalVideoFileTotalDailyFromUser, getOriginalVideoFileTotalFromUser, sendVerifyUserEmail } from '../../../lib/user'
16import { 16import {
17 asyncMiddleware, 17 asyncMiddleware,
@@ -28,9 +28,10 @@ import { deleteMeValidator, videoImportsSortValidator, videosSortValidator } fro
28import { updateAvatarValidator } from '../../../middlewares/validators/actor-image' 28import { updateAvatarValidator } from '../../../middlewares/validators/actor-image'
29import { AccountModel } from '../../../models/account/account' 29import { AccountModel } from '../../../models/account/account'
30import { AccountVideoRateModel } from '../../../models/account/account-video-rate' 30import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
31import { UserModel } from '../../../models/account/user' 31import { UserModel } from '../../../models/user/user'
32import { VideoModel } from '../../../models/video/video' 32import { VideoModel } from '../../../models/video/video'
33import { VideoImportModel } from '../../../models/video/video-import' 33import { VideoImportModel } from '../../../models/video/video-import'
34import { AttributesOnly } from '@shared/core-utils'
34 35
35const auditLogger = auditLoggerFactory('users') 36const auditLogger = auditLoggerFactory('users')
36 37
@@ -182,7 +183,7 @@ async function deleteMe (req: express.Request, res: express.Response) {
182 183
183 await user.destroy() 184 await user.destroy()
184 185
185 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 186 return res.status(HttpStatusCode.NO_CONTENT_204).end()
186} 187}
187 188
188async function updateMe (req: express.Request, res: express.Response) { 189async function updateMe (req: express.Request, res: express.Response) {
@@ -191,17 +192,23 @@ async function updateMe (req: express.Request, res: express.Response) {
191 192
192 const user = res.locals.oauth.token.user 193 const user = res.locals.oauth.token.user
193 194
194 if (body.password !== undefined) user.password = body.password 195 const keysToUpdate: (keyof UserUpdateMe & keyof AttributesOnly<UserModel>)[] = [
195 if (body.nsfwPolicy !== undefined) user.nsfwPolicy = body.nsfwPolicy 196 'password',
196 if (body.webTorrentEnabled !== undefined) user.webTorrentEnabled = body.webTorrentEnabled 197 'nsfwPolicy',
197 if (body.autoPlayVideo !== undefined) user.autoPlayVideo = body.autoPlayVideo 198 'webTorrentEnabled',
198 if (body.autoPlayNextVideo !== undefined) user.autoPlayNextVideo = body.autoPlayNextVideo 199 'autoPlayVideo',
199 if (body.autoPlayNextVideoPlaylist !== undefined) user.autoPlayNextVideoPlaylist = body.autoPlayNextVideoPlaylist 200 'autoPlayNextVideo',
200 if (body.videosHistoryEnabled !== undefined) user.videosHistoryEnabled = body.videosHistoryEnabled 201 'autoPlayNextVideoPlaylist',
201 if (body.videoLanguages !== undefined) user.videoLanguages = body.videoLanguages 202 'videosHistoryEnabled',
202 if (body.theme !== undefined) user.theme = body.theme 203 'videoLanguages',
203 if (body.noInstanceConfigWarningModal !== undefined) user.noInstanceConfigWarningModal = body.noInstanceConfigWarningModal 204 'theme',
204 if (body.noWelcomeModal !== undefined) user.noWelcomeModal = body.noWelcomeModal 205 'noInstanceConfigWarningModal',
206 'noWelcomeModal'
207 ]
208
209 for (const key of keysToUpdate) {
210 if (body[key] !== undefined) user.set(key, body[key])
211 }
205 212
206 if (body.email !== undefined) { 213 if (body.email !== undefined) {
207 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) { 214 if (CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION) {
@@ -215,22 +222,22 @@ async function updateMe (req: express.Request, res: express.Response) {
215 await sequelizeTypescript.transaction(async t => { 222 await sequelizeTypescript.transaction(async t => {
216 await user.save({ transaction: t }) 223 await user.save({ transaction: t })
217 224
218 if (body.displayName !== undefined || body.description !== undefined) { 225 if (body.displayName === undefined && body.description === undefined) return
219 const userAccount = await AccountModel.load(user.Account.id, t)
220 226
221 if (body.displayName !== undefined) userAccount.name = body.displayName 227 const userAccount = await AccountModel.load(user.Account.id, t)
222 if (body.description !== undefined) userAccount.description = body.description
223 await userAccount.save({ transaction: t })
224 228
225 await sendUpdateActor(userAccount, t) 229 if (body.displayName !== undefined) userAccount.name = body.displayName
226 } 230 if (body.description !== undefined) userAccount.description = body.description
231 await userAccount.save({ transaction: t })
232
233 await sendUpdateActor(userAccount, t)
227 }) 234 })
228 235
229 if (sendVerificationEmail === true) { 236 if (sendVerificationEmail === true) {
230 await sendVerifyUserEmail(user, true) 237 await sendVerifyUserEmail(user, true)
231 } 238 }
232 239
233 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 240 return res.status(HttpStatusCode.NO_CONTENT_204).end()
234} 241}
235 242
236async function updateMyAvatar (req: express.Request, res: express.Response) { 243async function updateMyAvatar (req: express.Request, res: express.Response) {
@@ -250,5 +257,5 @@ async function deleteMyAvatar (req: express.Request, res: express.Response) {
250 const userAccount = await AccountModel.load(user.Account.id) 257 const userAccount = await AccountModel.load(user.Account.id)
251 await deleteLocalActorImageFile(userAccount, ActorImageType.AVATAR) 258 await deleteLocalActorImageFile(userAccount, ActorImageType.AVATAR)
252 259
253 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 260 return res.status(HttpStatusCode.NO_CONTENT_204).end()
254} 261}
diff --git a/server/controllers/api/users/my-blocklist.ts b/server/controllers/api/users/my-blocklist.ts
index faaef3ac0..a1561b751 100644
--- a/server/controllers/api/users/my-blocklist.ts
+++ b/server/controllers/api/users/my-blocklist.ts
@@ -20,7 +20,7 @@ import {
20import { AccountBlocklistModel } from '../../../models/account/account-blocklist' 20import { AccountBlocklistModel } from '../../../models/account/account-blocklist'
21import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../../../lib/blocklist' 21import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../../../lib/blocklist'
22import { ServerBlocklistModel } from '../../../models/server/server-blocklist' 22import { ServerBlocklistModel } from '../../../models/server/server-blocklist'
23import { UserNotificationModel } from '@server/models/account/user-notification' 23import { UserNotificationModel } from '@server/models/user/user-notification'
24import { logger } from '@server/helpers/logger' 24import { logger } from '@server/helpers/logger'
25import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 25import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
26 26
diff --git a/server/controllers/api/users/my-history.ts b/server/controllers/api/users/my-history.ts
index 72c7da373..cff1697ab 100644
--- a/server/controllers/api/users/my-history.ts
+++ b/server/controllers/api/users/my-history.ts
@@ -9,7 +9,7 @@ import {
9 userHistoryRemoveValidator 9 userHistoryRemoveValidator
10} from '../../../middlewares' 10} from '../../../middlewares'
11import { getFormattedObjects } from '../../../helpers/utils' 11import { getFormattedObjects } from '../../../helpers/utils'
12import { UserVideoHistoryModel } from '../../../models/account/user-video-history' 12import { UserVideoHistoryModel } from '../../../models/user/user-video-history'
13import { sequelizeTypescript } from '../../../initializers/database' 13import { sequelizeTypescript } from '../../../initializers/database'
14import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 14import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
15 15
diff --git a/server/controllers/api/users/my-notifications.ts b/server/controllers/api/users/my-notifications.ts
index 0a9101a46..2909770da 100644
--- a/server/controllers/api/users/my-notifications.ts
+++ b/server/controllers/api/users/my-notifications.ts
@@ -1,5 +1,9 @@
1import * as express from 'express'
2import 'multer' 1import 'multer'
2import * as express from 'express'
3import { UserNotificationModel } from '@server/models/user/user-notification'
4import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
5import { UserNotificationSetting } from '../../../../shared/models/users'
6import { getFormattedObjects } from '../../../helpers/utils'
3import { 7import {
4 asyncMiddleware, 8 asyncMiddleware,
5 asyncRetryTransactionMiddleware, 9 asyncRetryTransactionMiddleware,
@@ -9,17 +13,13 @@ import {
9 setDefaultSort, 13 setDefaultSort,
10 userNotificationsSortValidator 14 userNotificationsSortValidator
11} from '../../../middlewares' 15} from '../../../middlewares'
12import { getFormattedObjects } from '../../../helpers/utils'
13import { UserNotificationModel } from '../../../models/account/user-notification'
14import { meRouter } from './me'
15import { 16import {
16 listUserNotificationsValidator, 17 listUserNotificationsValidator,
17 markAsReadUserNotificationsValidator, 18 markAsReadUserNotificationsValidator,
18 updateNotificationSettingsValidator 19 updateNotificationSettingsValidator
19} from '../../../middlewares/validators/user-notifications' 20} from '../../../middlewares/validators/user-notifications'
20import { UserNotificationSetting } from '../../../../shared/models/users' 21import { UserNotificationSettingModel } from '../../../models/user/user-notification-setting'
21import { UserNotificationSettingModel } from '../../../models/account/user-notification-setting' 22import { meRouter } from './me'
22import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
23 23
24const myNotificationsRouter = express.Router() 24const myNotificationsRouter = express.Router()
25 25
diff --git a/server/controllers/api/users/my-subscriptions.ts b/server/controllers/api/users/my-subscriptions.ts
index 56b93276f..46a73d49e 100644
--- a/server/controllers/api/users/my-subscriptions.ts
+++ b/server/controllers/api/users/my-subscriptions.ts
@@ -27,7 +27,7 @@ import {
27 userSubscriptionsSortValidator, 27 userSubscriptionsSortValidator,
28 videosSortValidator 28 videosSortValidator
29} from '../../../middlewares/validators' 29} from '../../../middlewares/validators'
30import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 30import { ActorFollowModel } from '../../../models/actor/actor-follow'
31import { VideoModel } from '../../../models/video/video' 31import { VideoModel } from '../../../models/video/video'
32 32
33const mySubscriptionsRouter = express.Router() 33const mySubscriptionsRouter = express.Router()
diff --git a/server/controllers/api/users/token.ts b/server/controllers/api/users/token.ts
index 694bb0a92..b405ddbf4 100644
--- a/server/controllers/api/users/token.ts
+++ b/server/controllers/api/users/token.ts
@@ -1,13 +1,13 @@
1import * as express from 'express' 1import * as express from 'express'
2import * as RateLimit from 'express-rate-limit' 2import * as RateLimit from 'express-rate-limit'
3import { v4 as uuidv4 } from 'uuid'
4import { logger } from '@server/helpers/logger' 3import { logger } from '@server/helpers/logger'
4import { buildUUID } from '@server/helpers/uuid'
5import { CONFIG } from '@server/initializers/config' 5import { CONFIG } from '@server/initializers/config'
6import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth' 6import { getAuthNameFromRefreshGrant, getBypassFromExternalAuth, getBypassFromPasswordGrant } from '@server/lib/auth/external-auth'
7import { handleOAuthToken } from '@server/lib/auth/oauth' 7import { handleOAuthToken } from '@server/lib/auth/oauth'
8import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model' 8import { BypassLogin, revokeToken } from '@server/lib/auth/oauth-model'
9import { Hooks } from '@server/lib/plugins/hooks' 9import { Hooks } from '@server/lib/plugins/hooks'
10import { asyncMiddleware, authenticate } from '@server/middlewares' 10import { asyncMiddleware, authenticate, openapiOperationDoc } from '@server/middlewares'
11import { ScopedToken } from '@shared/models/users/user-scoped-token' 11import { ScopedToken } from '@shared/models/users/user-scoped-token'
12 12
13const tokensRouter = express.Router() 13const tokensRouter = express.Router()
@@ -19,10 +19,12 @@ const loginRateLimiter = RateLimit({
19 19
20tokensRouter.post('/token', 20tokensRouter.post('/token',
21 loginRateLimiter, 21 loginRateLimiter,
22 openapiOperationDoc({ operationId: 'getOAuthToken' }),
22 asyncMiddleware(handleToken) 23 asyncMiddleware(handleToken)
23) 24)
24 25
25tokensRouter.post('/revoke-token', 26tokensRouter.post('/revoke-token',
27 openapiOperationDoc({ operationId: 'revokeOAuthToken' }),
26 authenticate, 28 authenticate,
27 asyncMiddleware(handleTokenRevocation) 29 asyncMiddleware(handleTokenRevocation)
28) 30)
@@ -78,9 +80,10 @@ async function handleToken (req: express.Request, res: express.Response, next: e
78 } catch (err) { 80 } catch (err) {
79 logger.warn('Login error', { err }) 81 logger.warn('Login error', { err })
80 82
81 return res.status(err.code || 400).json({ 83 return res.fail({
82 code: err.name, 84 status: err.code,
83 error: err.message 85 message: err.message,
86 type: err.name
84 }) 87 })
85 } 88 }
86} 89}
@@ -104,7 +107,7 @@ function getScopedTokens (req: express.Request, res: express.Response) {
104async function renewScopedTokens (req: express.Request, res: express.Response) { 107async function renewScopedTokens (req: express.Request, res: express.Response) {
105 const user = res.locals.oauth.token.user 108 const user = res.locals.oauth.token.user
106 109
107 user.feedToken = uuidv4() 110 user.feedToken = buildUUID()
108 await user.save() 111 await user.save()
109 112
110 return res.json({ 113 return res.json({
diff --git a/server/controllers/api/video-channel.ts b/server/controllers/api/video-channel.ts
index a755d7e57..bc8d203b0 100644
--- a/server/controllers/api/video-channel.ts
+++ b/server/controllers/api/video-channel.ts
@@ -13,8 +13,8 @@ import { CONFIG } from '../../initializers/config'
13import { MIMETYPES } from '../../initializers/constants' 13import { MIMETYPES } from '../../initializers/constants'
14import { sequelizeTypescript } from '../../initializers/database' 14import { sequelizeTypescript } from '../../initializers/database'
15import { sendUpdateActor } from '../../lib/activitypub/send' 15import { sendUpdateActor } from '../../lib/activitypub/send'
16import { deleteLocalActorImageFile, updateLocalActorImageFile } from '../../lib/actor-image'
17import { JobQueue } from '../../lib/job-queue' 16import { JobQueue } from '../../lib/job-queue'
17import { deleteLocalActorImageFile, updateLocalActorImageFile } from '../../lib/local-actor'
18import { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel' 18import { createLocalVideoChannel, federateAllVideosOfChannel } from '../../lib/video-channel'
19import { 19import {
20 asyncMiddleware, 20 asyncMiddleware,
@@ -32,7 +32,7 @@ import {
32 videoChannelsUpdateValidator, 32 videoChannelsUpdateValidator,
33 videoPlaylistsSortValidator 33 videoPlaylistsSortValidator
34} from '../../middlewares' 34} from '../../middlewares'
35import { videoChannelsNameWithHostValidator, videoChannelsOwnSearchValidator, videosSortValidator } from '../../middlewares/validators' 35import { videoChannelsListValidator, videoChannelsNameWithHostValidator, videosSortValidator } from '../../middlewares/validators'
36import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image' 36import { updateAvatarValidator, updateBannerValidator } from '../../middlewares/validators/actor-image'
37import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists' 37import { commonVideoPlaylistFiltersValidator } from '../../middlewares/validators/videos/video-playlists'
38import { AccountModel } from '../../models/account/account' 38import { AccountModel } from '../../models/account/account'
@@ -51,7 +51,7 @@ videoChannelRouter.get('/',
51 videoChannelsSortValidator, 51 videoChannelsSortValidator,
52 setDefaultSort, 52 setDefaultSort,
53 setDefaultPagination, 53 setDefaultPagination,
54 videoChannelsOwnSearchValidator, 54 videoChannelsListValidator,
55 asyncMiddleware(listVideoChannels) 55 asyncMiddleware(listVideoChannels)
56) 56)
57 57
@@ -162,6 +162,7 @@ async function updateVideoChannelBanner (req: express.Request, res: express.Resp
162 162
163 return res.json({ banner: banner.toFormattedJSON() }) 163 return res.json({ banner: banner.toFormattedJSON() })
164} 164}
165
165async function updateVideoChannelAvatar (req: express.Request, res: express.Response) { 166async function updateVideoChannelAvatar (req: express.Request, res: express.Response) {
166 const avatarPhysicalFile = req.files['avatarfile'][0] 167 const avatarPhysicalFile = req.files['avatarfile'][0]
167 const videoChannel = res.locals.videoChannel 168 const videoChannel = res.locals.videoChannel
@@ -179,7 +180,7 @@ async function deleteVideoChannelAvatar (req: express.Request, res: express.Resp
179 180
180 await deleteLocalActorImageFile(videoChannel, ActorImageType.AVATAR) 181 await deleteLocalActorImageFile(videoChannel, ActorImageType.AVATAR)
181 182
182 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 183 return res.status(HttpStatusCode.NO_CONTENT_204).end()
183} 184}
184 185
185async function deleteVideoChannelBanner (req: express.Request, res: express.Response) { 186async function deleteVideoChannelBanner (req: express.Request, res: express.Response) {
@@ -187,7 +188,7 @@ async function deleteVideoChannelBanner (req: express.Request, res: express.Resp
187 188
188 await deleteLocalActorImageFile(videoChannel, ActorImageType.BANNER) 189 await deleteLocalActorImageFile(videoChannel, ActorImageType.BANNER)
189 190
190 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 191 return res.status(HttpStatusCode.NO_CONTENT_204).end()
191} 192}
192 193
193async function addVideoChannel (req: express.Request, res: express.Response) { 194async function addVideoChannel (req: express.Request, res: express.Response) {
@@ -221,10 +222,6 @@ async function updateVideoChannel (req: express.Request, res: express.Response)
221 222
222 try { 223 try {
223 await sequelizeTypescript.transaction(async t => { 224 await sequelizeTypescript.transaction(async t => {
224 const sequelizeOptions = {
225 transaction: t
226 }
227
228 if (videoChannelInfoToUpdate.displayName !== undefined) videoChannelInstance.name = videoChannelInfoToUpdate.displayName 225 if (videoChannelInfoToUpdate.displayName !== undefined) videoChannelInstance.name = videoChannelInfoToUpdate.displayName
229 if (videoChannelInfoToUpdate.description !== undefined) videoChannelInstance.description = videoChannelInfoToUpdate.description 226 if (videoChannelInfoToUpdate.description !== undefined) videoChannelInstance.description = videoChannelInfoToUpdate.description
230 227
@@ -238,7 +235,7 @@ async function updateVideoChannel (req: express.Request, res: express.Response)
238 } 235 }
239 } 236 }
240 237
241 const videoChannelInstanceUpdated = await videoChannelInstance.save(sequelizeOptions) as MChannelBannerAccountDefault 238 const videoChannelInstanceUpdated = await videoChannelInstance.save({ transaction: t }) as MChannelBannerAccountDefault
242 await sendUpdateActor(videoChannelInstanceUpdated, t) 239 await sendUpdateActor(videoChannelInstanceUpdated, t)
243 240
244 auditLogger.update( 241 auditLogger.update(
diff --git a/server/controllers/api/video-playlist.ts b/server/controllers/api/video-playlist.ts
index aab16533d..87a6f6bbe 100644
--- a/server/controllers/api/video-playlist.ts
+++ b/server/controllers/api/video-playlist.ts
@@ -1,7 +1,11 @@
1import * as express from 'express' 1import * as express from 'express'
2import { join } from 'path' 2import { join } from 'path'
3import { uuidToShort } from '@server/helpers/uuid'
4import { scheduleRefreshIfNeeded } from '@server/lib/activitypub/playlists'
5import { Hooks } from '@server/lib/plugins/hooks'
3import { getServerActor } from '@server/models/application/application' 6import { getServerActor } from '@server/models/application/application'
4import { MVideoPlaylistFull, MVideoPlaylistThumbnail, MVideoThumbnail } from '@server/types/models' 7import { MVideoPlaylistFull, MVideoPlaylistThumbnail, MVideoThumbnail } from '@server/types/models'
8import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
5import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model' 9import { VideoPlaylistCreate } from '../../../shared/models/videos/playlist/video-playlist-create.model'
6import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model' 10import { VideoPlaylistElementCreate } from '../../../shared/models/videos/playlist/video-playlist-element-create.model'
7import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model' 11import { VideoPlaylistElementUpdate } from '../../../shared/models/videos/playlist/video-playlist-element-update.model'
@@ -17,8 +21,7 @@ import { MIMETYPES, VIDEO_PLAYLIST_PRIVACIES } from '../../initializers/constant
17import { sequelizeTypescript } from '../../initializers/database' 21import { sequelizeTypescript } from '../../initializers/database'
18import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send' 22import { sendCreateVideoPlaylist, sendDeleteVideoPlaylist, sendUpdateVideoPlaylist } from '../../lib/activitypub/send'
19import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url' 23import { getLocalVideoPlaylistActivityPubUrl, getLocalVideoPlaylistElementActivityPubUrl } from '../../lib/activitypub/url'
20import { JobQueue } from '../../lib/job-queue' 24import { updatePlaylistMiniatureFromExisting } from '../../lib/thumbnail'
21import { createPlaylistMiniatureFromExisting } from '../../lib/thumbnail'
22import { 25import {
23 asyncMiddleware, 26 asyncMiddleware,
24 asyncRetryTransactionMiddleware, 27 asyncRetryTransactionMiddleware,
@@ -42,7 +45,6 @@ import {
42import { AccountModel } from '../../models/account/account' 45import { AccountModel } from '../../models/account/account'
43import { VideoPlaylistModel } from '../../models/video/video-playlist' 46import { VideoPlaylistModel } from '../../models/video/video-playlist'
44import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element' 47import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
45import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
46 48
47const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR }) 49const reqThumbnailFile = createReqFiles([ 'thumbnailfile' ], MIMETYPES.IMAGE.MIMETYPE_EXT, { thumbnailfile: CONFIG.STORAGE.TMP_DIR })
48 50
@@ -144,9 +146,7 @@ async function listVideoPlaylists (req: express.Request, res: express.Response)
144function getVideoPlaylist (req: express.Request, res: express.Response) { 146function getVideoPlaylist (req: express.Request, res: express.Response) {
145 const videoPlaylist = res.locals.videoPlaylistSummary 147 const videoPlaylist = res.locals.videoPlaylistSummary
146 148
147 if (videoPlaylist.isOutdated()) { 149 scheduleRefreshIfNeeded(videoPlaylist)
148 JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: videoPlaylist.url } })
149 }
150 150
151 return res.json(videoPlaylist.toFormattedJSON()) 151 return res.json(videoPlaylist.toFormattedJSON())
152} 152}
@@ -173,7 +173,7 @@ async function addVideoPlaylist (req: express.Request, res: express.Response) {
173 173
174 const thumbnailField = req.files['thumbnailfile'] 174 const thumbnailField = req.files['thumbnailfile']
175 const thumbnailModel = thumbnailField 175 const thumbnailModel = thumbnailField
176 ? await createPlaylistMiniatureFromExisting({ 176 ? await updatePlaylistMiniatureFromExisting({
177 inputPath: thumbnailField[0].path, 177 inputPath: thumbnailField[0].path,
178 playlist: videoPlaylist, 178 playlist: videoPlaylist,
179 automaticallyGenerated: false 179 automaticallyGenerated: false
@@ -200,9 +200,10 @@ async function addVideoPlaylist (req: express.Request, res: express.Response) {
200 return res.json({ 200 return res.json({
201 videoPlaylist: { 201 videoPlaylist: {
202 id: videoPlaylistCreated.id, 202 id: videoPlaylistCreated.id,
203 shortUUID: uuidToShort(videoPlaylistCreated.uuid),
203 uuid: videoPlaylistCreated.uuid 204 uuid: videoPlaylistCreated.uuid
204 } 205 }
205 }).end() 206 })
206} 207}
207 208
208async function updateVideoPlaylist (req: express.Request, res: express.Response) { 209async function updateVideoPlaylist (req: express.Request, res: express.Response) {
@@ -215,7 +216,7 @@ async function updateVideoPlaylist (req: express.Request, res: express.Response)
215 216
216 const thumbnailField = req.files['thumbnailfile'] 217 const thumbnailField = req.files['thumbnailfile']
217 const thumbnailModel = thumbnailField 218 const thumbnailModel = thumbnailField
218 ? await createPlaylistMiniatureFromExisting({ 219 ? await updatePlaylistMiniatureFromExisting({
219 inputPath: thumbnailField[0].path, 220 inputPath: thumbnailField[0].path,
220 playlist: videoPlaylistInstance, 221 playlist: videoPlaylistInstance,
221 automaticallyGenerated: false 222 automaticallyGenerated: false
@@ -332,6 +333,8 @@ async function addVideoInPlaylist (req: express.Request, res: express.Response)
332 333
333 logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position) 334 logger.info('Video added in playlist %s at position %d.', videoPlaylist.uuid, playlistElement.position)
334 335
336 Hooks.runAction('action:api.video-playlist-element.created', { playlistElement })
337
335 return res.json({ 338 return res.json({
336 videoPlaylistElement: { 339 videoPlaylistElement: {
337 id: playlistElement.id 340 id: playlistElement.id
@@ -482,7 +485,7 @@ async function generateThumbnailForPlaylist (videoPlaylist: MVideoPlaylistThumbn
482 } 485 }
483 486
484 const inputPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoMiniature.filename) 487 const inputPath = join(CONFIG.STORAGE.THUMBNAILS_DIR, videoMiniature.filename)
485 const thumbnailModel = await createPlaylistMiniatureFromExisting({ 488 const thumbnailModel = await updatePlaylistMiniatureFromExisting({
486 inputPath, 489 inputPath,
487 playlist: videoPlaylist, 490 playlist: videoPlaylist,
488 automaticallyGenerated: true, 491 automaticallyGenerated: true,
diff --git a/server/controllers/api/videos/blacklist.ts b/server/controllers/api/videos/blacklist.ts
index fa8448c86..530e17965 100644
--- a/server/controllers/api/videos/blacklist.ts
+++ b/server/controllers/api/videos/blacklist.ts
@@ -9,6 +9,7 @@ import {
9 authenticate, 9 authenticate,
10 blacklistSortValidator, 10 blacklistSortValidator,
11 ensureUserHasRight, 11 ensureUserHasRight,
12 openapiOperationDoc,
12 paginationValidator, 13 paginationValidator,
13 setBlacklistSort, 14 setBlacklistSort,
14 setDefaultPagination, 15 setDefaultPagination,
@@ -23,6 +24,7 @@ import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-c
23const blacklistRouter = express.Router() 24const blacklistRouter = express.Router()
24 25
25blacklistRouter.post('/:videoId/blacklist', 26blacklistRouter.post('/:videoId/blacklist',
27 openapiOperationDoc({ operationId: 'addVideoBlock' }),
26 authenticate, 28 authenticate,
27 ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST), 29 ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
28 asyncMiddleware(videosBlacklistAddValidator), 30 asyncMiddleware(videosBlacklistAddValidator),
@@ -30,6 +32,7 @@ blacklistRouter.post('/:videoId/blacklist',
30) 32)
31 33
32blacklistRouter.get('/blacklist', 34blacklistRouter.get('/blacklist',
35 openapiOperationDoc({ operationId: 'getVideoBlocks' }),
33 authenticate, 36 authenticate,
34 ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST), 37 ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
35 paginationValidator, 38 paginationValidator,
@@ -48,6 +51,7 @@ blacklistRouter.put('/:videoId/blacklist',
48) 51)
49 52
50blacklistRouter.delete('/:videoId/blacklist', 53blacklistRouter.delete('/:videoId/blacklist',
54 openapiOperationDoc({ operationId: 'delVideoBlock' }),
51 authenticate, 55 authenticate,
52 ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST), 56 ensureUserHasRight(UserRight.MANAGE_VIDEO_BLACKLIST),
53 asyncMiddleware(videosBlacklistRemoveValidator), 57 asyncMiddleware(videosBlacklistRemoveValidator),
@@ -70,7 +74,7 @@ async function addVideoToBlacklistController (req: express.Request, res: express
70 74
71 logger.info('Video %s blacklisted.', videoInstance.uuid) 75 logger.info('Video %s blacklisted.', videoInstance.uuid)
72 76
73 return res.type('json').sendStatus(HttpStatusCode.NO_CONTENT_204) 77 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
74} 78}
75 79
76async function updateVideoBlacklistController (req: express.Request, res: express.Response) { 80async function updateVideoBlacklistController (req: express.Request, res: express.Response) {
@@ -82,7 +86,7 @@ async function updateVideoBlacklistController (req: express.Request, res: expres
82 return videoBlacklist.save({ transaction: t }) 86 return videoBlacklist.save({ transaction: t })
83 }) 87 })
84 88
85 return res.type('json').sendStatus(HttpStatusCode.NO_CONTENT_204) 89 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
86} 90}
87 91
88async function listBlacklist (req: express.Request, res: express.Response) { 92async function listBlacklist (req: express.Request, res: express.Response) {
@@ -105,5 +109,5 @@ async function removeVideoFromBlacklistController (req: express.Request, res: ex
105 109
106 logger.info('Video %s removed from blacklist.', video.uuid) 110 logger.info('Video %s removed from blacklist.', video.uuid)
107 111
108 return res.type('json').sendStatus(HttpStatusCode.NO_CONTENT_204) 112 return res.type('json').status(HttpStatusCode.NO_CONTENT_204).end()
109} 113}
diff --git a/server/controllers/api/videos/comment.ts b/server/controllers/api/videos/comment.ts
index f1f53d354..e6f28c1cb 100644
--- a/server/controllers/api/videos/comment.ts
+++ b/server/controllers/api/videos/comment.ts
@@ -1,7 +1,7 @@
1import * as express from 'express' 1import * as express from 'express'
2import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 2import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
3import { ResultList, ThreadsResultList, UserRight } from '../../../../shared/models' 3import { ResultList, ThreadsResultList, UserRight } from '../../../../shared/models'
4import { VideoCommentCreate } from '../../../../shared/models/videos/video-comment.model' 4import { VideoCommentCreate } from '../../../../shared/models/videos/comment/video-comment.model'
5import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger' 5import { auditLoggerFactory, CommentAuditView, getAuditIdFromRes } from '../../../helpers/audit-logger'
6import { getFormattedObjects } from '../../../helpers/utils' 6import { getFormattedObjects } from '../../../helpers/utils'
7import { sequelizeTypescript } from '../../../initializers/database' 7import { sequelizeTypescript } from '../../../initializers/database'
@@ -166,7 +166,10 @@ async function listVideoThreadComments (req: express.Request, res: express.Respo
166 } 166 }
167 167
168 if (resultList.data.length === 0) { 168 if (resultList.data.length === 0) {
169 return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 169 return res.fail({
170 status: HttpStatusCode.NOT_FOUND_404,
171 message: 'No comments were found'
172 })
170 } 173 }
171 174
172 return res.json(buildFormattedCommentTree(resultList)) 175 return res.json(buildFormattedCommentTree(resultList))
diff --git a/server/controllers/api/videos/import.ts b/server/controllers/api/videos/import.ts
index 3b9b887e2..de9a5308a 100644
--- a/server/controllers/api/videos/import.ts
+++ b/server/controllers/api/videos/import.ts
@@ -3,7 +3,9 @@ import { move, readFile } from 'fs-extra'
3import * as magnetUtil from 'magnet-uri' 3import * as magnetUtil from 'magnet-uri'
4import * as parseTorrent from 'parse-torrent' 4import * as parseTorrent from 'parse-torrent'
5import { join } from 'path' 5import { join } from 'path'
6import { ServerConfigManager } from '@server/lib/server-config-manager'
6import { setVideoTags } from '@server/lib/video' 7import { setVideoTags } from '@server/lib/video'
8import { FilteredModelAttributes } from '@server/types'
7import { 9import {
8 MChannelAccountDefault, 10 MChannelAccountDefault,
9 MThumbnail, 11 MThumbnail,
@@ -14,23 +16,22 @@ import {
14 MVideoThumbnail, 16 MVideoThumbnail,
15 MVideoWithBlacklistLight 17 MVideoWithBlacklistLight
16} from '@server/types/models' 18} from '@server/types/models'
17import { MVideoImport, MVideoImportFormattable } from '@server/types/models/video/video-import' 19import { MVideoImportFormattable } from '@server/types/models/video/video-import'
18import { VideoImportCreate, VideoImportState, VideoPrivacy, VideoState } from '../../../../shared' 20import { ServerErrorCode, VideoImportCreate, VideoImportState, VideoPrivacy, VideoState } from '../../../../shared'
19import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
20import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type' 21import { ThumbnailType } from '../../../../shared/models/videos/thumbnail.type'
21import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger' 22import { auditLoggerFactory, getAuditIdFromRes, VideoImportAuditView } from '../../../helpers/audit-logger'
22import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils' 23import { moveAndProcessCaptionFile } from '../../../helpers/captions-utils'
23import { isArray } from '../../../helpers/custom-validators/misc' 24import { isArray } from '../../../helpers/custom-validators/misc'
24import { createReqFiles } from '../../../helpers/express-utils' 25import { cleanUpReqFiles, createReqFiles } from '../../../helpers/express-utils'
25import { logger } from '../../../helpers/logger' 26import { logger } from '../../../helpers/logger'
26import { getSecureTorrentName } from '../../../helpers/utils' 27import { getSecureTorrentName } from '../../../helpers/utils'
27import { getYoutubeDLInfo, getYoutubeDLSubs, YoutubeDLInfo } from '../../../helpers/youtube-dl' 28import { YoutubeDL, YoutubeDLInfo } from '../../../helpers/youtube-dl'
28import { CONFIG } from '../../../initializers/config' 29import { CONFIG } from '../../../initializers/config'
29import { MIMETYPES } from '../../../initializers/constants' 30import { MIMETYPES } from '../../../initializers/constants'
30import { sequelizeTypescript } from '../../../initializers/database' 31import { sequelizeTypescript } from '../../../initializers/database'
31import { getLocalVideoActivityPubUrl } from '../../../lib/activitypub/url' 32import { getLocalVideoActivityPubUrl } from '../../../lib/activitypub/url'
32import { JobQueue } from '../../../lib/job-queue/job-queue' 33import { JobQueue } from '../../../lib/job-queue/job-queue'
33import { createVideoMiniatureFromExisting, createVideoMiniatureFromUrl } from '../../../lib/thumbnail' 34import { updateVideoMiniatureFromExisting, updateVideoMiniatureFromUrl } from '../../../lib/thumbnail'
34import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist' 35import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
35import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares' 36import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoImportAddValidator } from '../../../middlewares'
36import { VideoModel } from '../../../models/video/video' 37import { VideoModel } from '../../../models/video/video'
@@ -81,22 +82,15 @@ async function addTorrentImport (req: express.Request, res: express.Response, to
81 let magnetUri: string 82 let magnetUri: string
82 83
83 if (torrentfile) { 84 if (torrentfile) {
84 torrentName = torrentfile.originalname 85 const result = await processTorrentOrAbortRequest(req, res, torrentfile)
86 if (!result) return
85 87
86 // Rename the torrent to a secured name 88 videoName = result.name
87 const newTorrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, getSecureTorrentName(torrentName)) 89 torrentName = result.torrentName
88 await move(torrentfile.path, newTorrentPath)
89 torrentfile.path = newTorrentPath
90
91 const buf = await readFile(torrentfile.path)
92 const parsedTorrent = parseTorrent(buf)
93
94 videoName = isArray(parsedTorrent.name) ? parsedTorrent.name[0] : parsedTorrent.name as string
95 } else { 90 } else {
96 magnetUri = body.magnetUri 91 const result = processMagnetURI(body)
97 92 magnetUri = result.magnetUri
98 const parsed = magnetUtil.decode(magnetUri) 93 videoName = result.name
99 videoName = isArray(parsed.name) ? parsed.name[0] : parsed.name as string
100 } 94 }
101 95
102 const video = buildVideo(res.locals.videoChannel.id, body, { name: videoName }) 96 const video = buildVideo(res.locals.videoChannel.id, body, { name: videoName })
@@ -104,26 +98,26 @@ async function addTorrentImport (req: express.Request, res: express.Response, to
104 const thumbnailModel = await processThumbnail(req, video) 98 const thumbnailModel = await processThumbnail(req, video)
105 const previewModel = await processPreview(req, video) 99 const previewModel = await processPreview(req, video)
106 100
107 const tags = body.tags || undefined
108 const videoImportAttributes = {
109 magnetUri,
110 torrentName,
111 state: VideoImportState.PENDING,
112 userId: user.id
113 }
114 const videoImport = await insertIntoDB({ 101 const videoImport = await insertIntoDB({
115 video, 102 video,
116 thumbnailModel, 103 thumbnailModel,
117 previewModel, 104 previewModel,
118 videoChannel: res.locals.videoChannel, 105 videoChannel: res.locals.videoChannel,
119 tags, 106 tags: body.tags || undefined,
120 videoImportAttributes, 107 user,
121 user 108 videoImportAttributes: {
109 magnetUri,
110 torrentName,
111 state: VideoImportState.PENDING,
112 userId: user.id
113 }
122 }) 114 })
123 115
124 // Create job to import the video 116 // Create job to import the video
125 const payload = { 117 const payload = {
126 type: torrentfile ? 'torrent-file' as 'torrent-file' : 'magnet-uri' as 'magnet-uri', 118 type: torrentfile
119 ? 'torrent-file' as 'torrent-file'
120 : 'magnet-uri' as 'magnet-uri',
127 videoImportId: videoImport.id, 121 videoImportId: videoImport.id,
128 magnetUri 122 magnetUri
129 } 123 }
@@ -139,17 +133,21 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
139 const targetUrl = body.targetUrl 133 const targetUrl = body.targetUrl
140 const user = res.locals.oauth.token.User 134 const user = res.locals.oauth.token.User
141 135
136 const youtubeDL = new YoutubeDL(targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod'))
137
142 // Get video infos 138 // Get video infos
143 let youtubeDLInfo: YoutubeDLInfo 139 let youtubeDLInfo: YoutubeDLInfo
144 try { 140 try {
145 youtubeDLInfo = await getYoutubeDLInfo(targetUrl) 141 youtubeDLInfo = await youtubeDL.getYoutubeDLInfo()
146 } catch (err) { 142 } catch (err) {
147 logger.info('Cannot fetch information from import for URL %s.', targetUrl, { err }) 143 logger.info('Cannot fetch information from import for URL %s.', targetUrl, { err })
148 144
149 return res.status(HttpStatusCode.BAD_REQUEST_400) 145 return res.fail({
150 .json({ 146 message: 'Cannot fetch remote information of this URL.',
151 error: 'Cannot fetch remote information of this URL.' 147 data: {
152 }) 148 targetUrl
149 }
150 })
153 } 151 }
154 152
155 const video = buildVideo(res.locals.videoChannel.id, body, youtubeDLInfo) 153 const video = buildVideo(res.locals.videoChannel.id, body, youtubeDLInfo)
@@ -170,45 +168,22 @@ async function addYoutubeDLImport (req: express.Request, res: express.Response)
170 previewModel = await processPreviewFromUrl(youtubeDLInfo.thumbnailUrl, video) 168 previewModel = await processPreviewFromUrl(youtubeDLInfo.thumbnailUrl, video)
171 } 169 }
172 170
173 const tags = body.tags || youtubeDLInfo.tags
174 const videoImportAttributes = {
175 targetUrl,
176 state: VideoImportState.PENDING,
177 userId: user.id
178 }
179 const videoImport = await insertIntoDB({ 171 const videoImport = await insertIntoDB({
180 video, 172 video,
181 thumbnailModel, 173 thumbnailModel,
182 previewModel, 174 previewModel,
183 videoChannel: res.locals.videoChannel, 175 videoChannel: res.locals.videoChannel,
184 tags, 176 tags: body.tags || youtubeDLInfo.tags,
185 videoImportAttributes, 177 user,
186 user 178 videoImportAttributes: {
179 targetUrl,
180 state: VideoImportState.PENDING,
181 userId: user.id
182 }
187 }) 183 })
188 184
189 // Get video subtitles 185 // Get video subtitles
190 try { 186 await processYoutubeSubtitles(youtubeDL, targetUrl, video.id)
191 const subtitles = await getYoutubeDLSubs(targetUrl)
192
193 logger.info('Will create %s subtitles from youtube import %s.', subtitles.length, targetUrl)
194
195 for (const subtitle of subtitles) {
196 const videoCaption = new VideoCaptionModel({
197 videoId: video.id,
198 language: subtitle.language,
199 filename: VideoCaptionModel.generateCaptionName(subtitle.language)
200 }) as MVideoCaption
201
202 // Move physical file
203 await moveAndProcessCaptionFile(subtitle, videoCaption)
204
205 await sequelizeTypescript.transaction(async t => {
206 await VideoCaptionModel.insertOrReplaceLanguage(videoCaption, t)
207 })
208 }
209 } catch (err) {
210 logger.warn('Cannot get video subtitles.', { err })
211 }
212 187
213 // Create job to import the video 188 // Create job to import the video
214 const payload = { 189 const payload = {
@@ -240,7 +215,9 @@ function buildVideo (channelId: number, body: VideoImportCreate, importData: You
240 privacy: body.privacy || VideoPrivacy.PRIVATE, 215 privacy: body.privacy || VideoPrivacy.PRIVATE,
241 duration: 0, // duration will be set by the import job 216 duration: 0, // duration will be set by the import job
242 channelId: channelId, 217 channelId: channelId,
243 originallyPublishedAt: body.originallyPublishedAt || importData.originallyPublishedAt 218 originallyPublishedAt: body.originallyPublishedAt
219 ? new Date(body.originallyPublishedAt)
220 : importData.originallyPublishedAt
244 } 221 }
245 const video = new VideoModel(videoData) 222 const video = new VideoModel(videoData)
246 video.url = getLocalVideoActivityPubUrl(video) 223 video.url = getLocalVideoActivityPubUrl(video)
@@ -253,7 +230,7 @@ async function processThumbnail (req: express.Request, video: MVideoThumbnail) {
253 if (thumbnailField) { 230 if (thumbnailField) {
254 const thumbnailPhysicalFile = thumbnailField[0] 231 const thumbnailPhysicalFile = thumbnailField[0]
255 232
256 return createVideoMiniatureFromExisting({ 233 return updateVideoMiniatureFromExisting({
257 inputPath: thumbnailPhysicalFile.path, 234 inputPath: thumbnailPhysicalFile.path,
258 video, 235 video,
259 type: ThumbnailType.MINIATURE, 236 type: ThumbnailType.MINIATURE,
@@ -269,7 +246,7 @@ async function processPreview (req: express.Request, video: MVideoThumbnail): Pr
269 if (previewField) { 246 if (previewField) {
270 const previewPhysicalFile = previewField[0] 247 const previewPhysicalFile = previewField[0]
271 248
272 return createVideoMiniatureFromExisting({ 249 return updateVideoMiniatureFromExisting({
273 inputPath: previewPhysicalFile.path, 250 inputPath: previewPhysicalFile.path,
274 video, 251 video,
275 type: ThumbnailType.PREVIEW, 252 type: ThumbnailType.PREVIEW,
@@ -282,7 +259,7 @@ async function processPreview (req: express.Request, video: MVideoThumbnail): Pr
282 259
283async function processThumbnailFromUrl (url: string, video: MVideoThumbnail) { 260async function processThumbnailFromUrl (url: string, video: MVideoThumbnail) {
284 try { 261 try {
285 return createVideoMiniatureFromUrl({ downloadUrl: url, video, type: ThumbnailType.MINIATURE }) 262 return updateVideoMiniatureFromUrl({ downloadUrl: url, video, type: ThumbnailType.MINIATURE })
286 } catch (err) { 263 } catch (err) {
287 logger.warn('Cannot generate video thumbnail %s for %s.', url, video.url, { err }) 264 logger.warn('Cannot generate video thumbnail %s for %s.', url, video.url, { err })
288 return undefined 265 return undefined
@@ -291,7 +268,7 @@ async function processThumbnailFromUrl (url: string, video: MVideoThumbnail) {
291 268
292async function processPreviewFromUrl (url: string, video: MVideoThumbnail) { 269async function processPreviewFromUrl (url: string, video: MVideoThumbnail) {
293 try { 270 try {
294 return createVideoMiniatureFromUrl({ downloadUrl: url, video, type: ThumbnailType.PREVIEW }) 271 return updateVideoMiniatureFromUrl({ downloadUrl: url, video, type: ThumbnailType.PREVIEW })
295 } catch (err) { 272 } catch (err) {
296 logger.warn('Cannot generate video preview %s for %s.', url, video.url, { err }) 273 logger.warn('Cannot generate video preview %s for %s.', url, video.url, { err })
297 return undefined 274 return undefined
@@ -304,7 +281,7 @@ async function insertIntoDB (parameters: {
304 previewModel: MThumbnail 281 previewModel: MThumbnail
305 videoChannel: MChannelAccountDefault 282 videoChannel: MChannelAccountDefault
306 tags: string[] 283 tags: string[]
307 videoImportAttributes: Partial<MVideoImport> 284 videoImportAttributes: FilteredModelAttributes<VideoImportModel>
308 user: MUser 285 user: MUser
309}): Promise<MVideoImportFormattable> { 286}): Promise<MVideoImportFormattable> {
310 const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user } = parameters 287 const { video, thumbnailModel, previewModel, videoChannel, tags, videoImportAttributes, user } = parameters
@@ -342,3 +319,69 @@ async function insertIntoDB (parameters: {
342 319
343 return videoImport 320 return videoImport
344} 321}
322
323async function processTorrentOrAbortRequest (req: express.Request, res: express.Response, torrentfile: Express.Multer.File) {
324 const torrentName = torrentfile.originalname
325
326 // Rename the torrent to a secured name
327 const newTorrentPath = join(CONFIG.STORAGE.TORRENTS_DIR, getSecureTorrentName(torrentName))
328 await move(torrentfile.path, newTorrentPath, { overwrite: true })
329 torrentfile.path = newTorrentPath
330
331 const buf = await readFile(torrentfile.path)
332 const parsedTorrent = parseTorrent(buf) as parseTorrent.Instance
333
334 if (parsedTorrent.files.length !== 1) {
335 cleanUpReqFiles(req)
336
337 res.fail({
338 type: ServerErrorCode.INCORRECT_FILES_IN_TORRENT,
339 message: 'Torrents with only 1 file are supported.'
340 })
341 return undefined
342 }
343
344 return {
345 name: extractNameFromArray(parsedTorrent.name),
346 torrentName
347 }
348}
349
350function processMagnetURI (body: VideoImportCreate) {
351 const magnetUri = body.magnetUri
352 const parsed = magnetUtil.decode(magnetUri)
353
354 return {
355 name: extractNameFromArray(parsed.name),
356 magnetUri
357 }
358}
359
360function extractNameFromArray (name: string | string[]) {
361 return isArray(name) ? name[0] : name
362}
363
364async function processYoutubeSubtitles (youtubeDL: YoutubeDL, targetUrl: string, videoId: number) {
365 try {
366 const subtitles = await youtubeDL.getYoutubeDLSubs()
367
368 logger.info('Will create %s subtitles from youtube import %s.', subtitles.length, targetUrl)
369
370 for (const subtitle of subtitles) {
371 const videoCaption = new VideoCaptionModel({
372 videoId,
373 language: subtitle.language,
374 filename: VideoCaptionModel.generateCaptionName(subtitle.language)
375 }) as MVideoCaption
376
377 // Move physical file
378 await moveAndProcessCaptionFile(subtitle, videoCaption)
379
380 await sequelizeTypescript.transaction(async t => {
381 await VideoCaptionModel.insertOrReplaceLanguage(videoCaption, t)
382 })
383 }
384 } catch (err) {
385 logger.warn('Cannot get video subtitles.', { err })
386 }
387}
diff --git a/server/controllers/api/videos/index.ts b/server/controllers/api/videos/index.ts
index c32626d30..74b100e59 100644
--- a/server/controllers/api/videos/index.ts
+++ b/server/controllers/api/videos/index.ts
@@ -1,43 +1,22 @@
1import * as express from 'express' 1import * as express from 'express'
2import { move } from 'fs-extra'
3import { extname } from 'path'
4import toInt from 'validator/lib/toInt' 2import toInt from 'validator/lib/toInt'
5import { deleteResumableUploadMetaFile, getResumableUploadPath } from '@server/helpers/upload' 3import { doJSONRequest } from '@server/helpers/requests'
6import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' 4import { LiveManager } from '@server/lib/live'
7import { changeVideoChannelShare } from '@server/lib/activitypub/share' 5import { openapiOperationDoc } from '@server/middlewares/doc'
8import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
9import { LiveManager } from '@server/lib/live-manager'
10import { addOptimizeOrMergeAudioJob, buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
11import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
12import { getServerActor } from '@server/models/application/application' 6import { getServerActor } from '@server/models/application/application'
13import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models' 7import { MVideoAccountLight } from '@server/types/models'
14import { uploadx } from '@uploadx/core' 8import { VideosCommonQuery } from '../../../../shared'
15import { VideoCreate, VideosCommonQuery, VideoState, VideoUpdate } from '../../../../shared'
16import { HttpStatusCode } from '../../../../shared/core-utils/miscs' 9import { HttpStatusCode } from '../../../../shared/core-utils/miscs'
17import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger' 10import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
18import { resetSequelizeInstance, retryTransactionWrapper } from '../../../helpers/database-utils' 11import { buildNSFWFilter, getCountVideos } from '../../../helpers/express-utils'
19import { buildNSFWFilter, createReqFiles, getCountVideos } from '../../../helpers/express-utils' 12import { logger } from '../../../helpers/logger'
20import { getMetadataFromFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils'
21import { logger, loggerTagsFactory } from '../../../helpers/logger'
22import { getFormattedObjects } from '../../../helpers/utils' 13import { getFormattedObjects } from '../../../helpers/utils'
23import { CONFIG } from '../../../initializers/config' 14import { REMOTE_SCHEME, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES, VIDEO_PRIVACIES } from '../../../initializers/constants'
24import {
25 DEFAULT_AUDIO_RESOLUTION,
26 MIMETYPES,
27 VIDEO_CATEGORIES,
28 VIDEO_LANGUAGES,
29 VIDEO_LICENCES,
30 VIDEO_PRIVACIES
31} from '../../../initializers/constants'
32import { sequelizeTypescript } from '../../../initializers/database' 15import { sequelizeTypescript } from '../../../initializers/database'
33import { sendView } from '../../../lib/activitypub/send/send-view' 16import { sendView } from '../../../lib/activitypub/send/send-view'
34import { federateVideoIfNeeded, fetchRemoteVideoDescription } from '../../../lib/activitypub/videos'
35import { JobQueue } from '../../../lib/job-queue' 17import { JobQueue } from '../../../lib/job-queue'
36import { Notifier } from '../../../lib/notifier'
37import { Hooks } from '../../../lib/plugins/hooks' 18import { Hooks } from '../../../lib/plugins/hooks'
38import { Redis } from '../../../lib/redis' 19import { Redis } from '../../../lib/redis'
39import { generateVideoMiniature } from '../../../lib/thumbnail'
40import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
41import { 20import {
42 asyncMiddleware, 21 asyncMiddleware,
43 asyncRetryTransactionMiddleware, 22 asyncRetryTransactionMiddleware,
@@ -49,16 +28,11 @@ import {
49 setDefaultPagination, 28 setDefaultPagination,
50 setDefaultVideosSort, 29 setDefaultVideosSort,
51 videoFileMetadataGetValidator, 30 videoFileMetadataGetValidator,
52 videosAddLegacyValidator,
53 videosAddResumableInitValidator,
54 videosAddResumableValidator,
55 videosCustomGetValidator, 31 videosCustomGetValidator,
56 videosGetValidator, 32 videosGetValidator,
57 videosRemoveValidator, 33 videosRemoveValidator,
58 videosSortValidator, 34 videosSortValidator
59 videosUpdateValidator
60} from '../../../middlewares' 35} from '../../../middlewares'
61import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
62import { VideoModel } from '../../../models/video/video' 36import { VideoModel } from '../../../models/video/video'
63import { VideoFileModel } from '../../../models/video/video-file' 37import { VideoFileModel } from '../../../models/video/video-file'
64import { blacklistRouter } from './blacklist' 38import { blacklistRouter } from './blacklist'
@@ -68,40 +42,12 @@ import { videoImportsRouter } from './import'
68import { liveRouter } from './live' 42import { liveRouter } from './live'
69import { ownershipVideoRouter } from './ownership' 43import { ownershipVideoRouter } from './ownership'
70import { rateVideoRouter } from './rate' 44import { rateVideoRouter } from './rate'
45import { updateRouter } from './update'
46import { uploadRouter } from './upload'
71import { watchingRouter } from './watching' 47import { watchingRouter } from './watching'
72 48
73const lTags = loggerTagsFactory('api', 'video')
74const auditLogger = auditLoggerFactory('videos') 49const auditLogger = auditLoggerFactory('videos')
75const videosRouter = express.Router() 50const videosRouter = express.Router()
76const uploadxMiddleware = uploadx.upload({ directory: getResumableUploadPath() })
77
78const reqVideoFileAdd = createReqFiles(
79 [ 'videofile', 'thumbnailfile', 'previewfile' ],
80 Object.assign({}, MIMETYPES.VIDEO.MIMETYPE_EXT, MIMETYPES.IMAGE.MIMETYPE_EXT),
81 {
82 videofile: CONFIG.STORAGE.TMP_DIR,
83 thumbnailfile: CONFIG.STORAGE.TMP_DIR,
84 previewfile: CONFIG.STORAGE.TMP_DIR
85 }
86)
87
88const reqVideoFileAddResumable = createReqFiles(
89 [ 'thumbnailfile', 'previewfile' ],
90 MIMETYPES.IMAGE.MIMETYPE_EXT,
91 {
92 thumbnailfile: getResumableUploadPath(),
93 previewfile: getResumableUploadPath()
94 }
95)
96
97const reqVideoFileUpdate = createReqFiles(
98 [ 'thumbnailfile', 'previewfile' ],
99 MIMETYPES.IMAGE.MIMETYPE_EXT,
100 {
101 thumbnailfile: CONFIG.STORAGE.TMP_DIR,
102 previewfile: CONFIG.STORAGE.TMP_DIR
103 }
104)
105 51
106videosRouter.use('/', blacklistRouter) 52videosRouter.use('/', blacklistRouter)
107videosRouter.use('/', rateVideoRouter) 53videosRouter.use('/', rateVideoRouter)
@@ -111,13 +57,28 @@ videosRouter.use('/', videoImportsRouter)
111videosRouter.use('/', ownershipVideoRouter) 57videosRouter.use('/', ownershipVideoRouter)
112videosRouter.use('/', watchingRouter) 58videosRouter.use('/', watchingRouter)
113videosRouter.use('/', liveRouter) 59videosRouter.use('/', liveRouter)
60videosRouter.use('/', uploadRouter)
61videosRouter.use('/', updateRouter)
114 62
115videosRouter.get('/categories', listVideoCategories) 63videosRouter.get('/categories',
116videosRouter.get('/licences', listVideoLicences) 64 openapiOperationDoc({ operationId: 'getCategories' }),
117videosRouter.get('/languages', listVideoLanguages) 65 listVideoCategories
118videosRouter.get('/privacies', listVideoPrivacies) 66)
67videosRouter.get('/licences',
68 openapiOperationDoc({ operationId: 'getLicences' }),
69 listVideoLicences
70)
71videosRouter.get('/languages',
72 openapiOperationDoc({ operationId: 'getLanguages' }),
73 listVideoLanguages
74)
75videosRouter.get('/privacies',
76 openapiOperationDoc({ operationId: 'getPrivacies' }),
77 listVideoPrivacies
78)
119 79
120videosRouter.get('/', 80videosRouter.get('/',
81 openapiOperationDoc({ operationId: 'getVideos' }),
121 paginationValidator, 82 paginationValidator,
122 videosSortValidator, 83 videosSortValidator,
123 setDefaultVideosSort, 84 setDefaultVideosSort,
@@ -127,40 +88,8 @@ videosRouter.get('/',
127 asyncMiddleware(listVideos) 88 asyncMiddleware(listVideos)
128) 89)
129 90
130videosRouter.post('/upload',
131 authenticate,
132 reqVideoFileAdd,
133 asyncMiddleware(videosAddLegacyValidator),
134 asyncRetryTransactionMiddleware(addVideoLegacy)
135)
136
137videosRouter.post('/upload-resumable',
138 authenticate,
139 reqVideoFileAddResumable,
140 asyncMiddleware(videosAddResumableInitValidator),
141 uploadxMiddleware
142)
143
144videosRouter.delete('/upload-resumable',
145 authenticate,
146 uploadxMiddleware
147)
148
149videosRouter.put('/upload-resumable',
150 authenticate,
151 uploadxMiddleware, // uploadx doesn't use call next() before the file upload completes
152 asyncMiddleware(videosAddResumableValidator),
153 asyncMiddleware(addVideoResumable)
154)
155
156videosRouter.put('/:id',
157 authenticate,
158 reqVideoFileUpdate,
159 asyncMiddleware(videosUpdateValidator),
160 asyncRetryTransactionMiddleware(updateVideo)
161)
162
163videosRouter.get('/:id/description', 91videosRouter.get('/:id/description',
92 openapiOperationDoc({ operationId: 'getVideoDesc' }),
164 asyncMiddleware(videosGetValidator), 93 asyncMiddleware(videosGetValidator),
165 asyncMiddleware(getVideoDescription) 94 asyncMiddleware(getVideoDescription)
166) 95)
@@ -169,17 +98,20 @@ videosRouter.get('/:id/metadata/:videoFileId',
169 asyncMiddleware(getVideoFileMetadata) 98 asyncMiddleware(getVideoFileMetadata)
170) 99)
171videosRouter.get('/:id', 100videosRouter.get('/:id',
101 openapiOperationDoc({ operationId: 'getVideo' }),
172 optionalAuthenticate, 102 optionalAuthenticate,
173 asyncMiddleware(videosCustomGetValidator('only-video-with-rights')), 103 asyncMiddleware(videosCustomGetValidator('for-api')),
174 asyncMiddleware(checkVideoFollowConstraints), 104 asyncMiddleware(checkVideoFollowConstraints),
175 asyncMiddleware(getVideo) 105 asyncMiddleware(getVideo)
176) 106)
177videosRouter.post('/:id/views', 107videosRouter.post('/:id/views',
108 openapiOperationDoc({ operationId: 'addView' }),
178 asyncMiddleware(videosCustomGetValidator('only-immutable-attributes')), 109 asyncMiddleware(videosCustomGetValidator('only-immutable-attributes')),
179 asyncMiddleware(viewVideo) 110 asyncMiddleware(viewVideo)
180) 111)
181 112
182videosRouter.delete('/:id', 113videosRouter.delete('/:id',
114 openapiOperationDoc({ operationId: 'delVideo' }),
183 authenticate, 115 authenticate,
184 asyncMiddleware(videosRemoveValidator), 116 asyncMiddleware(videosRemoveValidator),
185 asyncRetryTransactionMiddleware(removeVideo) 117 asyncRetryTransactionMiddleware(removeVideo)
@@ -209,287 +141,8 @@ function listVideoPrivacies (_req: express.Request, res: express.Response) {
209 res.json(VIDEO_PRIVACIES) 141 res.json(VIDEO_PRIVACIES)
210} 142}
211 143
212async function addVideoLegacy (req: express.Request, res: express.Response) { 144async function getVideo (_req: express.Request, res: express.Response) {
213 // Uploading the video could be long 145 const video = res.locals.videoAPI
214 // Set timeout to 10 minutes, as Express's default is 2 minutes
215 req.setTimeout(1000 * 60 * 10, () => {
216 logger.error('Upload video has timed out.')
217 return res.sendStatus(HttpStatusCode.REQUEST_TIMEOUT_408)
218 })
219
220 const videoPhysicalFile = req.files['videofile'][0]
221 const videoInfo: VideoCreate = req.body
222 const files = req.files
223
224 return addVideo({ res, videoPhysicalFile, videoInfo, files })
225}
226
227async function addVideoResumable (_req: express.Request, res: express.Response) {
228 const videoPhysicalFile = res.locals.videoFileResumable
229 const videoInfo = videoPhysicalFile.metadata
230 const files = { previewfile: videoInfo.previewfile }
231
232 // Don't need the meta file anymore
233 await deleteResumableUploadMetaFile(videoPhysicalFile.path)
234
235 return addVideo({ res, videoPhysicalFile, videoInfo, files })
236}
237
238async function addVideo (options: {
239 res: express.Response
240 videoPhysicalFile: express.VideoUploadFile
241 videoInfo: VideoCreate
242 files: express.UploadFiles
243}) {
244 const { res, videoPhysicalFile, videoInfo, files } = options
245 const videoChannel = res.locals.videoChannel
246 const user = res.locals.oauth.token.User
247
248 const videoData = buildLocalVideoFromReq(videoInfo, videoChannel.id)
249
250 videoData.state = CONFIG.TRANSCODING.ENABLED
251 ? VideoState.TO_TRANSCODE
252 : VideoState.PUBLISHED
253
254 videoData.duration = videoPhysicalFile.duration // duration was added by a previous middleware
255
256 const video = new VideoModel(videoData) as MVideoFullLight
257 video.VideoChannel = videoChannel
258 video.url = getLocalVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
259
260 const videoFile = new VideoFileModel({
261 extname: extname(videoPhysicalFile.filename),
262 size: videoPhysicalFile.size,
263 videoStreamingPlaylistId: null,
264 metadata: await getMetadataFromFile(videoPhysicalFile.path)
265 })
266
267 if (videoFile.isAudio()) {
268 videoFile.resolution = DEFAULT_AUDIO_RESOLUTION
269 } else {
270 videoFile.fps = await getVideoFileFPS(videoPhysicalFile.path)
271 videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path)).videoFileResolution
272 }
273
274 videoFile.filename = generateVideoFilename(video, false, videoFile.resolution, videoFile.extname)
275
276 // Move physical file
277 const destination = getVideoFilePath(video, videoFile)
278 await move(videoPhysicalFile.path, destination)
279 // This is important in case if there is another attempt in the retry process
280 videoPhysicalFile.filename = getVideoFilePath(video, videoFile)
281 videoPhysicalFile.path = destination
282
283 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
284 video,
285 files,
286 fallback: type => generateVideoMiniature({ video, videoFile, type })
287 })
288
289 const { videoCreated } = await sequelizeTypescript.transaction(async t => {
290 const sequelizeOptions = { transaction: t }
291
292 const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight
293
294 await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
295 await videoCreated.addAndSaveThumbnail(previewModel, t)
296
297 // Do not forget to add video channel information to the created video
298 videoCreated.VideoChannel = res.locals.videoChannel
299
300 videoFile.videoId = video.id
301 await videoFile.save(sequelizeOptions)
302
303 video.VideoFiles = [ videoFile ]
304
305 await setVideoTags({ video, tags: videoInfo.tags, transaction: t })
306
307 // Schedule an update in the future?
308 if (videoInfo.scheduleUpdate) {
309 await ScheduleVideoUpdateModel.create({
310 videoId: video.id,
311 updateAt: videoInfo.scheduleUpdate.updateAt,
312 privacy: videoInfo.scheduleUpdate.privacy || null
313 }, { transaction: t })
314 }
315
316 // Channel has a new content, set as updated
317 await videoCreated.VideoChannel.setAsUpdated(t)
318
319 await autoBlacklistVideoIfNeeded({
320 video,
321 user,
322 isRemote: false,
323 isNew: true,
324 transaction: t
325 })
326
327 auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
328 logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid))
329
330 return { videoCreated }
331 })
332
333 // Create the torrent file in async way because it could be long
334 createTorrentAndSetInfoHashAsync(video, videoFile)
335 .catch(err => logger.error('Cannot create torrent file for video %s', video.url, { err, ...lTags(video.uuid) }))
336 .then(() => VideoModel.loadAndPopulateAccountAndServerAndTags(video.id))
337 .then(refreshedVideo => {
338 if (!refreshedVideo) return
339
340 // Only federate and notify after the torrent creation
341 Notifier.Instance.notifyOnNewVideoIfNeeded(refreshedVideo)
342
343 return retryTransactionWrapper(() => {
344 return sequelizeTypescript.transaction(t => federateVideoIfNeeded(refreshedVideo, true, t))
345 })
346 })
347 .catch(err => logger.error('Cannot federate or notify video creation %s', video.url, { err, ...lTags(video.uuid) }))
348
349 if (video.state === VideoState.TO_TRANSCODE) {
350 await addOptimizeOrMergeAudioJob(videoCreated, videoFile, user)
351 }
352
353 Hooks.runAction('action:api.video.uploaded', { video: videoCreated })
354
355 return res.json({
356 video: {
357 id: videoCreated.id,
358 uuid: videoCreated.uuid
359 }
360 })
361}
362
363async function updateVideo (req: express.Request, res: express.Response) {
364 const videoInstance = res.locals.videoAll
365 const videoFieldsSave = videoInstance.toJSON()
366 const oldVideoAuditView = new VideoAuditView(videoInstance.toFormattedDetailsJSON())
367 const videoInfoToUpdate: VideoUpdate = req.body
368
369 const wasConfidentialVideo = videoInstance.isConfidential()
370 const hadPrivacyForFederation = videoInstance.hasPrivacyForFederation()
371
372 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
373 video: videoInstance,
374 files: req.files,
375 fallback: () => Promise.resolve(undefined),
376 automaticallyGenerated: false
377 })
378
379 try {
380 const videoInstanceUpdated = await sequelizeTypescript.transaction(async t => {
381 const sequelizeOptions = { transaction: t }
382 const oldVideoChannel = videoInstance.VideoChannel
383
384 if (videoInfoToUpdate.name !== undefined) videoInstance.name = videoInfoToUpdate.name
385 if (videoInfoToUpdate.category !== undefined) videoInstance.category = videoInfoToUpdate.category
386 if (videoInfoToUpdate.licence !== undefined) videoInstance.licence = videoInfoToUpdate.licence
387 if (videoInfoToUpdate.language !== undefined) videoInstance.language = videoInfoToUpdate.language
388 if (videoInfoToUpdate.nsfw !== undefined) videoInstance.nsfw = videoInfoToUpdate.nsfw
389 if (videoInfoToUpdate.waitTranscoding !== undefined) videoInstance.waitTranscoding = videoInfoToUpdate.waitTranscoding
390 if (videoInfoToUpdate.support !== undefined) videoInstance.support = videoInfoToUpdate.support
391 if (videoInfoToUpdate.description !== undefined) videoInstance.description = videoInfoToUpdate.description
392 if (videoInfoToUpdate.commentsEnabled !== undefined) videoInstance.commentsEnabled = videoInfoToUpdate.commentsEnabled
393 if (videoInfoToUpdate.downloadEnabled !== undefined) videoInstance.downloadEnabled = videoInfoToUpdate.downloadEnabled
394
395 if (videoInfoToUpdate.originallyPublishedAt !== undefined && videoInfoToUpdate.originallyPublishedAt !== null) {
396 videoInstance.originallyPublishedAt = new Date(videoInfoToUpdate.originallyPublishedAt)
397 }
398
399 let isNewVideo = false
400 if (videoInfoToUpdate.privacy !== undefined) {
401 isNewVideo = videoInstance.isNewVideo(videoInfoToUpdate.privacy)
402
403 const newPrivacy = parseInt(videoInfoToUpdate.privacy.toString(), 10)
404 videoInstance.setPrivacy(newPrivacy)
405
406 // Unfederate the video if the new privacy is not compatible with federation
407 if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) {
408 await VideoModel.sendDelete(videoInstance, { transaction: t })
409 }
410 }
411
412 const videoInstanceUpdated = await videoInstance.save(sequelizeOptions) as MVideoFullLight
413
414 if (thumbnailModel) await videoInstanceUpdated.addAndSaveThumbnail(thumbnailModel, t)
415 if (previewModel) await videoInstanceUpdated.addAndSaveThumbnail(previewModel, t)
416
417 // Video tags update?
418 if (videoInfoToUpdate.tags !== undefined) {
419 await setVideoTags({
420 video: videoInstanceUpdated,
421 tags: videoInfoToUpdate.tags,
422 transaction: t
423 })
424 }
425
426 // Video channel update?
427 if (res.locals.videoChannel && videoInstanceUpdated.channelId !== res.locals.videoChannel.id) {
428 await videoInstanceUpdated.$set('VideoChannel', res.locals.videoChannel, { transaction: t })
429 videoInstanceUpdated.VideoChannel = res.locals.videoChannel
430
431 if (hadPrivacyForFederation === true) await changeVideoChannelShare(videoInstanceUpdated, oldVideoChannel, t)
432 }
433
434 // Schedule an update in the future?
435 if (videoInfoToUpdate.scheduleUpdate) {
436 await ScheduleVideoUpdateModel.upsert({
437 videoId: videoInstanceUpdated.id,
438 updateAt: videoInfoToUpdate.scheduleUpdate.updateAt,
439 privacy: videoInfoToUpdate.scheduleUpdate.privacy || null
440 }, { transaction: t })
441 } else if (videoInfoToUpdate.scheduleUpdate === null) {
442 await ScheduleVideoUpdateModel.deleteByVideoId(videoInstanceUpdated.id, t)
443 }
444
445 await autoBlacklistVideoIfNeeded({
446 video: videoInstanceUpdated,
447 user: res.locals.oauth.token.User,
448 isRemote: false,
449 isNew: false,
450 transaction: t
451 })
452
453 await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, t)
454
455 auditLogger.update(
456 getAuditIdFromRes(res),
457 new VideoAuditView(videoInstanceUpdated.toFormattedDetailsJSON()),
458 oldVideoAuditView
459 )
460 logger.info('Video with name %s and uuid %s updated.', videoInstance.name, videoInstance.uuid, lTags(videoInstance.uuid))
461
462 return videoInstanceUpdated
463 })
464
465 if (wasConfidentialVideo) {
466 Notifier.Instance.notifyOnNewVideoIfNeeded(videoInstanceUpdated)
467 }
468
469 Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated, body: req.body })
470 } catch (err) {
471 // Force fields we want to update
472 // If the transaction is retried, sequelize will think the object has not changed
473 // So it will skip the SQL request, even if the last one was ROLLBACKed!
474 resetSequelizeInstance(videoInstance, videoFieldsSave)
475
476 throw err
477 }
478
479 return res.type('json')
480 .status(HttpStatusCode.NO_CONTENT_204)
481 .end()
482}
483
484async function getVideo (req: express.Request, res: express.Response) {
485 // We need more attributes
486 const userId: number = res.locals.oauth ? res.locals.oauth.token.User.id : null
487
488 const video = await Hooks.wrapPromiseFun(
489 VideoModel.loadForGetAPI,
490 { id: res.locals.onlyVideoWithRights.id, userId },
491 'filter:api.video.get.result'
492 )
493 146
494 if (video.isOutdated()) { 147 if (video.isOutdated()) {
495 JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: video.url } }) 148 JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video', url: video.url } })
@@ -505,7 +158,7 @@ async function viewVideo (req: express.Request, res: express.Response) {
505 const exists = await Redis.Instance.doesVideoIPViewExist(ip, immutableVideoAttrs.uuid) 158 const exists = await Redis.Instance.doesVideoIPViewExist(ip, immutableVideoAttrs.uuid)
506 if (exists) { 159 if (exists) {
507 logger.debug('View for ip %s and video %s already exists.', ip, immutableVideoAttrs.uuid) 160 logger.debug('View for ip %s and video %s already exists.', ip, immutableVideoAttrs.uuid)
508 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 161 return res.status(HttpStatusCode.NO_CONTENT_204).end()
509 } 162 }
510 163
511 const video = await VideoModel.load(immutableVideoAttrs.id) 164 const video = await VideoModel.load(immutableVideoAttrs.id)
@@ -538,18 +191,15 @@ async function viewVideo (req: express.Request, res: express.Response) {
538 191
539 Hooks.runAction('action:api.video.viewed', { video, ip }) 192 Hooks.runAction('action:api.video.viewed', { video, ip })
540 193
541 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 194 return res.status(HttpStatusCode.NO_CONTENT_204).end()
542} 195}
543 196
544async function getVideoDescription (req: express.Request, res: express.Response) { 197async function getVideoDescription (req: express.Request, res: express.Response) {
545 const videoInstance = res.locals.videoAll 198 const videoInstance = res.locals.videoAll
546 let description = ''
547 199
548 if (videoInstance.isOwned()) { 200 const description = videoInstance.isOwned()
549 description = videoInstance.description 201 ? videoInstance.description
550 } else { 202 : await fetchRemoteVideoDescription(videoInstance)
551 description = await fetchRemoteVideoDescription(videoInstance)
552 }
553 203
554 return res.json({ description }) 204 return res.json({ description })
555} 205}
@@ -591,7 +241,7 @@ async function listVideos (req: express.Request, res: express.Response) {
591 return res.json(getFormattedObjects(resultList.data, resultList.total)) 241 return res.json(getFormattedObjects(resultList.data, resultList.total))
592} 242}
593 243
594async function removeVideo (req: express.Request, res: express.Response) { 244async function removeVideo (_req: express.Request, res: express.Response) {
595 const videoInstance = res.locals.videoAll 245 const videoInstance = res.locals.videoAll
596 246
597 await sequelizeTypescript.transaction(async t => { 247 await sequelizeTypescript.transaction(async t => {
@@ -608,16 +258,14 @@ async function removeVideo (req: express.Request, res: express.Response) {
608 .end() 258 .end()
609} 259}
610 260
611async function createTorrentAndSetInfoHashAsync (video: MVideo, fileArg: MVideoFile) { 261// ---------------------------------------------------------------------------
612 await createTorrentAndSetInfoHash(video, fileArg)
613
614 // Refresh videoFile because the createTorrentAndSetInfoHash could be long
615 const refreshedFile = await VideoFileModel.loadWithVideo(fileArg.id)
616 // File does not exist anymore, remove the generated torrent
617 if (!refreshedFile) return fileArg.removeTorrent()
618 262
619 refreshedFile.infoHash = fileArg.infoHash 263// FIXME: Should not exist, we rely on specific API
620 refreshedFile.torrentFilename = fileArg.torrentFilename 264async function fetchRemoteVideoDescription (video: MVideoAccountLight) {
265 const host = video.VideoChannel.Account.Actor.Server.host
266 const path = video.getDescriptionAPIPath()
267 const url = REMOTE_SCHEME.HTTP + '://' + host + path
621 268
622 return refreshedFile.save() 269 const { body } = await doJSONRequest<any>(url)
270 return body.description || ''
623} 271}
diff --git a/server/controllers/api/videos/live.ts b/server/controllers/api/videos/live.ts
index 04d2494ce..d8c51c2d4 100644
--- a/server/controllers/api/videos/live.ts
+++ b/server/controllers/api/videos/live.ts
@@ -1,6 +1,6 @@
1import * as express from 'express' 1import * as express from 'express'
2import { v4 as uuidv4 } from 'uuid'
3import { createReqFiles } from '@server/helpers/express-utils' 2import { createReqFiles } from '@server/helpers/express-utils'
3import { buildUUID, uuidToShort } from '@server/helpers/uuid'
4import { CONFIG } from '@server/initializers/config' 4import { CONFIG } from '@server/initializers/config'
5import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants' 5import { ASSETS_PATH, MIMETYPES } from '@server/initializers/constants'
6import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url' 6import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
@@ -11,12 +11,12 @@ import { videoLiveAddValidator, videoLiveGetValidator, videoLiveUpdateValidator
11import { VideoLiveModel } from '@server/models/video/video-live' 11import { VideoLiveModel } from '@server/models/video/video-live'
12import { MVideoDetails, MVideoFullLight } from '@server/types/models' 12import { MVideoDetails, MVideoFullLight } from '@server/types/models'
13import { LiveVideoCreate, LiveVideoUpdate, VideoState } from '../../../../shared' 13import { LiveVideoCreate, LiveVideoUpdate, VideoState } from '../../../../shared'
14import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
14import { logger } from '../../../helpers/logger' 15import { logger } from '../../../helpers/logger'
15import { sequelizeTypescript } from '../../../initializers/database' 16import { sequelizeTypescript } from '../../../initializers/database'
16import { createVideoMiniatureFromExisting } from '../../../lib/thumbnail' 17import { updateVideoMiniatureFromExisting } from '../../../lib/thumbnail'
17import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares' 18import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate } from '../../../middlewares'
18import { VideoModel } from '../../../models/video/video' 19import { VideoModel } from '../../../models/video/video'
19import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
20 20
21const liveRouter = express.Router() 21const liveRouter = express.Router()
22 22
@@ -76,7 +76,7 @@ async function updateLiveVideo (req: express.Request, res: express.Response) {
76 76
77 await federateVideoIfNeeded(video, false) 77 await federateVideoIfNeeded(video, false)
78 78
79 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 79 return res.status(HttpStatusCode.NO_CONTENT_204).end()
80} 80}
81 81
82async function addLiveVideo (req: express.Request, res: express.Response) { 82async function addLiveVideo (req: express.Request, res: express.Response) {
@@ -94,13 +94,13 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
94 const videoLive = new VideoLiveModel() 94 const videoLive = new VideoLiveModel()
95 videoLive.saveReplay = videoInfo.saveReplay || false 95 videoLive.saveReplay = videoInfo.saveReplay || false
96 videoLive.permanentLive = videoInfo.permanentLive || false 96 videoLive.permanentLive = videoInfo.permanentLive || false
97 videoLive.streamKey = uuidv4() 97 videoLive.streamKey = buildUUID()
98 98
99 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({ 99 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
100 video, 100 video,
101 files: req.files, 101 files: req.files,
102 fallback: type => { 102 fallback: type => {
103 return createVideoMiniatureFromExisting({ 103 return updateVideoMiniatureFromExisting({
104 inputPath: ASSETS_PATH.DEFAULT_LIVE_BACKGROUND, 104 inputPath: ASSETS_PATH.DEFAULT_LIVE_BACKGROUND,
105 video, 105 video,
106 type, 106 type,
@@ -138,6 +138,7 @@ async function addLiveVideo (req: express.Request, res: express.Response) {
138 return res.json({ 138 return res.json({
139 video: { 139 video: {
140 id: videoCreated.id, 140 id: videoCreated.id,
141 shortUUID: uuidToShort(videoCreated.uuid),
141 uuid: videoCreated.uuid 142 uuid: videoCreated.uuid
142 } 143 }
143 }) 144 })
diff --git a/server/controllers/api/videos/ownership.ts b/server/controllers/api/videos/ownership.ts
index a85d7c30b..1bb96e046 100644
--- a/server/controllers/api/videos/ownership.ts
+++ b/server/controllers/api/videos/ownership.ts
@@ -99,15 +99,15 @@ async function listVideoOwnership (req: express.Request, res: express.Response)
99 return res.json(getFormattedObjects(resultList.data, resultList.total)) 99 return res.json(getFormattedObjects(resultList.data, resultList.total))
100} 100}
101 101
102async function acceptOwnership (req: express.Request, res: express.Response) { 102function acceptOwnership (req: express.Request, res: express.Response) {
103 return sequelizeTypescript.transaction(async t => { 103 return sequelizeTypescript.transaction(async t => {
104 const videoChangeOwnership = res.locals.videoChangeOwnership 104 const videoChangeOwnership = res.locals.videoChangeOwnership
105 const channel = res.locals.videoChannel 105 const channel = res.locals.videoChannel
106 106
107 // We need more attributes for federation 107 // We need more attributes for federation
108 const targetVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoChangeOwnership.Video.id) 108 const targetVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoChangeOwnership.Video.id, t)
109 109
110 const oldVideoChannel = await VideoChannelModel.loadAndPopulateAccount(targetVideo.channelId) 110 const oldVideoChannel = await VideoChannelModel.loadAndPopulateAccount(targetVideo.channelId, t)
111 111
112 targetVideo.channelId = channel.id 112 targetVideo.channelId = channel.id
113 113
@@ -122,17 +122,17 @@ async function acceptOwnership (req: express.Request, res: express.Response) {
122 videoChangeOwnership.status = VideoChangeOwnershipStatus.ACCEPTED 122 videoChangeOwnership.status = VideoChangeOwnershipStatus.ACCEPTED
123 await videoChangeOwnership.save({ transaction: t }) 123 await videoChangeOwnership.save({ transaction: t })
124 124
125 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 125 return res.status(HttpStatusCode.NO_CONTENT_204).end()
126 }) 126 })
127} 127}
128 128
129async function refuseOwnership (req: express.Request, res: express.Response) { 129function refuseOwnership (req: express.Request, res: express.Response) {
130 return sequelizeTypescript.transaction(async t => { 130 return sequelizeTypescript.transaction(async t => {
131 const videoChangeOwnership = res.locals.videoChangeOwnership 131 const videoChangeOwnership = res.locals.videoChangeOwnership
132 132
133 videoChangeOwnership.status = VideoChangeOwnershipStatus.REFUSED 133 videoChangeOwnership.status = VideoChangeOwnershipStatus.REFUSED
134 await videoChangeOwnership.save({ transaction: t }) 134 await videoChangeOwnership.save({ transaction: t })
135 135
136 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 136 return res.status(HttpStatusCode.NO_CONTENT_204).end()
137 }) 137 })
138} 138}
diff --git a/server/controllers/api/videos/update.ts b/server/controllers/api/videos/update.ts
new file mode 100644
index 000000000..8affe71c6
--- /dev/null
+++ b/server/controllers/api/videos/update.ts
@@ -0,0 +1,193 @@
1import * as express from 'express'
2import { Transaction } from 'sequelize/types'
3import { changeVideoChannelShare } from '@server/lib/activitypub/share'
4import { buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
5import { FilteredModelAttributes } from '@server/types'
6import { MVideoFullLight } from '@server/types/models'
7import { VideoUpdate } from '../../../../shared'
8import { HttpStatusCode } from '../../../../shared/core-utils/miscs'
9import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
10import { resetSequelizeInstance } from '../../../helpers/database-utils'
11import { createReqFiles } from '../../../helpers/express-utils'
12import { logger, loggerTagsFactory } from '../../../helpers/logger'
13import { CONFIG } from '../../../initializers/config'
14import { MIMETYPES } from '../../../initializers/constants'
15import { sequelizeTypescript } from '../../../initializers/database'
16import { federateVideoIfNeeded } from '../../../lib/activitypub/videos'
17import { Notifier } from '../../../lib/notifier'
18import { Hooks } from '../../../lib/plugins/hooks'
19import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
20import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videosUpdateValidator } from '../../../middlewares'
21import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
22import { VideoModel } from '../../../models/video/video'
23import { openapiOperationDoc } from '@server/middlewares/doc'
24
25const lTags = loggerTagsFactory('api', 'video')
26const auditLogger = auditLoggerFactory('videos')
27const updateRouter = express.Router()
28
29const reqVideoFileUpdate = createReqFiles(
30 [ 'thumbnailfile', 'previewfile' ],
31 MIMETYPES.IMAGE.MIMETYPE_EXT,
32 {
33 thumbnailfile: CONFIG.STORAGE.TMP_DIR,
34 previewfile: CONFIG.STORAGE.TMP_DIR
35 }
36)
37
38updateRouter.put('/:id',
39 openapiOperationDoc({ operationId: 'putVideo' }),
40 authenticate,
41 reqVideoFileUpdate,
42 asyncMiddleware(videosUpdateValidator),
43 asyncRetryTransactionMiddleware(updateVideo)
44)
45
46// ---------------------------------------------------------------------------
47
48export {
49 updateRouter
50}
51
52// ---------------------------------------------------------------------------
53
54export async function updateVideo (req: express.Request, res: express.Response) {
55 const videoInstance = res.locals.videoAll
56 const videoFieldsSave = videoInstance.toJSON()
57 const oldVideoAuditView = new VideoAuditView(videoInstance.toFormattedDetailsJSON())
58 const videoInfoToUpdate: VideoUpdate = req.body
59
60 const wasConfidentialVideo = videoInstance.isConfidential()
61 const hadPrivacyForFederation = videoInstance.hasPrivacyForFederation()
62
63 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
64 video: videoInstance,
65 files: req.files,
66 fallback: () => Promise.resolve(undefined),
67 automaticallyGenerated: false
68 })
69
70 try {
71 const videoInstanceUpdated = await sequelizeTypescript.transaction(async t => {
72 const sequelizeOptions = { transaction: t }
73 const oldVideoChannel = videoInstance.VideoChannel
74
75 const keysToUpdate: (keyof VideoUpdate & FilteredModelAttributes<VideoModel>)[] = [
76 'name',
77 'category',
78 'licence',
79 'language',
80 'nsfw',
81 'waitTranscoding',
82 'support',
83 'description',
84 'commentsEnabled',
85 'downloadEnabled'
86 ]
87
88 for (const key of keysToUpdate) {
89 if (videoInfoToUpdate[key] !== undefined) videoInstance.set(key, videoInfoToUpdate[key])
90 }
91
92 if (videoInfoToUpdate.originallyPublishedAt !== undefined && videoInfoToUpdate.originallyPublishedAt !== null) {
93 videoInstance.originallyPublishedAt = new Date(videoInfoToUpdate.originallyPublishedAt)
94 }
95
96 // Privacy update?
97 let isNewVideo = false
98 if (videoInfoToUpdate.privacy !== undefined) {
99 isNewVideo = await updateVideoPrivacy({ videoInstance, videoInfoToUpdate, hadPrivacyForFederation, transaction: t })
100 }
101
102 const videoInstanceUpdated = await videoInstance.save(sequelizeOptions) as MVideoFullLight
103
104 // Thumbnail & preview updates?
105 if (thumbnailModel) await videoInstanceUpdated.addAndSaveThumbnail(thumbnailModel, t)
106 if (previewModel) await videoInstanceUpdated.addAndSaveThumbnail(previewModel, t)
107
108 // Video tags update?
109 if (videoInfoToUpdate.tags !== undefined) {
110 await setVideoTags({ video: videoInstanceUpdated, tags: videoInfoToUpdate.tags, transaction: t })
111 }
112
113 // Video channel update?
114 if (res.locals.videoChannel && videoInstanceUpdated.channelId !== res.locals.videoChannel.id) {
115 await videoInstanceUpdated.$set('VideoChannel', res.locals.videoChannel, { transaction: t })
116 videoInstanceUpdated.VideoChannel = res.locals.videoChannel
117
118 if (hadPrivacyForFederation === true) await changeVideoChannelShare(videoInstanceUpdated, oldVideoChannel, t)
119 }
120
121 // Schedule an update in the future?
122 await updateSchedule(videoInstanceUpdated, videoInfoToUpdate, t)
123
124 await autoBlacklistVideoIfNeeded({
125 video: videoInstanceUpdated,
126 user: res.locals.oauth.token.User,
127 isRemote: false,
128 isNew: false,
129 transaction: t
130 })
131
132 await federateVideoIfNeeded(videoInstanceUpdated, isNewVideo, t)
133
134 auditLogger.update(
135 getAuditIdFromRes(res),
136 new VideoAuditView(videoInstanceUpdated.toFormattedDetailsJSON()),
137 oldVideoAuditView
138 )
139 logger.info('Video with name %s and uuid %s updated.', videoInstance.name, videoInstance.uuid, lTags(videoInstance.uuid))
140
141 return videoInstanceUpdated
142 })
143
144 if (wasConfidentialVideo) {
145 Notifier.Instance.notifyOnNewVideoIfNeeded(videoInstanceUpdated)
146 }
147
148 Hooks.runAction('action:api.video.updated', { video: videoInstanceUpdated, body: req.body })
149 } catch (err) {
150 // Force fields we want to update
151 // If the transaction is retried, sequelize will think the object has not changed
152 // So it will skip the SQL request, even if the last one was ROLLBACKed!
153 resetSequelizeInstance(videoInstance, videoFieldsSave)
154
155 throw err
156 }
157
158 return res.type('json')
159 .status(HttpStatusCode.NO_CONTENT_204)
160 .end()
161}
162
163async function updateVideoPrivacy (options: {
164 videoInstance: MVideoFullLight
165 videoInfoToUpdate: VideoUpdate
166 hadPrivacyForFederation: boolean
167 transaction: Transaction
168}) {
169 const { videoInstance, videoInfoToUpdate, hadPrivacyForFederation, transaction } = options
170 const isNewVideo = videoInstance.isNewVideo(videoInfoToUpdate.privacy)
171
172 const newPrivacy = parseInt(videoInfoToUpdate.privacy.toString(), 10)
173 videoInstance.setPrivacy(newPrivacy)
174
175 // Unfederate the video if the new privacy is not compatible with federation
176 if (hadPrivacyForFederation && !videoInstance.hasPrivacyForFederation()) {
177 await VideoModel.sendDelete(videoInstance, { transaction })
178 }
179
180 return isNewVideo
181}
182
183function updateSchedule (videoInstance: MVideoFullLight, videoInfoToUpdate: VideoUpdate, transaction: Transaction) {
184 if (videoInfoToUpdate.scheduleUpdate) {
185 return ScheduleVideoUpdateModel.upsert({
186 videoId: videoInstance.id,
187 updateAt: new Date(videoInfoToUpdate.scheduleUpdate.updateAt),
188 privacy: videoInfoToUpdate.scheduleUpdate.privacy || null
189 }, { transaction })
190 } else if (videoInfoToUpdate.scheduleUpdate === null) {
191 return ScheduleVideoUpdateModel.deleteByVideoId(videoInstance.id, transaction)
192 }
193}
diff --git a/server/controllers/api/videos/upload.ts b/server/controllers/api/videos/upload.ts
new file mode 100644
index 000000000..bcd21ac99
--- /dev/null
+++ b/server/controllers/api/videos/upload.ts
@@ -0,0 +1,278 @@
1import * as express from 'express'
2import { move } from 'fs-extra'
3import { getLowercaseExtension } from '@server/helpers/core-utils'
4import { deleteResumableUploadMetaFile, getResumableUploadPath } from '@server/helpers/upload'
5import { uuidToShort } from '@server/helpers/uuid'
6import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
7import { getLocalVideoActivityPubUrl } from '@server/lib/activitypub/url'
8import { addOptimizeOrMergeAudioJob, buildLocalVideoFromReq, buildVideoThumbnailsFromReq, setVideoTags } from '@server/lib/video'
9import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
10import { openapiOperationDoc } from '@server/middlewares/doc'
11import { MVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
12import { uploadx } from '@uploadx/core'
13import { VideoCreate, VideoState } from '../../../../shared'
14import { HttpStatusCode } from '../../../../shared/core-utils/miscs'
15import { auditLoggerFactory, getAuditIdFromRes, VideoAuditView } from '../../../helpers/audit-logger'
16import { retryTransactionWrapper } from '../../../helpers/database-utils'
17import { createReqFiles } from '../../../helpers/express-utils'
18import { getMetadataFromFile, getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils'
19import { logger, loggerTagsFactory } from '../../../helpers/logger'
20import { CONFIG } from '../../../initializers/config'
21import { DEFAULT_AUDIO_RESOLUTION, MIMETYPES } from '../../../initializers/constants'
22import { sequelizeTypescript } from '../../../initializers/database'
23import { federateVideoIfNeeded } from '../../../lib/activitypub/videos'
24import { Notifier } from '../../../lib/notifier'
25import { Hooks } from '../../../lib/plugins/hooks'
26import { generateVideoMiniature } from '../../../lib/thumbnail'
27import { autoBlacklistVideoIfNeeded } from '../../../lib/video-blacklist'
28import {
29 asyncMiddleware,
30 asyncRetryTransactionMiddleware,
31 authenticate,
32 videosAddLegacyValidator,
33 videosAddResumableInitValidator,
34 videosAddResumableValidator
35} from '../../../middlewares'
36import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
37import { VideoModel } from '../../../models/video/video'
38import { VideoFileModel } from '../../../models/video/video-file'
39
40const lTags = loggerTagsFactory('api', 'video')
41const auditLogger = auditLoggerFactory('videos')
42const uploadRouter = express.Router()
43const uploadxMiddleware = uploadx.upload({ directory: getResumableUploadPath() })
44
45const reqVideoFileAdd = createReqFiles(
46 [ 'videofile', 'thumbnailfile', 'previewfile' ],
47 Object.assign({}, MIMETYPES.VIDEO.MIMETYPE_EXT, MIMETYPES.IMAGE.MIMETYPE_EXT),
48 {
49 videofile: CONFIG.STORAGE.TMP_DIR,
50 thumbnailfile: CONFIG.STORAGE.TMP_DIR,
51 previewfile: CONFIG.STORAGE.TMP_DIR
52 }
53)
54
55const reqVideoFileAddResumable = createReqFiles(
56 [ 'thumbnailfile', 'previewfile' ],
57 MIMETYPES.IMAGE.MIMETYPE_EXT,
58 {
59 thumbnailfile: getResumableUploadPath(),
60 previewfile: getResumableUploadPath()
61 }
62)
63
64uploadRouter.post('/upload',
65 openapiOperationDoc({ operationId: 'uploadLegacy' }),
66 authenticate,
67 reqVideoFileAdd,
68 asyncMiddleware(videosAddLegacyValidator),
69 asyncRetryTransactionMiddleware(addVideoLegacy)
70)
71
72uploadRouter.post('/upload-resumable',
73 openapiOperationDoc({ operationId: 'uploadResumableInit' }),
74 authenticate,
75 reqVideoFileAddResumable,
76 asyncMiddleware(videosAddResumableInitValidator),
77 uploadxMiddleware
78)
79
80uploadRouter.delete('/upload-resumable',
81 authenticate,
82 uploadxMiddleware
83)
84
85uploadRouter.put('/upload-resumable',
86 openapiOperationDoc({ operationId: 'uploadResumable' }),
87 authenticate,
88 uploadxMiddleware, // uploadx doesn't use call next() before the file upload completes
89 asyncMiddleware(videosAddResumableValidator),
90 asyncMiddleware(addVideoResumable)
91)
92
93// ---------------------------------------------------------------------------
94
95export {
96 uploadRouter
97}
98
99// ---------------------------------------------------------------------------
100
101export async function addVideoLegacy (req: express.Request, res: express.Response) {
102 // Uploading the video could be long
103 // Set timeout to 10 minutes, as Express's default is 2 minutes
104 req.setTimeout(1000 * 60 * 10, () => {
105 logger.error('Video upload has timed out.')
106 return res.fail({
107 status: HttpStatusCode.REQUEST_TIMEOUT_408,
108 message: 'Video upload has timed out.'
109 })
110 })
111
112 const videoPhysicalFile = req.files['videofile'][0]
113 const videoInfo: VideoCreate = req.body
114 const files = req.files
115
116 return addVideo({ res, videoPhysicalFile, videoInfo, files })
117}
118
119export async function addVideoResumable (_req: express.Request, res: express.Response) {
120 const videoPhysicalFile = res.locals.videoFileResumable
121 const videoInfo = videoPhysicalFile.metadata
122 const files = { previewfile: videoInfo.previewfile }
123
124 // Don't need the meta file anymore
125 await deleteResumableUploadMetaFile(videoPhysicalFile.path)
126
127 return addVideo({ res, videoPhysicalFile, videoInfo, files })
128}
129
130async function addVideo (options: {
131 res: express.Response
132 videoPhysicalFile: express.VideoUploadFile
133 videoInfo: VideoCreate
134 files: express.UploadFiles
135}) {
136 const { res, videoPhysicalFile, videoInfo, files } = options
137 const videoChannel = res.locals.videoChannel
138 const user = res.locals.oauth.token.User
139
140 const videoData = buildLocalVideoFromReq(videoInfo, videoChannel.id)
141
142 videoData.state = CONFIG.TRANSCODING.ENABLED
143 ? VideoState.TO_TRANSCODE
144 : VideoState.PUBLISHED
145
146 videoData.duration = videoPhysicalFile.duration // duration was added by a previous middleware
147
148 const video = new VideoModel(videoData) as MVideoFullLight
149 video.VideoChannel = videoChannel
150 video.url = getLocalVideoActivityPubUrl(video) // We use the UUID, so set the URL after building the object
151
152 const videoFile = await buildNewFile(video, videoPhysicalFile)
153
154 // Move physical file
155 const destination = getVideoFilePath(video, videoFile)
156 await move(videoPhysicalFile.path, destination)
157 // This is important in case if there is another attempt in the retry process
158 videoPhysicalFile.filename = getVideoFilePath(video, videoFile)
159 videoPhysicalFile.path = destination
160
161 const [ thumbnailModel, previewModel ] = await buildVideoThumbnailsFromReq({
162 video,
163 files,
164 fallback: type => generateVideoMiniature({ video, videoFile, type })
165 })
166
167 const { videoCreated } = await sequelizeTypescript.transaction(async t => {
168 const sequelizeOptions = { transaction: t }
169
170 const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight
171
172 await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
173 await videoCreated.addAndSaveThumbnail(previewModel, t)
174
175 // Do not forget to add video channel information to the created video
176 videoCreated.VideoChannel = res.locals.videoChannel
177
178 videoFile.videoId = video.id
179 await videoFile.save(sequelizeOptions)
180
181 video.VideoFiles = [ videoFile ]
182
183 await setVideoTags({ video, tags: videoInfo.tags, transaction: t })
184
185 // Schedule an update in the future?
186 if (videoInfo.scheduleUpdate) {
187 await ScheduleVideoUpdateModel.create({
188 videoId: video.id,
189 updateAt: new Date(videoInfo.scheduleUpdate.updateAt),
190 privacy: videoInfo.scheduleUpdate.privacy || null
191 }, sequelizeOptions)
192 }
193
194 // Channel has a new content, set as updated
195 await videoCreated.VideoChannel.setAsUpdated(t)
196
197 await autoBlacklistVideoIfNeeded({
198 video,
199 user,
200 isRemote: false,
201 isNew: true,
202 transaction: t
203 })
204
205 auditLogger.create(getAuditIdFromRes(res), new VideoAuditView(videoCreated.toFormattedDetailsJSON()))
206 logger.info('Video with name %s and uuid %s created.', videoInfo.name, videoCreated.uuid, lTags(videoCreated.uuid))
207
208 return { videoCreated }
209 })
210
211 createTorrentFederate(video, videoFile)
212
213 if (video.state === VideoState.TO_TRANSCODE) {
214 await addOptimizeOrMergeAudioJob(videoCreated, videoFile, user)
215 }
216
217 Hooks.runAction('action:api.video.uploaded', { video: videoCreated })
218
219 return res.json({
220 video: {
221 id: videoCreated.id,
222 shortUUID: uuidToShort(videoCreated.uuid),
223 uuid: videoCreated.uuid
224 }
225 })
226}
227
228async function buildNewFile (video: MVideo, videoPhysicalFile: express.VideoUploadFile) {
229 const videoFile = new VideoFileModel({
230 extname: getLowercaseExtension(videoPhysicalFile.filename),
231 size: videoPhysicalFile.size,
232 videoStreamingPlaylistId: null,
233 metadata: await getMetadataFromFile(videoPhysicalFile.path)
234 })
235
236 if (videoFile.isAudio()) {
237 videoFile.resolution = DEFAULT_AUDIO_RESOLUTION
238 } else {
239 videoFile.fps = await getVideoFileFPS(videoPhysicalFile.path)
240 videoFile.resolution = (await getVideoFileResolution(videoPhysicalFile.path)).videoFileResolution
241 }
242
243 videoFile.filename = generateVideoFilename(video, false, videoFile.resolution, videoFile.extname)
244
245 return videoFile
246}
247
248async function createTorrentAndSetInfoHashAsync (video: MVideo, fileArg: MVideoFile) {
249 await createTorrentAndSetInfoHash(video, fileArg)
250
251 // Refresh videoFile because the createTorrentAndSetInfoHash could be long
252 const refreshedFile = await VideoFileModel.loadWithVideo(fileArg.id)
253 // File does not exist anymore, remove the generated torrent
254 if (!refreshedFile) return fileArg.removeTorrent()
255
256 refreshedFile.infoHash = fileArg.infoHash
257 refreshedFile.torrentFilename = fileArg.torrentFilename
258
259 return refreshedFile.save()
260}
261
262function createTorrentFederate (video: MVideoFullLight, videoFile: MVideoFile): void {
263 // Create the torrent file in async way because it could be long
264 createTorrentAndSetInfoHashAsync(video, videoFile)
265 .catch(err => logger.error('Cannot create torrent file for video %s', video.url, { err, ...lTags(video.uuid) }))
266 .then(() => VideoModel.loadAndPopulateAccountAndServerAndTags(video.id))
267 .then(refreshedVideo => {
268 if (!refreshedVideo) return
269
270 // Only federate and notify after the torrent creation
271 Notifier.Instance.notifyOnNewVideoIfNeeded(refreshedVideo)
272
273 return retryTransactionWrapper(() => {
274 return sequelizeTypescript.transaction(t => federateVideoIfNeeded(refreshedVideo, true, t))
275 })
276 })
277 .catch(err => logger.error('Cannot federate or notify video creation %s', video.url, { err, ...lTags(video.uuid) }))
278}
diff --git a/server/controllers/api/videos/watching.ts b/server/controllers/api/videos/watching.ts
index 627f12aa9..8b15525aa 100644
--- a/server/controllers/api/videos/watching.ts
+++ b/server/controllers/api/videos/watching.ts
@@ -1,12 +1,19 @@
1import * as express from 'express' 1import * as express from 'express'
2import { UserWatchingVideo } from '../../../../shared' 2import { UserWatchingVideo } from '../../../../shared'
3import { asyncMiddleware, asyncRetryTransactionMiddleware, authenticate, videoWatchingValidator } from '../../../middlewares' 3import {
4import { UserVideoHistoryModel } from '../../../models/account/user-video-history' 4 asyncMiddleware,
5 asyncRetryTransactionMiddleware,
6 authenticate,
7 openapiOperationDoc,
8 videoWatchingValidator
9} from '../../../middlewares'
10import { UserVideoHistoryModel } from '../../../models/user/user-video-history'
5import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 11import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
6 12
7const watchingRouter = express.Router() 13const watchingRouter = express.Router()
8 14
9watchingRouter.put('/:videoId/watching', 15watchingRouter.put('/:videoId/watching',
16 openapiOperationDoc({ operationId: 'setProgress' }),
10 authenticate, 17 authenticate,
11 asyncMiddleware(videoWatchingValidator), 18 asyncMiddleware(videoWatchingValidator),
12 asyncRetryTransactionMiddleware(userWatchVideo) 19 asyncRetryTransactionMiddleware(userWatchVideo)
diff --git a/server/controllers/bots.ts b/server/controllers/bots.ts
index 8d1fa72f3..9e92063d4 100644
--- a/server/controllers/bots.ts
+++ b/server/controllers/bots.ts
@@ -75,7 +75,7 @@ async function getSitemapLocalVideoUrls () {
75 }) 75 })
76 76
77 return data.map(v => ({ 77 return data.map(v => ({
78 url: WEBSERVER.URL + '/videos/watch/' + v.uuid, 78 url: WEBSERVER.URL + '/w/' + v.uuid,
79 video: [ 79 video: [
80 { 80 {
81 title: v.name, 81 title: v.name,
diff --git a/server/controllers/client.ts b/server/controllers/client.ts
index 022a17ff4..eb1ee6cbd 100644
--- a/server/controllers/client.ts
+++ b/server/controllers/client.ts
@@ -19,10 +19,11 @@ const testEmbedPath = join(distPath, 'standalone', 'videos', 'test-embed.html')
19 19
20// Special route that add OpenGraph and oEmbed tags 20// Special route that add OpenGraph and oEmbed tags
21// Do not use a template engine for a so little thing 21// Do not use a template engine for a so little thing
22clientsRouter.use('/videos/watch/playlist/:id', asyncMiddleware(generateWatchPlaylistHtmlPage)) 22clientsRouter.use([ '/w/p/:id', '/videos/watch/playlist/:id' ], asyncMiddleware(generateWatchPlaylistHtmlPage))
23clientsRouter.use('/videos/watch/:id', asyncMiddleware(generateWatchHtmlPage)) 23clientsRouter.use([ '/w/:id', '/videos/watch/:id' ], asyncMiddleware(generateWatchHtmlPage))
24clientsRouter.use('/accounts/:nameWithHost', asyncMiddleware(generateAccountHtmlPage)) 24clientsRouter.use([ '/accounts/:nameWithHost', '/a/:nameWithHost' ], asyncMiddleware(generateAccountHtmlPage))
25clientsRouter.use('/video-channels/:nameWithHost', asyncMiddleware(generateVideoChannelHtmlPage)) 25clientsRouter.use([ '/video-channels/:nameWithHost', '/c/:nameWithHost' ], asyncMiddleware(generateVideoChannelHtmlPage))
26clientsRouter.use('/@:nameWithHost', asyncMiddleware(generateActorHtmlPage))
26 27
27const embedMiddlewares = [ 28const embedMiddlewares = [
28 CONFIG.CSP.ENABLED 29 CONFIG.CSP.ENABLED
@@ -77,7 +78,7 @@ clientsRouter.use('/client', express.static(distPath, { maxAge: STATIC_MAX_AGE.C
77 78
78// 404 for static files not found 79// 404 for static files not found
79clientsRouter.use('/client/*', (req: express.Request, res: express.Response) => { 80clientsRouter.use('/client/*', (req: express.Request, res: express.Response) => {
80 res.sendStatus(HttpStatusCode.NOT_FOUND_404) 81 res.status(HttpStatusCode.NOT_FOUND_404).end()
81}) 82})
82 83
83// Always serve index client page (the client is a single page application, let it handle routing) 84// Always serve index client page (the client is a single page application, let it handle routing)
@@ -104,7 +105,7 @@ function serveServerTranslations (req: express.Request, res: express.Response) {
104 return res.sendFile(path, { maxAge: STATIC_MAX_AGE.SERVER }) 105 return res.sendFile(path, { maxAge: STATIC_MAX_AGE.SERVER })
105 } 106 }
106 107
107 return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 108 return res.status(HttpStatusCode.NOT_FOUND_404).end()
108} 109}
109 110
110async function generateEmbedHtmlPage (req: express.Request, res: express.Response) { 111async function generateEmbedHtmlPage (req: express.Request, res: express.Response) {
@@ -155,6 +156,12 @@ async function generateVideoChannelHtmlPage (req: express.Request, res: express.
155 return sendHTML(html, res) 156 return sendHTML(html, res)
156} 157}
157 158
159async function generateActorHtmlPage (req: express.Request, res: express.Response) {
160 const html = await ClientHtml.getActorHTMLPage(req.params.nameWithHost, req, res)
161
162 return sendHTML(html, res)
163}
164
158async function generateManifest (req: express.Request, res: express.Response) { 165async function generateManifest (req: express.Request, res: express.Response) {
159 const manifestPhysicalPath = join(root(), 'client', 'dist', 'manifest.webmanifest') 166 const manifestPhysicalPath = join(root(), 'client', 'dist', 'manifest.webmanifest')
160 const manifestJson = await readFile(manifestPhysicalPath, 'utf8') 167 const manifestJson = await readFile(manifestPhysicalPath, 'utf8')
diff --git a/server/controllers/download.ts b/server/controllers/download.ts
index 9a8194c5c..4293a32e2 100644
--- a/server/controllers/download.ts
+++ b/server/controllers/download.ts
@@ -41,7 +41,12 @@ export {
41 41
42async function downloadTorrent (req: express.Request, res: express.Response) { 42async function downloadTorrent (req: express.Request, res: express.Response) {
43 const result = await VideosTorrentCache.Instance.getFilePath(req.params.filename) 43 const result = await VideosTorrentCache.Instance.getFilePath(req.params.filename)
44 if (!result) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 44 if (!result) {
45 return res.fail({
46 status: HttpStatusCode.NOT_FOUND_404,
47 message: 'Torrent file not found'
48 })
49 }
45 50
46 const allowParameters = { torrentPath: result.path, downloadName: result.downloadName } 51 const allowParameters = { torrentPath: result.path, downloadName: result.downloadName }
47 52
@@ -60,7 +65,12 @@ async function downloadVideoFile (req: express.Request, res: express.Response) {
60 const video = res.locals.videoAll 65 const video = res.locals.videoAll
61 66
62 const videoFile = getVideoFile(req, video.VideoFiles) 67 const videoFile = getVideoFile(req, video.VideoFiles)
63 if (!videoFile) return res.status(HttpStatusCode.NOT_FOUND_404).end() 68 if (!videoFile) {
69 return res.fail({
70 status: HttpStatusCode.NOT_FOUND_404,
71 message: 'Video file not found'
72 })
73 }
64 74
65 const allowParameters = { video, videoFile } 75 const allowParameters = { video, videoFile }
66 76
@@ -81,7 +91,12 @@ async function downloadHLSVideoFile (req: express.Request, res: express.Response
81 if (!streamingPlaylist) return res.status(HttpStatusCode.NOT_FOUND_404).end 91 if (!streamingPlaylist) return res.status(HttpStatusCode.NOT_FOUND_404).end
82 92
83 const videoFile = getVideoFile(req, streamingPlaylist.VideoFiles) 93 const videoFile = getVideoFile(req, streamingPlaylist.VideoFiles)
84 if (!videoFile) return res.status(HttpStatusCode.NOT_FOUND_404).end() 94 if (!videoFile) {
95 return res.fail({
96 status: HttpStatusCode.NOT_FOUND_404,
97 message: 'Video file not found'
98 })
99 }
85 100
86 const allowParameters = { video, streamingPlaylist, videoFile } 101 const allowParameters = { video, streamingPlaylist, videoFile }
87 102
@@ -131,9 +146,11 @@ function isVideoDownloadAllowed (_object: {
131function checkAllowResult (res: express.Response, allowParameters: any, result?: AllowedResult) { 146function checkAllowResult (res: express.Response, allowParameters: any, result?: AllowedResult) {
132 if (!result || result.allowed !== true) { 147 if (!result || result.allowed !== true) {
133 logger.info('Download is not allowed.', { result, allowParameters }) 148 logger.info('Download is not allowed.', { result, allowParameters })
134 res.status(HttpStatusCode.FORBIDDEN_403)
135 .json({ error: result?.errorMessage || 'Refused download' })
136 149
150 res.fail({
151 status: HttpStatusCode.FORBIDDEN_403,
152 message: result?.errorMessage || 'Refused download'
153 })
137 return false 154 return false
138 } 155 }
139 156
diff --git a/server/controllers/feeds.ts b/server/controllers/feeds.ts
index f0717bbbc..435b12193 100644
--- a/server/controllers/feeds.ts
+++ b/server/controllers/feeds.ts
@@ -1,5 +1,6 @@
1import * as express from 'express' 1import * as express from 'express'
2import * as Feed from 'pfeed' 2import * as Feed from 'pfeed'
3import { getCategoryLabel } from '@server/models/video/formatter/video-format-utils'
3import { VideoFilter } from '../../shared/models/videos/video-query.type' 4import { VideoFilter } from '../../shared/models/videos/video-query.type'
4import { buildNSFWFilter } from '../helpers/express-utils' 5import { buildNSFWFilter } from '../helpers/express-utils'
5import { CONFIG } from '../initializers/config' 6import { CONFIG } from '../initializers/config'
@@ -286,14 +287,14 @@ function addVideosToFeed (feed, videos: VideoModel[]) {
286 if (video.category) { 287 if (video.category) {
287 categories.push({ 288 categories.push({
288 value: video.category, 289 value: video.category,
289 label: VideoModel.getCategoryLabel(video.category) 290 label: getCategoryLabel(video.category)
290 }) 291 })
291 } 292 }
292 293
293 feed.addItem({ 294 feed.addItem({
294 title: video.name, 295 title: video.name,
295 id: video.url, 296 id: video.url,
296 link: WEBSERVER.URL + '/videos/watch/' + video.uuid, 297 link: WEBSERVER.URL + '/w/' + video.uuid,
297 description: video.getTruncatedDescription(), 298 description: video.getTruncatedDescription(),
298 content: video.description, 299 content: video.description,
299 author: [ 300 author: [
diff --git a/server/controllers/lazy-static.ts b/server/controllers/lazy-static.ts
index 6f71fdb16..9a7dacba0 100644
--- a/server/controllers/lazy-static.ts
+++ b/server/controllers/lazy-static.ts
@@ -4,10 +4,10 @@ import { VideosTorrentCache } from '@server/lib/files-cache/videos-torrent-cache
4import { HttpStatusCode } from '../../shared/core-utils/miscs/http-error-codes' 4import { HttpStatusCode } from '../../shared/core-utils/miscs/http-error-codes'
5import { logger } from '../helpers/logger' 5import { logger } from '../helpers/logger'
6import { LAZY_STATIC_PATHS, STATIC_MAX_AGE } from '../initializers/constants' 6import { LAZY_STATIC_PATHS, STATIC_MAX_AGE } from '../initializers/constants'
7import { actorImagePathUnsafeCache, pushActorImageProcessInQueue } from '../lib/actor-image'
8import { VideosCaptionCache, VideosPreviewCache } from '../lib/files-cache' 7import { VideosCaptionCache, VideosPreviewCache } from '../lib/files-cache'
8import { actorImagePathUnsafeCache, pushActorImageProcessInQueue } from '../lib/local-actor'
9import { asyncMiddleware } from '../middlewares' 9import { asyncMiddleware } from '../middlewares'
10import { ActorImageModel } from '../models/account/actor-image' 10import { ActorImageModel } from '../models/actor/actor-image'
11 11
12const lazyStaticRouter = express.Router() 12const lazyStaticRouter = express.Router()
13 13
@@ -48,7 +48,7 @@ export {
48 48
49// --------------------------------------------------------------------------- 49// ---------------------------------------------------------------------------
50 50
51async function getActorImage (req: express.Request, res: express.Response) { 51async function getActorImage (req: express.Request, res: express.Response, next: express.NextFunction) {
52 const filename = req.params.filename 52 const filename = req.params.filename
53 53
54 if (actorImagePathUnsafeCache.has(filename)) { 54 if (actorImagePathUnsafeCache.has(filename)) {
@@ -56,10 +56,10 @@ async function getActorImage (req: express.Request, res: express.Response) {
56 } 56 }
57 57
58 const image = await ActorImageModel.loadByName(filename) 58 const image = await ActorImageModel.loadByName(filename)
59 if (!image) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 59 if (!image) return res.status(HttpStatusCode.NOT_FOUND_404).end()
60 60
61 if (image.onDisk === false) { 61 if (image.onDisk === false) {
62 if (!image.fileUrl) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 62 if (!image.fileUrl) return res.status(HttpStatusCode.NOT_FOUND_404).end()
63 63
64 logger.info('Lazy serve remote actor image %s.', image.fileUrl) 64 logger.info('Lazy serve remote actor image %s.', image.fileUrl)
65 65
@@ -67,7 +67,7 @@ async function getActorImage (req: express.Request, res: express.Response) {
67 await pushActorImageProcessInQueue({ filename: image.filename, fileUrl: image.fileUrl, type: image.type }) 67 await pushActorImageProcessInQueue({ filename: image.filename, fileUrl: image.fileUrl, type: image.type })
68 } catch (err) { 68 } catch (err) {
69 logger.warn('Cannot process remote actor image %s.', image.fileUrl, { err }) 69 logger.warn('Cannot process remote actor image %s.', image.fileUrl, { err })
70 return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 70 return res.status(HttpStatusCode.NOT_FOUND_404).end()
71 } 71 }
72 72
73 image.onDisk = true 73 image.onDisk = true
@@ -78,26 +78,42 @@ async function getActorImage (req: express.Request, res: express.Response) {
78 const path = image.getPath() 78 const path = image.getPath()
79 79
80 actorImagePathUnsafeCache.set(filename, path) 80 actorImagePathUnsafeCache.set(filename, path)
81 return res.sendFile(path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER }) 81
82 return res.sendFile(path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER }, (err: any) => {
83 if (!err) return
84
85 // It seems this actor image is not on the disk anymore
86 if (err.status === HttpStatusCode.NOT_FOUND_404 && !image.isOwned()) {
87 logger.error('Cannot lazy serve actor image %s.', filename, { err })
88
89 actorImagePathUnsafeCache.del(filename)
90
91 image.onDisk = false
92 image.save()
93 .catch(err => logger.error('Cannot save new actor image disk state.', { err }))
94 }
95
96 return next(err)
97 })
82} 98}
83 99
84async function getPreview (req: express.Request, res: express.Response) { 100async function getPreview (req: express.Request, res: express.Response) {
85 const result = await VideosPreviewCache.Instance.getFilePath(req.params.filename) 101 const result = await VideosPreviewCache.Instance.getFilePath(req.params.filename)
86 if (!result) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 102 if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
87 103
88 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER }) 104 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER })
89} 105}
90 106
91async function getVideoCaption (req: express.Request, res: express.Response) { 107async function getVideoCaption (req: express.Request, res: express.Response) {
92 const result = await VideosCaptionCache.Instance.getFilePath(req.params.filename) 108 const result = await VideosCaptionCache.Instance.getFilePath(req.params.filename)
93 if (!result) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 109 if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
94 110
95 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER }) 111 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.LAZY_SERVER })
96} 112}
97 113
98async function getTorrent (req: express.Request, res: express.Response) { 114async function getTorrent (req: express.Request, res: express.Response) {
99 const result = await VideosTorrentCache.Instance.getFilePath(req.params.filename) 115 const result = await VideosTorrentCache.Instance.getFilePath(req.params.filename)
100 if (!result) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 116 if (!result) return res.status(HttpStatusCode.NOT_FOUND_404).end()
101 117
102 // Torrents still use the old naming convention (video uuid + .torrent) 118 // Torrents still use the old naming convention (video uuid + .torrent)
103 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.SERVER }) 119 return res.sendFile(result.path, { maxAge: STATIC_MAX_AGE.SERVER })
diff --git a/server/controllers/live.ts b/server/controllers/live.ts
index ff48b0e21..f2686fb23 100644
--- a/server/controllers/live.ts
+++ b/server/controllers/live.ts
@@ -1,7 +1,7 @@
1import * as cors from 'cors' 1import * as cors from 'cors'
2import * as express from 'express' 2import * as express from 'express'
3import { mapToJSON } from '@server/helpers/core-utils' 3import { mapToJSON } from '@server/helpers/core-utils'
4import { LiveManager } from '@server/lib/live-manager' 4import { LiveSegmentShaStore } from '@server/lib/live'
5import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' 5import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
6 6
7const liveRouter = express.Router() 7const liveRouter = express.Router()
@@ -22,10 +22,10 @@ export {
22function getSegmentsSha256 (req: express.Request, res: express.Response) { 22function getSegmentsSha256 (req: express.Request, res: express.Response) {
23 const videoUUID = req.params.videoUUID 23 const videoUUID = req.params.videoUUID
24 24
25 const result = LiveManager.Instance.getSegmentsSha256(videoUUID) 25 const result = LiveSegmentShaStore.Instance.getSegmentsSha256(videoUUID)
26 26
27 if (!result) { 27 if (!result) {
28 return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 28 return res.status(HttpStatusCode.NOT_FOUND_404).end()
29 } 29 }
30 30
31 return res.json(mapToJSON(result)) 31 return res.json(mapToJSON(result))
diff --git a/server/controllers/plugins.ts b/server/controllers/plugins.ts
index 105f51518..7213e3f15 100644
--- a/server/controllers/plugins.ts
+++ b/server/controllers/plugins.ts
@@ -100,7 +100,7 @@ function getPluginTranslations (req: express.Request, res: express.Response) {
100 return res.json(json) 100 return res.json(json)
101 } 101 }
102 102
103 return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 103 return res.status(HttpStatusCode.NOT_FOUND_404).end()
104} 104}
105 105
106function servePluginStaticDirectory (req: express.Request, res: express.Response) { 106function servePluginStaticDirectory (req: express.Request, res: express.Response) {
@@ -110,7 +110,7 @@ function servePluginStaticDirectory (req: express.Request, res: express.Response
110 const [ directory, ...file ] = staticEndpoint.split('/') 110 const [ directory, ...file ] = staticEndpoint.split('/')
111 111
112 const staticPath = plugin.staticDirs[directory] 112 const staticPath = plugin.staticDirs[directory]
113 if (!staticPath) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 113 if (!staticPath) return res.status(HttpStatusCode.NOT_FOUND_404).end()
114 114
115 const filepath = file.join('/') 115 const filepath = file.join('/')
116 return res.sendFile(join(plugin.path, staticPath, filepath), sendFileOptions) 116 return res.sendFile(join(plugin.path, staticPath, filepath), sendFileOptions)
@@ -120,7 +120,7 @@ function servePluginCustomRoutes (req: express.Request, res: express.Response, n
120 const plugin: RegisteredPlugin = res.locals.registeredPlugin 120 const plugin: RegisteredPlugin = res.locals.registeredPlugin
121 const router = PluginManager.Instance.getRouter(plugin.npmName) 121 const router = PluginManager.Instance.getRouter(plugin.npmName)
122 122
123 if (!router) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 123 if (!router) return res.status(HttpStatusCode.NOT_FOUND_404).end()
124 124
125 return router(req, res, next) 125 return router(req, res, next)
126} 126}
@@ -130,7 +130,7 @@ function servePluginClientScripts (req: express.Request, res: express.Response)
130 const staticEndpoint = req.params.staticEndpoint 130 const staticEndpoint = req.params.staticEndpoint
131 131
132 const file = plugin.clientScripts[staticEndpoint] 132 const file = plugin.clientScripts[staticEndpoint]
133 if (!file) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 133 if (!file) return res.status(HttpStatusCode.NOT_FOUND_404).end()
134 134
135 return res.sendFile(join(plugin.path, staticEndpoint), sendFileOptions) 135 return res.sendFile(join(plugin.path, staticEndpoint), sendFileOptions)
136} 136}
@@ -140,7 +140,7 @@ function serveThemeCSSDirectory (req: express.Request, res: express.Response) {
140 const staticEndpoint = req.params.staticEndpoint 140 const staticEndpoint = req.params.staticEndpoint
141 141
142 if (plugin.css.includes(staticEndpoint) === false) { 142 if (plugin.css.includes(staticEndpoint) === false) {
143 return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 143 return res.status(HttpStatusCode.NOT_FOUND_404).end()
144 } 144 }
145 145
146 return res.sendFile(join(plugin.path, staticEndpoint), sendFileOptions) 146 return res.sendFile(join(plugin.path, staticEndpoint), sendFileOptions)
diff --git a/server/controllers/services.ts b/server/controllers/services.ts
index 189e1651b..8c0af9ff7 100644
--- a/server/controllers/services.ts
+++ b/server/controllers/services.ts
@@ -78,17 +78,18 @@ function buildOEmbed (options: {
78 const maxWidth = parseInt(req.query.maxwidth, 10) 78 const maxWidth = parseInt(req.query.maxwidth, 10)
79 79
80 const embedUrl = webserverUrl + embedPath 80 const embedUrl = webserverUrl + embedPath
81 let embedWidth = EMBED_SIZE.width
82 let embedHeight = EMBED_SIZE.height
83 const embedTitle = escapeHTML(title) 81 const embedTitle = escapeHTML(title)
84 82
85 let thumbnailUrl = previewPath 83 let thumbnailUrl = previewPath
86 ? webserverUrl + previewPath 84 ? webserverUrl + previewPath
87 : undefined 85 : undefined
88 86
89 if (maxHeight < embedHeight) embedHeight = maxHeight 87 let embedWidth = EMBED_SIZE.width
90 if (maxWidth < embedWidth) embedWidth = maxWidth 88 if (maxWidth < embedWidth) embedWidth = maxWidth
91 89
90 let embedHeight = EMBED_SIZE.height
91 if (maxHeight < embedHeight) embedHeight = maxHeight
92
92 // Our thumbnail is too big for the consumer 93 // Our thumbnail is too big for the consumer
93 if ( 94 if (
94 (maxHeight !== undefined && maxHeight < previewSize.height) || 95 (maxHeight !== undefined && maxHeight < previewSize.height) ||
diff --git a/server/controllers/static.ts b/server/controllers/static.ts
index 8d9003a3e..35e024dda 100644
--- a/server/controllers/static.ts
+++ b/server/controllers/static.ts
@@ -2,9 +2,9 @@ import * as cors from 'cors'
2import * as express from 'express' 2import * as express from 'express'
3import { join } from 'path' 3import { join } from 'path'
4import { serveIndexHTML } from '@server/lib/client-html' 4import { serveIndexHTML } from '@server/lib/client-html'
5import { getRegisteredPlugins, getRegisteredThemes } from '@server/lib/config' 5import { ServerConfigManager } from '@server/lib/server-config-manager'
6import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes' 6import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
7import { HttpNodeinfoDiasporaSoftwareNsSchema20 } from '../../shared/models/nodeinfo' 7import { HttpNodeinfoDiasporaSoftwareNsSchema20 } from '../../shared/models/nodeinfo/nodeinfo.model'
8import { root } from '../helpers/core-utils' 8import { root } from '../helpers/core-utils'
9import { CONFIG, isEmailEnabled } from '../initializers/config' 9import { CONFIG, isEmailEnabled } from '../initializers/config'
10import { 10import {
@@ -18,10 +18,9 @@ import {
18 WEBSERVER 18 WEBSERVER
19} from '../initializers/constants' 19} from '../initializers/constants'
20import { getThemeOrDefault } from '../lib/plugins/theme-utils' 20import { getThemeOrDefault } from '../lib/plugins/theme-utils'
21import { getEnabledResolutions } from '../lib/video-transcoding'
22import { asyncMiddleware } from '../middlewares' 21import { asyncMiddleware } from '../middlewares'
23import { cacheRoute } from '../middlewares/cache' 22import { cacheRoute } from '../middlewares/cache'
24import { UserModel } from '../models/account/user' 23import { UserModel } from '../models/user/user'
25import { VideoModel } from '../models/video/video' 24import { VideoModel } from '../models/video/video'
26import { VideoCommentModel } from '../models/video/video-comment' 25import { VideoCommentModel } from '../models/video/video-comment'
27 26
@@ -161,143 +160,145 @@ async function generateNodeinfo (req: express.Request, res: express.Response) {
161 const { totalVideos } = await VideoModel.getStats() 160 const { totalVideos } = await VideoModel.getStats()
162 const { totalLocalVideoComments } = await VideoCommentModel.getStats() 161 const { totalLocalVideoComments } = await VideoCommentModel.getStats()
163 const { totalUsers, totalMonthlyActiveUsers, totalHalfYearActiveUsers } = await UserModel.getStats() 162 const { totalUsers, totalMonthlyActiveUsers, totalHalfYearActiveUsers } = await UserModel.getStats()
164 let json = {}
165 163
166 if (req.params.version && (req.params.version === '2.0')) { 164 if (!req.params.version || req.params.version !== '2.0') {
167 json = { 165 return res.fail({
168 version: '2.0', 166 status: HttpStatusCode.NOT_FOUND_404,
169 software: { 167 message: 'Nodeinfo schema version not handled'
170 name: 'peertube', 168 })
171 version: PEERTUBE_VERSION 169 }
170
171 const json = {
172 version: '2.0',
173 software: {
174 name: 'peertube',
175 version: PEERTUBE_VERSION
176 },
177 protocols: [
178 'activitypub'
179 ],
180 services: {
181 inbound: [],
182 outbound: [
183 'atom1.0',
184 'rss2.0'
185 ]
186 },
187 openRegistrations: CONFIG.SIGNUP.ENABLED,
188 usage: {
189 users: {
190 total: totalUsers,
191 activeMonth: totalMonthlyActiveUsers,
192 activeHalfyear: totalHalfYearActiveUsers
172 }, 193 },
173 protocols: [ 194 localPosts: totalVideos,
174 'activitypub' 195 localComments: totalLocalVideoComments
175 ], 196 },
176 services: { 197 metadata: {
177 inbound: [], 198 taxonomy: {
178 outbound: [ 199 postsName: 'Videos'
179 'atom1.0',
180 'rss2.0'
181 ]
182 }, 200 },
183 openRegistrations: CONFIG.SIGNUP.ENABLED, 201 nodeName: CONFIG.INSTANCE.NAME,
184 usage: { 202 nodeDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
185 users: { 203 nodeConfig: {
186 total: totalUsers, 204 search: {
187 activeMonth: totalMonthlyActiveUsers, 205 remoteUri: {
188 activeHalfyear: totalHalfYearActiveUsers 206 users: CONFIG.SEARCH.REMOTE_URI.USERS,
207 anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
208 }
189 }, 209 },
190 localPosts: totalVideos, 210 plugin: {
191 localComments: totalLocalVideoComments 211 registered: ServerConfigManager.Instance.getRegisteredPlugins()
192 },
193 metadata: {
194 taxonomy: {
195 postsName: 'Videos'
196 }, 212 },
197 nodeName: CONFIG.INSTANCE.NAME, 213 theme: {
198 nodeDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION, 214 registered: ServerConfigManager.Instance.getRegisteredThemes(),
199 nodeConfig: { 215 default: getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
200 search: { 216 },
201 remoteUri: { 217 email: {
202 users: CONFIG.SEARCH.REMOTE_URI.USERS, 218 enabled: isEmailEnabled()
203 anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS 219 },
204 } 220 contactForm: {
205 }, 221 enabled: CONFIG.CONTACT_FORM.ENABLED
206 plugin: { 222 },
207 registered: getRegisteredPlugins() 223 transcoding: {
208 }, 224 hls: {
209 theme: { 225 enabled: CONFIG.TRANSCODING.HLS.ENABLED
210 registered: getRegisteredThemes(),
211 default: getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
212 },
213 email: {
214 enabled: isEmailEnabled()
215 }, 226 },
216 contactForm: { 227 webtorrent: {
217 enabled: CONFIG.CONTACT_FORM.ENABLED 228 enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
218 }, 229 },
230 enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('vod')
231 },
232 live: {
233 enabled: CONFIG.LIVE.ENABLED,
219 transcoding: { 234 transcoding: {
220 hls: { 235 enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
221 enabled: CONFIG.TRANSCODING.HLS.ENABLED 236 enabledResolutions: ServerConfigManager.Instance.getEnabledResolutions('live')
222 }, 237 }
223 webtorrent: { 238 },
224 enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED 239 import: {
240 videos: {
241 http: {
242 enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED
225 }, 243 },
226 enabledResolutions: getEnabledResolutions('vod') 244 torrent: {
227 }, 245 enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED
228 live: {
229 enabled: CONFIG.LIVE.ENABLED,
230 transcoding: {
231 enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
232 enabledResolutions: getEnabledResolutions('live')
233 }
234 },
235 import: {
236 videos: {
237 http: {
238 enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED
239 },
240 torrent: {
241 enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED
242 }
243 }
244 },
245 autoBlacklist: {
246 videos: {
247 ofUsers: {
248 enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED
249 }
250 } 246 }
251 }, 247 }
252 avatar: { 248 },
253 file: { 249 autoBlacklist: {
254 size: { 250 videos: {
255 max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max 251 ofUsers: {
256 }, 252 enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED
257 extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
258 } 253 }
259 }, 254 }
260 video: { 255 },
261 image: { 256 avatar: {
262 extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME, 257 file: {
263 size: { 258 size: {
264 max: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max 259 max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
265 }
266 }, 260 },
267 file: { 261 extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
268 extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME 262 }
269 } 263 },
270 }, 264 video: {
271 videoCaption: { 265 image: {
272 file: { 266 extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME,
273 size: { 267 size: {
274 max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max 268 max: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max
275 },
276 extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
277 }
278 },
279 user: {
280 videoQuota: CONFIG.USER.VIDEO_QUOTA,
281 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
282 },
283 trending: {
284 videos: {
285 intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
286 } 269 }
287 }, 270 },
288 tracker: { 271 file: {
289 enabled: CONFIG.TRACKER.ENABLED 272 extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME
273 }
274 },
275 videoCaption: {
276 file: {
277 size: {
278 max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
279 },
280 extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
290 } 281 }
282 },
283 user: {
284 videoQuota: CONFIG.USER.VIDEO_QUOTA,
285 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
286 },
287 trending: {
288 videos: {
289 intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS
290 }
291 },
292 tracker: {
293 enabled: CONFIG.TRACKER.ENABLED
291 } 294 }
292 } 295 }
293 } as HttpNodeinfoDiasporaSoftwareNsSchema20 296 }
294 res.contentType('application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"') 297 } as HttpNodeinfoDiasporaSoftwareNsSchema20
295 } else {
296 json = { error: 'Nodeinfo schema version not handled' }
297 res.status(HttpStatusCode.NOT_FOUND_404)
298 }
299 298
300 return res.send(json).end() 299 res.contentType('application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"')
300 .send(json)
301 .end()
301} 302}
302 303
303function getCup (req: express.Request, res: express.Response, next: express.NextFunction) { 304function getCup (req: express.Request, res: express.Response, next: express.NextFunction) {
diff --git a/server/helpers/actor.ts b/server/helpers/actor.ts
deleted file mode 100644
index a60d3ed5d..000000000
--- a/server/helpers/actor.ts
+++ /dev/null
@@ -1,16 +0,0 @@
1
2import { ActorModel } from '../models/activitypub/actor'
3import { MActorAccountChannelId, MActorFull } from '../types/models'
4
5type ActorFetchByUrlType = 'all' | 'association-ids'
6
7function fetchActorByUrl (url: string, fetchType: ActorFetchByUrlType): Promise<MActorFull | MActorAccountChannelId> {
8 if (fetchType === 'all') return ActorModel.loadByUrlAndPopulateAccountAndChannel(url)
9
10 if (fetchType === 'association-ids') return ActorModel.loadByUrl(url)
11}
12
13export {
14 ActorFetchByUrlType,
15 fetchActorByUrl
16}
diff --git a/server/helpers/audit-logger.ts b/server/helpers/audit-logger.ts
index 6aae5e821..884bd187d 100644
--- a/server/helpers/audit-logger.ts
+++ b/server/helpers/audit-logger.ts
@@ -7,7 +7,7 @@ import * as winston from 'winston'
7import { AUDIT_LOG_FILENAME } from '@server/initializers/constants' 7import { AUDIT_LOG_FILENAME } from '@server/initializers/constants'
8import { AdminAbuse, User, VideoChannel, VideoDetails, VideoImport } from '../../shared' 8import { AdminAbuse, User, VideoChannel, VideoDetails, VideoImport } from '../../shared'
9import { CustomConfig } from '../../shared/models/server/custom-config.model' 9import { CustomConfig } from '../../shared/models/server/custom-config.model'
10import { VideoComment } from '../../shared/models/videos/video-comment.model' 10import { VideoComment } from '../../shared/models/videos/comment/video-comment.model'
11import { CONFIG } from '../initializers/config' 11import { CONFIG } from '../initializers/config'
12import { jsonLoggerFormat, labelFormatter } from './logger' 12import { jsonLoggerFormat, labelFormatter } from './logger'
13 13
diff --git a/server/helpers/core-utils.ts b/server/helpers/core-utils.ts
index b93868c12..9abc532d2 100644
--- a/server/helpers/core-utils.ts
+++ b/server/helpers/core-utils.ts
@@ -8,7 +8,7 @@
8import { exec, ExecOptions } from 'child_process' 8import { exec, ExecOptions } from 'child_process'
9import { BinaryToTextEncoding, createHash, randomBytes } from 'crypto' 9import { BinaryToTextEncoding, createHash, randomBytes } from 'crypto'
10import { truncate } from 'lodash' 10import { truncate } from 'lodash'
11import { basename, isAbsolute, join, resolve } from 'path' 11import { basename, extname, isAbsolute, join, resolve } from 'path'
12import * as pem from 'pem' 12import * as pem from 'pem'
13import { pipeline } from 'stream' 13import { pipeline } from 'stream'
14import { URL } from 'url' 14import { URL } from 'url'
@@ -32,6 +32,18 @@ const objectConverter = (oldObject: any, keyConverter: (e: string) => string, va
32 return newObject 32 return newObject
33} 33}
34 34
35function mapToJSON (map: Map<any, any>) {
36 const obj: any = {}
37
38 for (const [ k, v ] of map) {
39 obj[k] = v
40 }
41
42 return obj
43}
44
45// ---------------------------------------------------------------------------
46
35const timeTable = { 47const timeTable = {
36 ms: 1, 48 ms: 1,
37 second: 1000, 49 second: 1000,
@@ -110,6 +122,8 @@ export function parseBytes (value: string | number): number {
110 } 122 }
111} 123}
112 124
125// ---------------------------------------------------------------------------
126
113function sanitizeUrl (url: string) { 127function sanitizeUrl (url: string) {
114 const urlObject = new URL(url) 128 const urlObject = new URL(url)
115 129
@@ -129,6 +143,8 @@ function sanitizeHost (host: string, remoteScheme: string) {
129 return host.replace(new RegExp(`:${toRemove}$`), '') 143 return host.replace(new RegExp(`:${toRemove}$`), '')
130} 144}
131 145
146// ---------------------------------------------------------------------------
147
132function isTestInstance () { 148function isTestInstance () {
133 return process.env.NODE_ENV === 'test' 149 return process.env.NODE_ENV === 'test'
134} 150}
@@ -141,6 +157,8 @@ function getAppNumber () {
141 return process.env.NODE_APP_INSTANCE 157 return process.env.NODE_APP_INSTANCE
142} 158}
143 159
160// ---------------------------------------------------------------------------
161
144let rootPath: string 162let rootPath: string
145 163
146function root () { 164function root () {
@@ -154,27 +172,19 @@ function root () {
154 return rootPath 172 return rootPath
155} 173}
156 174
157function pageToStartAndCount (page: number, itemsPerPage: number) { 175function buildPath (path: string) {
158 const start = (page - 1) * itemsPerPage 176 if (isAbsolute(path)) return path
159 177
160 return { start, count: itemsPerPage } 178 return join(root(), path)
161} 179}
162 180
163function mapToJSON (map: Map<any, any>) { 181function getLowercaseExtension (filename: string) {
164 const obj: any = {} 182 const ext = extname(filename) || ''
165 183
166 for (const [ k, v ] of map) { 184 return ext.toLowerCase()
167 obj[k] = v
168 }
169
170 return obj
171} 185}
172 186
173function buildPath (path: string) { 187// ---------------------------------------------------------------------------
174 if (isAbsolute(path)) return path
175
176 return join(root(), path)
177}
178 188
179// Consistent with .length, lodash truncate function is not 189// Consistent with .length, lodash truncate function is not
180function peertubeTruncate (str: string, options: { length: number, separator?: RegExp, omission?: string }) { 190function peertubeTruncate (str: string, options: { length: number, separator?: RegExp, omission?: string }) {
@@ -189,6 +199,27 @@ function peertubeTruncate (str: string, options: { length: number, separator?: R
189 return truncate(str, options) 199 return truncate(str, options)
190} 200}
191 201
202function pageToStartAndCount (page: number, itemsPerPage: number) {
203 const start = (page - 1) * itemsPerPage
204
205 return { start, count: itemsPerPage }
206}
207
208// ---------------------------------------------------------------------------
209
210type SemVersion = { major: number, minor: number, patch: number }
211function parseSemVersion (s: string) {
212 const parsed = s.match(/^v?(\d+)\.(\d+)\.(\d+)$/i)
213
214 return {
215 major: parseInt(parsed[1]),
216 minor: parseInt(parsed[2]),
217 patch: parseInt(parsed[3])
218 } as SemVersion
219}
220
221// ---------------------------------------------------------------------------
222
192function sha256 (str: string | Buffer, encoding: BinaryToTextEncoding = 'hex') { 223function sha256 (str: string | Buffer, encoding: BinaryToTextEncoding = 'hex') {
193 return createHash('sha256').update(str).digest(encoding) 224 return createHash('sha256').update(str).digest(encoding)
194} 225}
@@ -197,6 +228,8 @@ function sha1 (str: string | Buffer, encoding: BinaryToTextEncoding = 'hex') {
197 return createHash('sha1').update(str).digest(encoding) 228 return createHash('sha1').update(str).digest(encoding)
198} 229}
199 230
231// ---------------------------------------------------------------------------
232
200function execShell (command: string, options?: ExecOptions) { 233function execShell (command: string, options?: ExecOptions) {
201 return new Promise<{ err?: Error, stdout: string, stderr: string }>((res, rej) => { 234 return new Promise<{ err?: Error, stdout: string, stderr: string }>((res, rej) => {
202 exec(command, options, (err, stdout, stderr) => { 235 exec(command, options, (err, stdout, stderr) => {
@@ -208,6 +241,20 @@ function execShell (command: string, options?: ExecOptions) {
208 }) 241 })
209} 242}
210 243
244// ---------------------------------------------------------------------------
245
246function isOdd (num: number) {
247 return (num % 2) !== 0
248}
249
250function toEven (num: number) {
251 if (isOdd(num)) return num + 1
252
253 return num
254}
255
256// ---------------------------------------------------------------------------
257
211function promisify0<A> (func: (cb: (err: any, result: A) => void) => void): () => Promise<A> { 258function promisify0<A> (func: (cb: (err: any, result: A) => void) => void): () => Promise<A> {
212 return function promisified (): Promise<A> { 259 return function promisified (): Promise<A> {
213 return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => { 260 return new Promise<A>((resolve: (arg: A) => void, reject: (err: any) => void) => {
@@ -233,17 +280,6 @@ function promisify2<T, U, A> (func: (arg1: T, arg2: U, cb: (err: any, result: A)
233 } 280 }
234} 281}
235 282
236type SemVersion = { major: number, minor: number, patch: number }
237function parseSemVersion (s: string) {
238 const parsed = s.match(/^v?(\d+)\.(\d+)\.(\d+)$/i)
239
240 return {
241 major: parseInt(parsed[1]),
242 minor: parseInt(parsed[2]),
243 patch: parseInt(parsed[3])
244 } as SemVersion
245}
246
247const randomBytesPromise = promisify1<number, Buffer>(randomBytes) 283const randomBytesPromise = promisify1<number, Buffer>(randomBytes)
248const createPrivateKey = promisify1<number, { key: string }>(pem.createPrivateKey) 284const createPrivateKey = promisify1<number, { key: string }>(pem.createPrivateKey)
249const getPublicKey = promisify1<string, { publicKey: string }>(pem.getPublicKey) 285const getPublicKey = promisify1<string, { publicKey: string }>(pem.getPublicKey)
@@ -259,17 +295,21 @@ export {
259 getAppNumber, 295 getAppNumber,
260 296
261 objectConverter, 297 objectConverter,
298 mapToJSON,
299
262 root, 300 root,
263 pageToStartAndCount, 301 buildPath,
302 getLowercaseExtension,
264 sanitizeUrl, 303 sanitizeUrl,
265 sanitizeHost, 304 sanitizeHost,
266 buildPath, 305
267 execShell, 306 execShell,
307
308 pageToStartAndCount,
268 peertubeTruncate, 309 peertubeTruncate,
269 310
270 sha256, 311 sha256,
271 sha1, 312 sha1,
272 mapToJSON,
273 313
274 promisify0, 314 promisify0,
275 promisify1, 315 promisify1,
@@ -282,5 +322,8 @@ export {
282 execPromise, 322 execPromise,
283 pipelinePromise, 323 pipelinePromise,
284 324
285 parseSemVersion 325 parseSemVersion,
326
327 isOdd,
328 toEven
286} 329}
diff --git a/server/helpers/custom-validators/activitypub/playlist.ts b/server/helpers/custom-validators/activitypub/playlist.ts
index bd0d16a4a..72c5b80e9 100644
--- a/server/helpers/custom-validators/activitypub/playlist.ts
+++ b/server/helpers/custom-validators/activitypub/playlist.ts
@@ -1,13 +1,16 @@
1import { exists, isDateValid } from '../misc'
2import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
3import validator from 'validator' 1import validator from 'validator'
4import { PlaylistElementObject } from '../../../../shared/models/activitypub/objects/playlist-element-object' 2import { PlaylistElementObject } from '../../../../shared/models/activitypub/objects/playlist-element-object'
3import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
4import { exists, isDateValid, isUUIDValid } from '../misc'
5import { isVideoPlaylistNameValid } from '../video-playlists'
5import { isActivityPubUrlValid } from './misc' 6import { isActivityPubUrlValid } from './misc'
6 7
7function isPlaylistObjectValid (object: PlaylistObject) { 8function isPlaylistObjectValid (object: PlaylistObject) {
8 return exists(object) && 9 return exists(object) &&
9 object.type === 'Playlist' && 10 object.type === 'Playlist' &&
10 validator.isInt(object.totalItems + '') && 11 validator.isInt(object.totalItems + '') &&
12 isVideoPlaylistNameValid(object.name) &&
13 isUUIDValid(object.uuid) &&
11 isDateValid(object.published) && 14 isDateValid(object.published) &&
12 isDateValid(object.updated) 15 isDateValid(object.updated)
13} 16}
diff --git a/server/helpers/custom-validators/misc.ts b/server/helpers/custom-validators/misc.ts
index fd3b45804..528bfcfb8 100644
--- a/server/helpers/custom-validators/misc.ts
+++ b/server/helpers/custom-validators/misc.ts
@@ -2,6 +2,7 @@ import 'multer'
2import { UploadFilesForCheck } from 'express' 2import { UploadFilesForCheck } from 'express'
3import { sep } from 'path' 3import { sep } from 'path'
4import validator from 'validator' 4import validator from 'validator'
5import { isShortUUID, shortToUUID } from '../uuid'
5 6
6function exists (value: any) { 7function exists (value: any) {
7 return value !== undefined && value !== null 8 return value !== undefined && value !== null
@@ -14,7 +15,7 @@ function isSafePath (p: string) {
14 }) 15 })
15} 16}
16 17
17function isArray (value: any) { 18function isArray (value: any): value is any[] {
18 return Array.isArray(value) 19 return Array.isArray(value)
19} 20}
20 21
@@ -50,42 +51,7 @@ function isIntOrNull (value: any) {
50 return value === null || validator.isInt('' + value) 51 return value === null || validator.isInt('' + value)
51} 52}
52 53
53function toIntOrNull (value: string) { 54// ---------------------------------------------------------------------------
54 const v = toValueOrNull(value)
55
56 if (v === null || v === undefined) return v
57 if (typeof v === 'number') return v
58
59 return validator.toInt('' + v)
60}
61
62function toBooleanOrNull (value: any) {
63 const v = toValueOrNull(value)
64
65 if (v === null || v === undefined) return v
66 if (typeof v === 'boolean') return v
67
68 return validator.toBoolean('' + v)
69}
70
71function toValueOrNull (value: string) {
72 if (value === 'null') return null
73
74 return value
75}
76
77function toArray (value: any) {
78 if (value && isArray(value) === false) return [ value ]
79
80 return value
81}
82
83function toIntArray (value: any) {
84 if (!value) return []
85 if (isArray(value) === false) return [ validator.toInt(value) ]
86
87 return value.map(v => validator.toInt(v))
88}
89 55
90function isFileFieldValid ( 56function isFileFieldValid (
91 files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[], 57 files: { [ fieldname: string ]: Express.Multer.File[] } | Express.Multer.File[],
@@ -160,6 +126,51 @@ function isFileValid (
160 126
161// --------------------------------------------------------------------------- 127// ---------------------------------------------------------------------------
162 128
129function toCompleteUUID (value: string) {
130 if (isShortUUID(value)) return shortToUUID(value)
131
132 return value
133}
134
135function toIntOrNull (value: string) {
136 const v = toValueOrNull(value)
137
138 if (v === null || v === undefined) return v
139 if (typeof v === 'number') return v
140
141 return validator.toInt('' + v)
142}
143
144function toBooleanOrNull (value: any) {
145 const v = toValueOrNull(value)
146
147 if (v === null || v === undefined) return v
148 if (typeof v === 'boolean') return v
149
150 return validator.toBoolean('' + v)
151}
152
153function toValueOrNull (value: string) {
154 if (value === 'null') return null
155
156 return value
157}
158
159function toArray (value: any) {
160 if (value && isArray(value) === false) return [ value ]
161
162 return value
163}
164
165function toIntArray (value: any) {
166 if (!value) return []
167 if (isArray(value) === false) return [ validator.toInt(value) ]
168
169 return value.map(v => validator.toInt(v))
170}
171
172// ---------------------------------------------------------------------------
173
163export { 174export {
164 exists, 175 exists,
165 isArrayOf, 176 isArrayOf,
@@ -169,6 +180,7 @@ export {
169 isIdValid, 180 isIdValid,
170 isSafePath, 181 isSafePath,
171 isUUIDValid, 182 isUUIDValid,
183 toCompleteUUID,
172 isIdOrUUIDValid, 184 isIdOrUUIDValid,
173 isDateValid, 185 isDateValid,
174 toValueOrNull, 186 toValueOrNull,
diff --git a/server/helpers/custom-validators/video-comments.ts b/server/helpers/custom-validators/video-comments.ts
index 8d3ce580e..94bdf237a 100644
--- a/server/helpers/custom-validators/video-comments.ts
+++ b/server/helpers/custom-validators/video-comments.ts
@@ -1,9 +1,5 @@
1import * as express from 'express'
2import validator from 'validator' 1import validator from 'validator'
3import { VideoCommentModel } from '@server/models/video/video-comment'
4import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 2import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
5import { MVideoId } from '@server/types/models'
6import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
7 3
8const VIDEO_COMMENTS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_COMMENTS 4const VIDEO_COMMENTS_CONSTRAINTS_FIELDS = CONSTRAINTS_FIELDS.VIDEO_COMMENTS
9 5
@@ -11,83 +7,8 @@ function isValidVideoCommentText (value: string) {
11 return value === null || validator.isLength(value, VIDEO_COMMENTS_CONSTRAINTS_FIELDS.TEXT) 7 return value === null || validator.isLength(value, VIDEO_COMMENTS_CONSTRAINTS_FIELDS.TEXT)
12} 8}
13 9
14async function doesVideoCommentThreadExist (idArg: number | string, video: MVideoId, res: express.Response) {
15 const id = parseInt(idArg + '', 10)
16 const videoComment = await VideoCommentModel.loadById(id)
17
18 if (!videoComment) {
19 res.status(HttpStatusCode.NOT_FOUND_404)
20 .json({ error: 'Video comment thread not found' })
21 .end()
22
23 return false
24 }
25
26 if (videoComment.videoId !== video.id) {
27 res.status(HttpStatusCode.BAD_REQUEST_400)
28 .json({ error: 'Video comment is not associated to this video.' })
29 .end()
30
31 return false
32 }
33
34 if (videoComment.inReplyToCommentId !== null) {
35 res.status(HttpStatusCode.BAD_REQUEST_400)
36 .json({ error: 'Video comment is not a thread.' })
37 .end()
38
39 return false
40 }
41
42 res.locals.videoCommentThread = videoComment
43 return true
44}
45
46async function doesVideoCommentExist (idArg: number | string, video: MVideoId, res: express.Response) {
47 const id = parseInt(idArg + '', 10)
48 const videoComment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(id)
49
50 if (!videoComment) {
51 res.status(HttpStatusCode.NOT_FOUND_404)
52 .json({ error: 'Video comment thread not found' })
53 .end()
54
55 return false
56 }
57
58 if (videoComment.videoId !== video.id) {
59 res.status(HttpStatusCode.BAD_REQUEST_400)
60 .json({ error: 'Video comment is not associated to this video.' })
61 .end()
62
63 return false
64 }
65
66 res.locals.videoCommentFull = videoComment
67 return true
68}
69
70async function doesCommentIdExist (idArg: number | string, res: express.Response) {
71 const id = parseInt(idArg + '', 10)
72 const videoComment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(id)
73
74 if (!videoComment) {
75 res.status(HttpStatusCode.NOT_FOUND_404)
76 .json({ error: 'Video comment thread not found' })
77
78 return false
79 }
80
81 res.locals.videoCommentFull = videoComment
82
83 return true
84}
85
86// --------------------------------------------------------------------------- 10// ---------------------------------------------------------------------------
87 11
88export { 12export {
89 isValidVideoCommentText, 13 isValidVideoCommentText
90 doesVideoCommentThreadExist,
91 doesVideoCommentExist,
92 doesCommentIdExist
93} 14}
diff --git a/server/helpers/custom-validators/video-imports.ts b/server/helpers/custom-validators/video-imports.ts
index 0063d3337..dbf6a3504 100644
--- a/server/helpers/custom-validators/video-imports.ts
+++ b/server/helpers/custom-validators/video-imports.ts
@@ -2,9 +2,6 @@ import 'multer'
2import validator from 'validator' 2import validator from 'validator'
3import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers/constants' 3import { CONSTRAINTS_FIELDS, MIMETYPES, VIDEO_IMPORT_STATES } from '../../initializers/constants'
4import { exists, isFileValid } from './misc' 4import { exists, isFileValid } from './misc'
5import * as express from 'express'
6import { VideoImportModel } from '../../models/video/video-import'
7import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
8 5
9function isVideoImportTargetUrlValid (url: string) { 6function isVideoImportTargetUrlValid (url: string) {
10 const isURLOptions = { 7 const isURLOptions = {
@@ -32,26 +29,10 @@ function isVideoImportTorrentFile (files: { [ fieldname: string ]: Express.Multe
32 return isFileValid(files, videoTorrentImportRegex, 'torrentfile', CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max, true) 29 return isFileValid(files, videoTorrentImportRegex, 'torrentfile', CONSTRAINTS_FIELDS.VIDEO_IMPORTS.TORRENT_FILE.FILE_SIZE.max, true)
33} 30}
34 31
35async function doesVideoImportExist (id: number, res: express.Response) {
36 const videoImport = await VideoImportModel.loadAndPopulateVideo(id)
37
38 if (!videoImport) {
39 res.status(HttpStatusCode.NOT_FOUND_404)
40 .json({ error: 'Video import not found' })
41 .end()
42
43 return false
44 }
45
46 res.locals.videoImport = videoImport
47 return true
48}
49
50// --------------------------------------------------------------------------- 32// ---------------------------------------------------------------------------
51 33
52export { 34export {
53 isVideoImportStateValid, 35 isVideoImportStateValid,
54 isVideoImportTargetUrlValid, 36 isVideoImportTargetUrlValid,
55 doesVideoImportExist,
56 isVideoImportTorrentFile 37 isVideoImportTorrentFile
57} 38}
diff --git a/server/helpers/custom-validators/video-ownership.ts b/server/helpers/custom-validators/video-ownership.ts
index ee3cebe10..0e1c63bad 100644
--- a/server/helpers/custom-validators/video-ownership.ts
+++ b/server/helpers/custom-validators/video-ownership.ts
@@ -1,32 +1,20 @@
1import { Response } from 'express' 1import { Response } from 'express'
2import { VideoChangeOwnershipModel } from '../../models/video/video-change-ownership'
3import { MVideoChangeOwnershipFull } from '@server/types/models/video/video-change-ownership'
4import { MUserId } from '@server/types/models' 2import { MUserId } from '@server/types/models'
3import { MVideoChangeOwnershipFull } from '@server/types/models/video/video-change-ownership'
5import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' 4import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
6 5
7export async function doesChangeVideoOwnershipExist (idArg: number | string, res: Response) { 6function checkUserCanTerminateOwnershipChange (user: MUserId, videoChangeOwnership: MVideoChangeOwnershipFull, res: Response) {
8 const id = parseInt(idArg + '', 10)
9 const videoChangeOwnership = await VideoChangeOwnershipModel.load(id)
10
11 if (!videoChangeOwnership) {
12 res.status(HttpStatusCode.NOT_FOUND_404)
13 .json({ error: 'Video change ownership not found' })
14 .end()
15
16 return false
17 }
18
19 res.locals.videoChangeOwnership = videoChangeOwnership
20 return true
21}
22
23export function checkUserCanTerminateOwnershipChange (user: MUserId, videoChangeOwnership: MVideoChangeOwnershipFull, res: Response) {
24 if (videoChangeOwnership.NextOwner.userId === user.id) { 7 if (videoChangeOwnership.NextOwner.userId === user.id) {
25 return true 8 return true
26 } 9 }
27 10
28 res.status(HttpStatusCode.FORBIDDEN_403) 11 res.fail({
29 .json({ error: 'Cannot terminate an ownership change of another user' }) 12 status: HttpStatusCode.FORBIDDEN_403,
30 .end() 13 message: 'Cannot terminate an ownership change of another user'
14 })
31 return false 15 return false
32} 16}
17
18export {
19 checkUserCanTerminateOwnershipChange
20}
diff --git a/server/helpers/database-utils.ts b/server/helpers/database-utils.ts
index f9cb33aca..b5dc70c17 100644
--- a/server/helpers/database-utils.ts
+++ b/server/helpers/database-utils.ts
@@ -58,7 +58,7 @@ function transactionRetryer <T> (func: (err: any, data: T) => any) {
58 58
59 errorFilter: err => { 59 errorFilter: err => {
60 const willRetry = (err.name === 'SequelizeDatabaseError') 60 const willRetry = (err.name === 'SequelizeDatabaseError')
61 logger.debug('Maybe retrying the transaction function.', { willRetry, err }) 61 logger.debug('Maybe retrying the transaction function.', { willRetry, err, tags: [ 'sql', 'retry' ] })
62 return willRetry 62 return willRetry
63 } 63 }
64 }, 64 },
@@ -68,7 +68,9 @@ function transactionRetryer <T> (func: (err: any, data: T) => any) {
68 }) 68 })
69} 69}
70 70
71function updateInstanceWithAnother <T extends Model<T>> (instanceToUpdate: Model<T>, baseInstance: Model<T>) { 71// ---------------------------------------------------------------------------
72
73function updateInstanceWithAnother <M, T extends U, U extends Model<M>> (instanceToUpdate: T, baseInstance: U) {
72 const obj = baseInstance.toJSON() 74 const obj = baseInstance.toJSON()
73 75
74 for (const key of Object.keys(obj)) { 76 for (const key of Object.keys(obj)) {
@@ -82,13 +84,7 @@ function resetSequelizeInstance (instance: Model<any>, savedFields: object) {
82 }) 84 })
83} 85}
84 86
85function afterCommitIfTransaction (t: Transaction, fn: Function) { 87function deleteNonExistingModels <T extends { hasSameUniqueKeysThan (other: T): boolean } & Pick<Model, 'destroy'>> (
86 if (t) return t.afterCommit(() => fn())
87
88 return fn()
89}
90
91function deleteNonExistingModels <T extends { hasSameUniqueKeysThan (other: T): boolean } & Model<T>> (
92 fromDatabase: T[], 88 fromDatabase: T[],
93 newModels: T[], 89 newModels: T[],
94 t: Transaction 90 t: Transaction
@@ -111,6 +107,20 @@ function setAsUpdated (table: string, id: number, transaction?: Transaction) {
111 107
112// --------------------------------------------------------------------------- 108// ---------------------------------------------------------------------------
113 109
110function runInReadCommittedTransaction <T> (fn: (t: Transaction) => Promise<T>) {
111 const options = { isolationLevel: Transaction.ISOLATION_LEVELS.READ_COMMITTED }
112
113 return sequelizeTypescript.transaction(options, t => fn(t))
114}
115
116function afterCommitIfTransaction (t: Transaction, fn: Function) {
117 if (t) return t.afterCommit(() => fn())
118
119 return fn()
120}
121
122// ---------------------------------------------------------------------------
123
114export { 124export {
115 resetSequelizeInstance, 125 resetSequelizeInstance,
116 retryTransactionWrapper, 126 retryTransactionWrapper,
@@ -118,5 +128,6 @@ export {
118 updateInstanceWithAnother, 128 updateInstanceWithAnother,
119 afterCommitIfTransaction, 129 afterCommitIfTransaction,
120 deleteNonExistingModels, 130 deleteNonExistingModels,
121 setAsUpdated 131 setAsUpdated,
132 runInReadCommittedTransaction
122} 133}
diff --git a/server/helpers/express-utils.ts b/server/helpers/express-utils.ts
index ede22a3cc..0ff113274 100644
--- a/server/helpers/express-utils.ts
+++ b/server/helpers/express-utils.ts
@@ -1,13 +1,13 @@
1import * as express from 'express' 1import * as express from 'express'
2import * as multer from 'multer' 2import * as multer from 'multer'
3import { HttpStatusCode } from '../../shared/core-utils/miscs/http-error-codes'
4import { CONFIG } from '../initializers/config'
3import { REMOTE_SCHEME } from '../initializers/constants' 5import { REMOTE_SCHEME } from '../initializers/constants'
6import { getLowercaseExtension } from './core-utils'
7import { isArray } from './custom-validators/misc'
4import { logger } from './logger' 8import { logger } from './logger'
5import { deleteFileAndCatch, generateRandomString } from './utils' 9import { deleteFileAndCatch, generateRandomString } from './utils'
6import { extname } from 'path'
7import { isArray } from './custom-validators/misc'
8import { CONFIG } from '../initializers/config'
9import { getExtFromMimetype } from './video' 10import { getExtFromMimetype } from './video'
10import { HttpStatusCode } from '../../shared/core-utils/miscs/http-error-codes'
11 11
12function buildNSFWFilter (res?: express.Response, paramNSFW?: string) { 12function buildNSFWFilter (res?: express.Response, paramNSFW?: string) {
13 if (paramNSFW === 'true') return true 13 if (paramNSFW === 'true') return true
@@ -30,21 +30,19 @@ function buildNSFWFilter (res?: express.Response, paramNSFW?: string) {
30 return null 30 return null
31} 31}
32 32
33function cleanUpReqFiles (req: { files: { [fieldname: string]: Express.Multer.File[] } | Express.Multer.File[] }) { 33function cleanUpReqFiles (req: express.Request) {
34 const files = req.files 34 const filesObject = req.files
35 35 if (!filesObject) return
36 if (!files) return
37 36
38 if (isArray(files)) { 37 if (isArray(filesObject)) {
39 (files as Express.Multer.File[]).forEach(f => deleteFileAndCatch(f.path)) 38 filesObject.forEach(f => deleteFileAndCatch(f.path))
40 return 39 return
41 } 40 }
42 41
43 for (const key of Object.keys(files)) { 42 for (const key of Object.keys(filesObject)) {
44 const file = files[key] 43 const files = filesObject[key]
45 44
46 if (isArray(file)) file.forEach(f => deleteFileAndCatch(f.path)) 45 files.forEach(f => deleteFileAndCatch(f.path))
47 else deleteFileAndCatch(file.path)
48 } 46 }
49} 47}
50 48
@@ -79,7 +77,7 @@ function createReqFiles (
79 77
80 filename: async (req, file, cb) => { 78 filename: async (req, file, cb) => {
81 let extension: string 79 let extension: string
82 const fileExtension = extname(file.originalname) 80 const fileExtension = getLowercaseExtension(file.originalname)
83 const extensionFromMimetype = getExtFromMimetype(mimeTypes, file.mimetype) 81 const extensionFromMimetype = getExtFromMimetype(mimeTypes, file.mimetype)
84 82
85 // Take the file extension if we don't understand the mime type 83 // Take the file extension if we don't understand the mime type
diff --git a/server/helpers/ffmpeg-utils.ts b/server/helpers/ffmpeg-utils.ts
index e328c49ac..6f5a71b4a 100644
--- a/server/helpers/ffmpeg-utils.ts
+++ b/server/helpers/ffmpeg-utils.ts
@@ -3,13 +3,12 @@ import * as ffmpeg from 'fluent-ffmpeg'
3import { readFile, remove, writeFile } from 'fs-extra' 3import { readFile, remove, writeFile } from 'fs-extra'
4import { dirname, join } from 'path' 4import { dirname, join } from 'path'
5import { FFMPEG_NICE, VIDEO_LIVE } from '@server/initializers/constants' 5import { FFMPEG_NICE, VIDEO_LIVE } from '@server/initializers/constants'
6import { AvailableEncoders, EncoderOptionsBuilder, EncoderOptions, EncoderProfile, VideoResolution } from '../../shared/models/videos' 6import { AvailableEncoders, EncoderOptions, EncoderOptionsBuilder, EncoderProfile, VideoResolution } from '../../shared/models/videos'
7import { CONFIG } from '../initializers/config' 7import { CONFIG } from '../initializers/config'
8import { execPromise, promisify0 } from './core-utils' 8import { execPromise, promisify0 } from './core-utils'
9import { computeFPS, getAudioStream, getVideoFileFPS } from './ffprobe-utils' 9import { computeFPS, getAudioStream, getVideoFileFPS } from './ffprobe-utils'
10import { processImage } from './image-utils' 10import { processImage } from './image-utils'
11import { logger } from './logger' 11import { logger } from './logger'
12import { FilterSpecification } from 'fluent-ffmpeg'
13 12
14/** 13/**
15 * 14 *
@@ -133,7 +132,7 @@ interface BaseTranscodeOptions {
133 availableEncoders: AvailableEncoders 132 availableEncoders: AvailableEncoders
134 profile: string 133 profile: string
135 134
136 resolution: VideoResolution 135 resolution: number
137 136
138 isPortraitMode?: boolean 137 isPortraitMode?: boolean
139 138
@@ -227,7 +226,7 @@ async function getLiveTranscodingCommand (options: {
227 226
228 const varStreamMap: string[] = [] 227 const varStreamMap: string[] = []
229 228
230 const complexFilter: FilterSpecification[] = [ 229 const complexFilter: ffmpeg.FilterSpecification[] = [
231 { 230 {
232 inputs: '[v:0]', 231 inputs: '[v:0]',
233 filter: 'split', 232 filter: 'split',
@@ -407,8 +406,7 @@ async function buildx264VODCommand (command: ffmpeg.FfmpegCommand, options: Tran
407async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: MergeAudioTranscodeOptions) { 406async function buildAudioMergeCommand (command: ffmpeg.FfmpegCommand, options: MergeAudioTranscodeOptions) {
408 command = command.loop(undefined) 407 command = command.loop(undefined)
409 408
410 // Avoid "height not divisible by 2" error 409 const scaleFilterValue = getScaleCleanerValue()
411 const scaleFilterValue = 'trunc(iw/2)*2:trunc(ih/2)*2'
412 command = await presetVideo({ command, input: options.audioPath, transcodeOptions: options, scaleFilterValue }) 410 command = await presetVideo({ command, input: options.audioPath, transcodeOptions: options, scaleFilterValue })
413 411
414 command.outputOption('-preset:v veryfast') 412 command.outputOption('-preset:v veryfast')
@@ -542,7 +540,7 @@ async function getEncoderBuilderResult (options: {
542 } 540 }
543 } 541 }
544 542
545 const result = await builder({ input, resolution: resolution, fps, streamNum }) 543 const result = await builder({ input, resolution, fps, streamNum })
546 544
547 return { 545 return {
548 result, 546 result,
@@ -727,6 +725,11 @@ async function runCommand (options: {
727 }) 725 })
728} 726}
729 727
728// Avoid "height not divisible by 2" error
729function getScaleCleanerValue () {
730 return 'trunc(iw/2)*2:trunc(ih/2)*2'
731}
732
730// --------------------------------------------------------------------------- 733// ---------------------------------------------------------------------------
731 734
732export { 735export {
diff --git a/server/helpers/ffprobe-utils.ts b/server/helpers/ffprobe-utils.ts
index 40eaafd57..ef2aa3f89 100644
--- a/server/helpers/ffprobe-utils.ts
+++ b/server/helpers/ffprobe-utils.ts
@@ -1,6 +1,5 @@
1import * as ffmpeg from 'fluent-ffmpeg' 1import * as ffmpeg from 'fluent-ffmpeg'
2import { VideoFileMetadata } from '@shared/models/videos/video-file-metadata' 2import { getMaxBitrate, VideoFileMetadata, VideoResolution } from '../../shared/models/videos'
3import { getMaxBitrate, VideoResolution } from '../../shared/models/videos'
4import { CONFIG } from '../initializers/config' 3import { CONFIG } from '../initializers/config'
5import { VIDEO_TRANSCODING_FPS } from '../initializers/constants' 4import { VIDEO_TRANSCODING_FPS } from '../initializers/constants'
6import { logger } from './logger' 5import { logger } from './logger'
diff --git a/server/helpers/image-utils.ts b/server/helpers/image-utils.ts
index 6f6f8d4da..c76ed545b 100644
--- a/server/helpers/image-utils.ts
+++ b/server/helpers/image-utils.ts
@@ -1,12 +1,12 @@
1import { copy, readFile, remove, rename } from 'fs-extra' 1import { copy, readFile, remove, rename } from 'fs-extra'
2import * as Jimp from 'jimp' 2import * as Jimp from 'jimp'
3import { extname } from 'path' 3import { getLowercaseExtension } from './core-utils'
4import { v4 as uuidv4 } from 'uuid'
5import { convertWebPToJPG, processGIF } from './ffmpeg-utils' 4import { convertWebPToJPG, processGIF } from './ffmpeg-utils'
6import { logger } from './logger' 5import { logger } from './logger'
6import { buildUUID } from './uuid'
7 7
8function generateImageFilename (extension = '.jpg') { 8function generateImageFilename (extension = '.jpg') {
9 return uuidv4() + extension 9 return buildUUID() + extension
10} 10}
11 11
12async function processImage ( 12async function processImage (
@@ -15,7 +15,7 @@ async function processImage (
15 newSize: { width: number, height: number }, 15 newSize: { width: number, height: number },
16 keepOriginal = false 16 keepOriginal = false
17) { 17) {
18 const extension = extname(path) 18 const extension = getLowercaseExtension(path)
19 19
20 if (path === destination) { 20 if (path === destination) {
21 throw new Error('Jimp/FFmpeg needs an input path different that the output path.') 21 throw new Error('Jimp/FFmpeg needs an input path different that the output path.')
@@ -61,7 +61,8 @@ async function jimpProcessor (path: string, destination: string, newSize: { widt
61 await remove(destination) 61 await remove(destination)
62 62
63 // Optimization if the source file has the appropriate size 63 // Optimization if the source file has the appropriate size
64 if (await skipProcessing({ jimpInstance, newSize, imageBytes: inputBuffer.byteLength, inputExt, outputExt: extname(destination) })) { 64 const outputExt = getLowercaseExtension(destination)
65 if (skipProcessing({ jimpInstance, newSize, imageBytes: inputBuffer.byteLength, inputExt, outputExt })) {
65 return copy(path, destination) 66 return copy(path, destination)
66 } 67 }
67 68
diff --git a/server/helpers/logger.ts b/server/helpers/logger.ts
index a112fd300..29e06860d 100644
--- a/server/helpers/logger.ts
+++ b/server/helpers/logger.ts
@@ -151,7 +151,8 @@ const bunyanLogger = {
151 fatal: bunyanLogFactory('error') 151 fatal: bunyanLogFactory('error')
152} 152}
153 153
154function loggerTagsFactory (...defaultTags: string[]) { 154type LoggerTagsFn = (...tags: string[]) => { tags: string[] }
155function loggerTagsFactory (...defaultTags: string[]): LoggerTagsFn {
155 return (...tags: string[]) => { 156 return (...tags: string[]) => {
156 return { tags: defaultTags.concat(tags) } 157 return { tags: defaultTags.concat(tags) }
157 } 158 }
@@ -160,6 +161,8 @@ function loggerTagsFactory (...defaultTags: string[]) {
160// --------------------------------------------------------------------------- 161// ---------------------------------------------------------------------------
161 162
162export { 163export {
164 LoggerTagsFn,
165
163 buildLogger, 166 buildLogger,
164 timestampFormatter, 167 timestampFormatter,
165 labelFormatter, 168 labelFormatter,
diff --git a/server/helpers/markdown.ts b/server/helpers/markdown.ts
index 2126bb752..41e57d857 100644
--- a/server/helpers/markdown.ts
+++ b/server/helpers/markdown.ts
@@ -1,4 +1,6 @@
1import { SANITIZE_OPTIONS, TEXT_WITH_HTML_RULES } from '@shared/core-utils' 1import { getSanitizeOptions, TEXT_WITH_HTML_RULES } from '@shared/core-utils'
2
3const sanitizeOptions = getSanitizeOptions()
2 4
3const sanitizeHtml = require('sanitize-html') 5const sanitizeHtml = require('sanitize-html')
4const markdownItEmoji = require('markdown-it-emoji/light') 6const markdownItEmoji = require('markdown-it-emoji/light')
@@ -18,7 +20,7 @@ const toSafeHtml = text => {
18 const html = markdownIt.render(textWithLineFeed) 20 const html = markdownIt.render(textWithLineFeed)
19 21
20 // Convert to safe Html 22 // Convert to safe Html
21 return sanitizeHtml(html, SANITIZE_OPTIONS) 23 return sanitizeHtml(html, sanitizeOptions)
22} 24}
23 25
24const mdToPlainText = text => { 26const mdToPlainText = text => {
@@ -28,7 +30,7 @@ const mdToPlainText = text => {
28 const html = markdownIt.render(text) 30 const html = markdownIt.render(text)
29 31
30 // Convert to safe Html 32 // Convert to safe Html
31 const safeHtml = sanitizeHtml(html, SANITIZE_OPTIONS) 33 const safeHtml = sanitizeHtml(html, sanitizeOptions)
32 34
33 return safeHtml.replace(/<[^>]+>/g, '') 35 return safeHtml.replace(/<[^>]+>/g, '')
34 .replace(/\n$/, '') 36 .replace(/\n$/, '')
diff --git a/server/helpers/promise-cache.ts b/server/helpers/promise-cache.ts
new file mode 100644
index 000000000..07e8a9962
--- /dev/null
+++ b/server/helpers/promise-cache.ts
@@ -0,0 +1,21 @@
1export class PromiseCache <A, R> {
2 private readonly running = new Map<string, Promise<R>>()
3
4 constructor (
5 private readonly fn: (arg: A) => Promise<R>,
6 private readonly keyBuilder: (arg: A) => string
7 ) {
8 }
9
10 run (arg: A) {
11 const key = this.keyBuilder(arg)
12
13 if (this.running.has(key)) return this.running.get(key)
14
15 const p = this.fn(arg)
16
17 this.running.set(key, p)
18
19 return p.finally(() => this.running.delete(key))
20 }
21}
diff --git a/server/helpers/requests.ts b/server/helpers/requests.ts
index fd2a56f30..36e69458e 100644
--- a/server/helpers/requests.ts
+++ b/server/helpers/requests.ts
@@ -2,7 +2,7 @@ import { createWriteStream, remove } from 'fs-extra'
2import got, { CancelableRequest, Options as GotOptions, RequestError } from 'got' 2import got, { CancelableRequest, Options as GotOptions, RequestError } from 'got'
3import { join } from 'path' 3import { join } from 'path'
4import { CONFIG } from '../initializers/config' 4import { CONFIG } from '../initializers/config'
5import { ACTIVITY_PUB, PEERTUBE_VERSION, WEBSERVER } from '../initializers/constants' 5import { ACTIVITY_PUB, PEERTUBE_VERSION, REQUEST_TIMEOUT, WEBSERVER } from '../initializers/constants'
6import { pipelinePromise } from './core-utils' 6import { pipelinePromise } from './core-utils'
7import { processImage } from './image-utils' 7import { processImage } from './image-utils'
8import { logger } from './logger' 8import { logger } from './logger'
@@ -24,6 +24,7 @@ type PeerTubeRequestOptions = {
24 key: string 24 key: string
25 headers: string[] 25 headers: string[]
26 } 26 }
27 timeout?: number
27 jsonResponse?: boolean 28 jsonResponse?: boolean
28} & Pick<GotOptions, 'headers' | 'json' | 'method' | 'searchParams'> 29} & Pick<GotOptions, 'headers' | 'json' | 'method' | 'searchParams'>
29 30
@@ -92,6 +93,10 @@ const peertubeGot = got.extend({
92 path 93 path
93 }, httpSignatureOptions) 94 }, httpSignatureOptions)
94 } 95 }
96 },
97
98 (options: GotOptions) => {
99 options.timeout = REQUEST_TIMEOUT
95 } 100 }
96 ] 101 ]
97 } 102 }
@@ -180,8 +185,10 @@ function buildGotOptions (options: PeerTubeRequestOptions) {
180 185
181 return { 186 return {
182 method: options.method, 187 method: options.method,
188 dnsCache: true,
183 json: options.json, 189 json: options.json,
184 searchParams: options.searchParams, 190 searchParams: options.searchParams,
191 timeout: options.timeout ?? REQUEST_TIMEOUT,
185 headers, 192 headers,
186 context 193 context
187 } 194 }
diff --git a/server/helpers/uuid.ts b/server/helpers/uuid.ts
new file mode 100644
index 000000000..3eb06c773
--- /dev/null
+++ b/server/helpers/uuid.ts
@@ -0,0 +1,32 @@
1import * as short from 'short-uuid'
2
3const translator = short()
4
5function buildUUID () {
6 return short.uuid()
7}
8
9function uuidToShort (uuid: string) {
10 if (!uuid) return uuid
11
12 return translator.fromUUID(uuid)
13}
14
15function shortToUUID (shortUUID: string) {
16 if (!shortUUID) return shortUUID
17
18 return translator.toUUID(shortUUID)
19}
20
21function isShortUUID (value: string) {
22 if (!value) return false
23
24 return value.length === translator.maxLength
25}
26
27export {
28 buildUUID,
29 uuidToShort,
30 shortToUUID,
31 isShortUUID
32}
diff --git a/server/helpers/video.ts b/server/helpers/video.ts
index 7c510f474..f5f645d3e 100644
--- a/server/helpers/video.ts
+++ b/server/helpers/video.ts
@@ -1,69 +1,10 @@
1import { Response } from 'express' 1import { Response } from 'express'
2import { CONFIG } from '@server/initializers/config' 2import { CONFIG } from '@server/initializers/config'
3import { 3import { isStreamingPlaylist, MStreamingPlaylistVideo, MVideo } from '@server/types/models'
4 isStreamingPlaylist,
5 MStreamingPlaylistVideo,
6 MVideo,
7 MVideoAccountLightBlacklistAllFiles,
8 MVideoFullLight,
9 MVideoIdThumbnail,
10 MVideoImmutable,
11 MVideoThumbnail,
12 MVideoWithRights
13} from '@server/types/models'
14import { VideoPrivacy, VideoState } from '@shared/models' 4import { VideoPrivacy, VideoState } from '@shared/models'
15import { VideoModel } from '../models/video/video'
16
17type VideoFetchType = 'all' | 'only-video' | 'only-video-with-rights' | 'id' | 'none' | 'only-immutable-attributes'
18
19function fetchVideo (id: number | string, fetchType: 'all', userId?: number): Promise<MVideoFullLight>
20function fetchVideo (id: number | string, fetchType: 'only-immutable-attributes'): Promise<MVideoImmutable>
21function fetchVideo (id: number | string, fetchType: 'only-video', userId?: number): Promise<MVideoThumbnail>
22function fetchVideo (id: number | string, fetchType: 'only-video-with-rights', userId?: number): Promise<MVideoWithRights>
23function fetchVideo (id: number | string, fetchType: 'id' | 'none', userId?: number): Promise<MVideoIdThumbnail>
24function fetchVideo (
25 id: number | string,
26 fetchType: VideoFetchType,
27 userId?: number
28): Promise<MVideoFullLight | MVideoThumbnail | MVideoWithRights | MVideoIdThumbnail | MVideoImmutable>
29function fetchVideo (
30 id: number | string,
31 fetchType: VideoFetchType,
32 userId?: number
33): Promise<MVideoFullLight | MVideoThumbnail | MVideoWithRights | MVideoIdThumbnail | MVideoImmutable> {
34 if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id, undefined, userId)
35
36 if (fetchType === 'only-immutable-attributes') return VideoModel.loadImmutableAttributes(id)
37
38 if (fetchType === 'only-video-with-rights') return VideoModel.loadWithRights(id)
39
40 if (fetchType === 'only-video') return VideoModel.load(id)
41
42 if (fetchType === 'id' || fetchType === 'none') return VideoModel.loadOnlyId(id)
43}
44
45type VideoFetchByUrlType = 'all' | 'only-video' | 'only-immutable-attributes'
46
47function fetchVideoByUrl (url: string, fetchType: 'all'): Promise<MVideoAccountLightBlacklistAllFiles>
48function fetchVideoByUrl (url: string, fetchType: 'only-immutable-attributes'): Promise<MVideoImmutable>
49function fetchVideoByUrl (url: string, fetchType: 'only-video'): Promise<MVideoThumbnail>
50function fetchVideoByUrl (
51 url: string,
52 fetchType: VideoFetchByUrlType
53): Promise<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail | MVideoImmutable>
54function fetchVideoByUrl (
55 url: string,
56 fetchType: VideoFetchByUrlType
57): Promise<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail | MVideoImmutable> {
58 if (fetchType === 'all') return VideoModel.loadByUrlAndPopulateAccount(url)
59
60 if (fetchType === 'only-immutable-attributes') return VideoModel.loadByUrlImmutableAttributes(url)
61
62 if (fetchType === 'only-video') return VideoModel.loadByUrl(url)
63}
64 5
65function getVideoWithAttributes (res: Response) { 6function getVideoWithAttributes (res: Response) {
66 return res.locals.videoAll || res.locals.onlyVideo || res.locals.onlyVideoWithRights 7 return res.locals.videoAPI || res.locals.videoAll || res.locals.onlyVideo
67} 8}
68 9
69function extractVideo (videoOrPlaylist: MVideo | MStreamingPlaylistVideo) { 10function extractVideo (videoOrPlaylist: MVideo | MStreamingPlaylistVideo) {
@@ -100,11 +41,7 @@ function getExtFromMimetype (mimeTypes: { [id: string]: string | string[] }, mim
100} 41}
101 42
102export { 43export {
103 VideoFetchType,
104 VideoFetchByUrlType,
105 fetchVideo,
106 getVideoWithAttributes, 44 getVideoWithAttributes,
107 fetchVideoByUrl,
108 extractVideo, 45 extractVideo,
109 getExtFromMimetype, 46 getExtFromMimetype,
110 isStateForFederation, 47 isStateForFederation,
diff --git a/server/helpers/youtube-dl.ts b/server/helpers/youtube-dl.ts
index fac3da6ba..fdd361390 100644
--- a/server/helpers/youtube-dl.ts
+++ b/server/helpers/youtube-dl.ts
@@ -6,7 +6,6 @@ import { CONFIG } from '@server/initializers/config'
6import { HttpStatusCode } from '../../shared/core-utils/miscs/http-error-codes' 6import { HttpStatusCode } from '../../shared/core-utils/miscs/http-error-codes'
7import { VideoResolution } from '../../shared/models/videos' 7import { VideoResolution } from '../../shared/models/videos'
8import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '../initializers/constants' 8import { CONSTRAINTS_FIELDS, VIDEO_CATEGORIES, VIDEO_LANGUAGES, VIDEO_LICENCES } from '../initializers/constants'
9import { getEnabledResolutions } from '../lib/video-transcoding'
10import { peertubeTruncate, pipelinePromise, root } from './core-utils' 9import { peertubeTruncate, pipelinePromise, root } from './core-utils'
11import { isVideoFileExtnameValid } from './custom-validators/videos' 10import { isVideoFileExtnameValid } from './custom-validators/videos'
12import { logger } from './logger' 11import { logger } from './logger'
@@ -35,361 +34,359 @@ const processOptions = {
35 maxBuffer: 1024 * 1024 * 10 // 10MB 34 maxBuffer: 1024 * 1024 * 10 // 10MB
36} 35}
37 36
38function getYoutubeDLInfo (url: string, opts?: string[]): Promise<YoutubeDLInfo> { 37class YoutubeDL {
39 return new Promise<YoutubeDLInfo>((res, rej) => {
40 let args = opts || [ '-j', '--flat-playlist' ]
41 38
42 if (CONFIG.IMPORT.VIDEOS.HTTP.FORCE_IPV4) { 39 constructor (private readonly url: string = '', private readonly enabledResolutions: number[] = []) {
43 args.push('--force-ipv4')
44 }
45 40
46 args = wrapWithProxyOptions(args) 41 }
47 args = [ '-f', getYoutubeDLVideoFormat() ].concat(args)
48 42
49 safeGetYoutubeDL() 43 getYoutubeDLInfo (opts?: string[]): Promise<YoutubeDLInfo> {
50 .then(youtubeDL => { 44 return new Promise<YoutubeDLInfo>((res, rej) => {
51 youtubeDL.getInfo(url, args, processOptions, (err, info) => { 45 let args = opts || []
52 if (err) return rej(err)
53 if (info.is_live === true) return rej(new Error('Cannot download a live streaming.'))
54 46
55 const obj = buildVideoInfo(normalizeObject(info)) 47 if (CONFIG.IMPORT.VIDEOS.HTTP.FORCE_IPV4) {
56 if (obj.name && obj.name.length < CONSTRAINTS_FIELDS.VIDEOS.NAME.min) obj.name += ' video' 48 args.push('--force-ipv4')
49 }
57 50
58 return res(obj) 51 args = this.wrapWithProxyOptions(args)
59 }) 52 args = [ '-f', this.getYoutubeDLVideoFormat() ].concat(args)
60 })
61 .catch(err => rej(err))
62 })
63}
64 53
65function getYoutubeDLSubs (url: string, opts?: object): Promise<YoutubeDLSubs> { 54 YoutubeDL.safeGetYoutubeDL()
66 return new Promise<YoutubeDLSubs>((res, rej) => { 55 .then(youtubeDL => {
67 const cwd = CONFIG.STORAGE.TMP_DIR 56 youtubeDL.getInfo(this.url, args, processOptions, (err, info) => {
68 const options = opts || { all: true, format: 'vtt', cwd } 57 if (err) return rej(err)
69 58 if (info.is_live === true) return rej(new Error('Cannot download a live streaming.'))
70 safeGetYoutubeDL()
71 .then(youtubeDL => {
72 youtubeDL.getSubs(url, options, (err, files) => {
73 if (err) return rej(err)
74 if (!files) return []
75
76 logger.debug('Get subtitles from youtube dl.', { url, files })
77
78 const subtitles = files.reduce((acc, filename) => {
79 const matched = filename.match(/\.([a-z]{2})(-[a-z]+)?\.(vtt|ttml)/i)
80 if (!matched || !matched[1]) return acc
81
82 return [
83 ...acc,
84 {
85 language: matched[1],
86 path: join(cwd, filename),
87 filename
88 }
89 ]
90 }, [])
91 59
92 return res(subtitles) 60 const obj = this.buildVideoInfo(this.normalizeObject(info))
61 if (obj.name && obj.name.length < CONSTRAINTS_FIELDS.VIDEOS.NAME.min) obj.name += ' video'
62
63 return res(obj)
64 })
93 }) 65 })
94 }) 66 .catch(err => rej(err))
95 .catch(err => rej(err)) 67 })
96 }) 68 }
97}
98 69
99function getYoutubeDLVideoFormat () { 70 getYoutubeDLSubs (opts?: object): Promise<YoutubeDLSubs> {
100 /** 71 return new Promise<YoutubeDLSubs>((res, rej) => {
101 * list of format selectors in order or preference 72 const cwd = CONFIG.STORAGE.TMP_DIR
102 * see https://github.com/ytdl-org/youtube-dl#format-selection 73 const options = opts || { all: true, format: 'vtt', cwd }
103 * 74
104 * case #1 asks for a mp4 using h264 (avc1) and the exact resolution in the hope 75 YoutubeDL.safeGetYoutubeDL()
105 * of being able to do a "quick-transcode" 76 .then(youtubeDL => {
106 * case #2 is the first fallback. No "quick-transcode" means we can get anything else (like vp9) 77 youtubeDL.getSubs(this.url, options, (err, files) => {
107 * case #3 is the resolution-degraded equivalent of #1, and already a pretty safe fallback 78 if (err) return rej(err)
108 * 79 if (!files) return []
109 * in any case we avoid AV1, see https://github.com/Chocobozzz/PeerTube/issues/3499 80
110 **/ 81 logger.debug('Get subtitles from youtube dl.', { url: this.url, files })
111 const enabledResolutions = getEnabledResolutions('vod') 82
112 const resolution = enabledResolutions.length === 0 83 const subtitles = files.reduce((acc, filename) => {
113 ? VideoResolution.H_720P 84 const matched = filename.match(/\.([a-z]{2})(-[a-z]+)?\.(vtt|ttml)/i)
114 : Math.max(...enabledResolutions) 85 if (!matched || !matched[1]) return acc
115 86
116 return [ 87 return [
117 `bestvideo[vcodec^=avc1][height=${resolution}]+bestaudio[ext=m4a]`, // case #1 88 ...acc,
118 `bestvideo[vcodec!*=av01][vcodec!*=vp9.2][height=${resolution}]+bestaudio`, // case #2 89 {
119 `bestvideo[vcodec^=avc1][height<=${resolution}]+bestaudio[ext=m4a]`, // case #3 90 language: matched[1],
120 `bestvideo[vcodec!*=av01][vcodec!*=vp9.2]+bestaudio`, 91 path: join(cwd, filename),
121 'best[vcodec!*=av01][vcodec!*=vp9.2]', // case fallback for known formats 92 filename
122 'best' // Ultimate fallback 93 }
123 ].join('/') 94 ]
124} 95 }, [])
96
97 return res(subtitles)
98 })
99 })
100 .catch(err => rej(err))
101 })
102 }
125 103
126function downloadYoutubeDLVideo (url: string, fileExt: string, timeout: number) { 104 getYoutubeDLVideoFormat () {
127 // Leave empty the extension, youtube-dl will add it 105 /**
128 const pathWithoutExtension = generateVideoImportTmpPath(url, '') 106 * list of format selectors in order or preference
107 * see https://github.com/ytdl-org/youtube-dl#format-selection
108 *
109 * case #1 asks for a mp4 using h264 (avc1) and the exact resolution in the hope
110 * of being able to do a "quick-transcode"
111 * case #2 is the first fallback. No "quick-transcode" means we can get anything else (like vp9)
112 * case #3 is the resolution-degraded equivalent of #1, and already a pretty safe fallback
113 *
114 * in any case we avoid AV1, see https://github.com/Chocobozzz/PeerTube/issues/3499
115 **/
116 const resolution = this.enabledResolutions.length === 0
117 ? VideoResolution.H_720P
118 : Math.max(...this.enabledResolutions)
119
120 return [
121 `bestvideo[vcodec^=avc1][height=${resolution}]+bestaudio[ext=m4a]`, // case #1
122 `bestvideo[vcodec!*=av01][vcodec!*=vp9.2][height=${resolution}]+bestaudio`, // case #2
123 `bestvideo[vcodec^=avc1][height<=${resolution}]+bestaudio[ext=m4a]`, // case #3
124 `bestvideo[vcodec!*=av01][vcodec!*=vp9.2]+bestaudio`,
125 'best[vcodec!*=av01][vcodec!*=vp9.2]', // case fallback for known formats
126 'best' // Ultimate fallback
127 ].join('/')
128 }
129 129
130 let timer 130 downloadYoutubeDLVideo (fileExt: string, timeout: number) {
131 // Leave empty the extension, youtube-dl will add it
132 const pathWithoutExtension = generateVideoImportTmpPath(this.url, '')
131 133
132 logger.info('Importing youtubeDL video %s to %s', url, pathWithoutExtension) 134 let timer
133 135
134 let options = [ '-f', getYoutubeDLVideoFormat(), '-o', pathWithoutExtension ] 136 logger.info('Importing youtubeDL video %s to %s', this.url, pathWithoutExtension)
135 options = wrapWithProxyOptions(options)
136 137
137 if (process.env.FFMPEG_PATH) { 138 let options = [ '-f', this.getYoutubeDLVideoFormat(), '-o', pathWithoutExtension ]
138 options = options.concat([ '--ffmpeg-location', process.env.FFMPEG_PATH ]) 139 options = this.wrapWithProxyOptions(options)
139 }
140 140
141 logger.debug('YoutubeDL options for %s.', url, { options }) 141 if (process.env.FFMPEG_PATH) {
142 options = options.concat([ '--ffmpeg-location', process.env.FFMPEG_PATH ])
143 }
142 144
143 return new Promise<string>((res, rej) => { 145 logger.debug('YoutubeDL options for %s.', this.url, { options })
144 safeGetYoutubeDL()
145 .then(youtubeDL => {
146 youtubeDL.exec(url, options, processOptions, async err => {
147 clearTimeout(timer)
148 146
149 try { 147 return new Promise<string>((res, rej) => {
150 // If youtube-dl did not guess an extension for our file, just use .mp4 as default 148 YoutubeDL.safeGetYoutubeDL()
151 if (await pathExists(pathWithoutExtension)) { 149 .then(youtubeDL => {
152 await move(pathWithoutExtension, pathWithoutExtension + '.mp4') 150 youtubeDL.exec(this.url, options, processOptions, async err => {
153 } 151 clearTimeout(timer)
152
153 try {
154 // If youtube-dl did not guess an extension for our file, just use .mp4 as default
155 if (await pathExists(pathWithoutExtension)) {
156 await move(pathWithoutExtension, pathWithoutExtension + '.mp4')
157 }
154 158
155 const path = await guessVideoPathWithExtension(pathWithoutExtension, fileExt) 159 const path = await this.guessVideoPathWithExtension(pathWithoutExtension, fileExt)
156 160
157 if (err) { 161 if (err) {
158 remove(path) 162 remove(path)
159 .catch(err => logger.error('Cannot delete path on YoutubeDL error.', { err })) 163 .catch(err => logger.error('Cannot delete path on YoutubeDL error.', { err }))
160 164
165 return rej(err)
166 }
167
168 return res(path)
169 } catch (err) {
161 return rej(err) 170 return rej(err)
162 } 171 }
163 172 })
164 return res(path) 173
165 } catch (err) { 174 timer = setTimeout(() => {
166 return rej(err) 175 const err = new Error('YoutubeDL download timeout.')
167 } 176
177 this.guessVideoPathWithExtension(pathWithoutExtension, fileExt)
178 .then(path => remove(path))
179 .finally(() => rej(err))
180 .catch(err => {
181 logger.error('Cannot remove file in youtubeDL timeout.', { err })
182 return rej(err)
183 })
184 }, timeout)
168 }) 185 })
186 .catch(err => rej(err))
187 })
188 }
169 189
170 timer = setTimeout(() => { 190 buildOriginallyPublishedAt (obj: any) {
171 const err = new Error('YoutubeDL download timeout.') 191 let originallyPublishedAt: Date = null
172 192
173 guessVideoPathWithExtension(pathWithoutExtension, fileExt) 193 const uploadDateMatcher = /^(\d{4})(\d{2})(\d{2})$/.exec(obj.upload_date)
174 .then(path => remove(path)) 194 if (uploadDateMatcher) {
175 .finally(() => rej(err)) 195 originallyPublishedAt = new Date()
176 .catch(err => { 196 originallyPublishedAt.setHours(0, 0, 0, 0)
177 logger.error('Cannot remove file in youtubeDL timeout.', { err })
178 return rej(err)
179 })
180 }, timeout)
181 })
182 .catch(err => rej(err))
183 })
184}
185
186// Thanks: https://github.com/przemyslawpluta/node-youtube-dl/blob/master/lib/downloader.js
187// We rewrote it to avoid sync calls
188async function updateYoutubeDLBinary () {
189 logger.info('Updating youtubeDL binary.')
190 197
191 const binDirectory = join(root(), 'node_modules', 'youtube-dl', 'bin') 198 const year = parseInt(uploadDateMatcher[1], 10)
192 const bin = join(binDirectory, 'youtube-dl') 199 // Month starts from 0
193 const detailsPath = join(binDirectory, 'details') 200 const month = parseInt(uploadDateMatcher[2], 10) - 1
194 const url = process.env.YOUTUBE_DL_DOWNLOAD_HOST || 'https://yt-dl.org/downloads/latest/youtube-dl' 201 const day = parseInt(uploadDateMatcher[3], 10)
195 202
196 await ensureDir(binDirectory) 203 originallyPublishedAt.setFullYear(year, month, day)
204 }
197 205
198 try { 206 return originallyPublishedAt
199 const result = await got(url, { followRedirect: false }) 207 }
200 208
201 if (result.statusCode !== HttpStatusCode.FOUND_302) { 209 private async guessVideoPathWithExtension (tmpPath: string, sourceExt: string) {
202 logger.error('youtube-dl update error: did not get redirect for the latest version link. Status %d', result.statusCode) 210 if (!isVideoFileExtnameValid(sourceExt)) {
203 return 211 throw new Error('Invalid video extension ' + sourceExt)
204 } 212 }
205 213
206 const newUrl = result.headers.location 214 const extensions = [ sourceExt, '.mp4', '.mkv', '.webm' ]
207 const newVersion = /yt-dl\.org\/downloads\/(\d{4}\.\d\d\.\d\d(\.\d)?)\/youtube-dl/.exec(newUrl)[1]
208 215
209 const downloadFileStream = got.stream(newUrl) 216 for (const extension of extensions) {
210 const writeStream = createWriteStream(bin, { mode: 493 }) 217 const path = tmpPath + extension
211 218
212 await pipelinePromise( 219 if (await pathExists(path)) return path
213 downloadFileStream, 220 }
214 writeStream
215 )
216
217 const details = JSON.stringify({ version: newVersion, path: bin, exec: 'youtube-dl' })
218 await writeFile(detailsPath, details, { encoding: 'utf8' })
219 221
220 logger.info('youtube-dl updated to version %s.', newVersion) 222 throw new Error('Cannot guess path of ' + tmpPath)
221 } catch (err) {
222 logger.error('Cannot update youtube-dl.', { err })
223 } 223 }
224}
225 224
226async function safeGetYoutubeDL () { 225 private normalizeObject (obj: any) {
227 let youtubeDL 226 const newObj: any = {}
228 227
229 try { 228 for (const key of Object.keys(obj)) {
230 youtubeDL = require('youtube-dl') 229 // Deprecated key
231 } catch (e) { 230 if (key === 'resolution') continue
232 // Download binary
233 await updateYoutubeDLBinary()
234 youtubeDL = require('youtube-dl')
235 }
236 231
237 return youtubeDL 232 const value = obj[key]
238}
239 233
240function buildOriginallyPublishedAt (obj: any) { 234 if (typeof value === 'string') {
241 let originallyPublishedAt: Date = null 235 newObj[key] = value.normalize()
236 } else {
237 newObj[key] = value
238 }
239 }
242 240
243 const uploadDateMatcher = /^(\d{4})(\d{2})(\d{2})$/.exec(obj.upload_date) 241 return newObj
244 if (uploadDateMatcher) { 242 }
245 originallyPublishedAt = new Date()
246 originallyPublishedAt.setHours(0, 0, 0, 0)
247 243
248 const year = parseInt(uploadDateMatcher[1], 10) 244 private buildVideoInfo (obj: any): YoutubeDLInfo {
249 // Month starts from 0 245 return {
250 const month = parseInt(uploadDateMatcher[2], 10) - 1 246 name: this.titleTruncation(obj.title),
251 const day = parseInt(uploadDateMatcher[3], 10) 247 description: this.descriptionTruncation(obj.description),
248 category: this.getCategory(obj.categories),
249 licence: this.getLicence(obj.license),
250 language: this.getLanguage(obj.language),
251 nsfw: this.isNSFW(obj),
252 tags: this.getTags(obj.tags),
253 thumbnailUrl: obj.thumbnail || undefined,
254 originallyPublishedAt: this.buildOriginallyPublishedAt(obj),
255 ext: obj.ext
256 }
257 }
252 258
253 originallyPublishedAt.setFullYear(year, month, day) 259 private titleTruncation (title: string) {
260 return peertubeTruncate(title, {
261 length: CONSTRAINTS_FIELDS.VIDEOS.NAME.max,
262 separator: /,? +/,
263 omission: ' […]'
264 })
254 } 265 }
255 266
256 return originallyPublishedAt 267 private descriptionTruncation (description: string) {
257} 268 if (!description || description.length < CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.min) return undefined
258 269
259// --------------------------------------------------------------------------- 270 return peertubeTruncate(description, {
271 length: CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max,
272 separator: /,? +/,
273 omission: ' […]'
274 })
275 }
260 276
261export { 277 private isNSFW (info: any) {
262 updateYoutubeDLBinary, 278 return info.age_limit && info.age_limit >= 16
263 getYoutubeDLVideoFormat, 279 }
264 downloadYoutubeDLVideo,
265 getYoutubeDLSubs,
266 getYoutubeDLInfo,
267 safeGetYoutubeDL,
268 buildOriginallyPublishedAt
269}
270 280
271// --------------------------------------------------------------------------- 281 private getTags (tags: any) {
282 if (Array.isArray(tags) === false) return []
272 283
273async function guessVideoPathWithExtension (tmpPath: string, sourceExt: string) { 284 return tags
274 if (!isVideoFileExtnameValid(sourceExt)) { 285 .filter(t => t.length < CONSTRAINTS_FIELDS.VIDEOS.TAG.max && t.length > CONSTRAINTS_FIELDS.VIDEOS.TAG.min)
275 throw new Error('Invalid video extension ' + sourceExt) 286 .map(t => t.normalize())
287 .slice(0, 5)
276 } 288 }
277 289
278 const extensions = [ sourceExt, '.mp4', '.mkv', '.webm' ] 290 private getLicence (licence: string) {
291 if (!licence) return undefined
279 292
280 for (const extension of extensions) { 293 if (licence.includes('Creative Commons Attribution')) return 1
281 const path = tmpPath + extension
282 294
283 if (await pathExists(path)) return path 295 for (const key of Object.keys(VIDEO_LICENCES)) {
284 } 296 const peertubeLicence = VIDEO_LICENCES[key]
297 if (peertubeLicence.toLowerCase() === licence.toLowerCase()) return parseInt(key, 10)
298 }
285 299
286 throw new Error('Cannot guess path of ' + tmpPath) 300 return undefined
287} 301 }
288 302
289function normalizeObject (obj: any) { 303 private getCategory (categories: string[]) {
290 const newObj: any = {} 304 if (!categories) return undefined
291 305
292 for (const key of Object.keys(obj)) { 306 const categoryString = categories[0]
293 // Deprecated key 307 if (!categoryString || typeof categoryString !== 'string') return undefined
294 if (key === 'resolution') continue
295 308
296 const value = obj[key] 309 if (categoryString === 'News & Politics') return 11
297 310
298 if (typeof value === 'string') { 311 for (const key of Object.keys(VIDEO_CATEGORIES)) {
299 newObj[key] = value.normalize() 312 const category = VIDEO_CATEGORIES[key]
300 } else { 313 if (categoryString.toLowerCase() === category.toLowerCase()) return parseInt(key, 10)
301 newObj[key] = value
302 } 314 }
303 }
304 315
305 return newObj 316 return undefined
306}
307
308function buildVideoInfo (obj: any): YoutubeDLInfo {
309 return {
310 name: titleTruncation(obj.title),
311 description: descriptionTruncation(obj.description),
312 category: getCategory(obj.categories),
313 licence: getLicence(obj.license),
314 language: getLanguage(obj.language),
315 nsfw: isNSFW(obj),
316 tags: getTags(obj.tags),
317 thumbnailUrl: obj.thumbnail || undefined,
318 originallyPublishedAt: buildOriginallyPublishedAt(obj),
319 ext: obj.ext
320 } 317 }
321}
322 318
323function titleTruncation (title: string) { 319 private getLanguage (language: string) {
324 return peertubeTruncate(title, { 320 return VIDEO_LANGUAGES[language] ? language : undefined
325 length: CONSTRAINTS_FIELDS.VIDEOS.NAME.max, 321 }
326 separator: /,? +/,
327 omission: ' […]'
328 })
329}
330 322
331function descriptionTruncation (description: string) { 323 private wrapWithProxyOptions (options: string[]) {
332 if (!description || description.length < CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.min) return undefined 324 if (CONFIG.IMPORT.VIDEOS.HTTP.PROXY.ENABLED) {
325 logger.debug('Using proxy for YoutubeDL')
333 326
334 return peertubeTruncate(description, { 327 return [ '--proxy', CONFIG.IMPORT.VIDEOS.HTTP.PROXY.URL ].concat(options)
335 length: CONSTRAINTS_FIELDS.VIDEOS.DESCRIPTION.max, 328 }
336 separator: /,? +/,
337 omission: ' […]'
338 })
339}
340 329
341function isNSFW (info: any) { 330 return options
342 return info.age_limit && info.age_limit >= 16 331 }
343}
344 332
345function getTags (tags: any) { 333 // Thanks: https://github.com/przemyslawpluta/node-youtube-dl/blob/master/lib/downloader.js
346 if (Array.isArray(tags) === false) return [] 334 // We rewrote it to avoid sync calls
335 static async updateYoutubeDLBinary () {
336 logger.info('Updating youtubeDL binary.')
347 337
348 return tags 338 const binDirectory = join(root(), 'node_modules', 'youtube-dl', 'bin')
349 .filter(t => t.length < CONSTRAINTS_FIELDS.VIDEOS.TAG.max && t.length > CONSTRAINTS_FIELDS.VIDEOS.TAG.min) 339 const bin = join(binDirectory, 'youtube-dl')
350 .map(t => t.normalize()) 340 const detailsPath = join(binDirectory, 'details')
351 .slice(0, 5) 341 const url = process.env.YOUTUBE_DL_DOWNLOAD_HOST || 'https://yt-dl.org/downloads/latest/youtube-dl'
352}
353 342
354function getLicence (licence: string) { 343 await ensureDir(binDirectory)
355 if (!licence) return undefined
356 344
357 if (licence.includes('Creative Commons Attribution')) return 1 345 try {
346 const result = await got(url, { followRedirect: false })
358 347
359 for (const key of Object.keys(VIDEO_LICENCES)) { 348 if (result.statusCode !== HttpStatusCode.FOUND_302) {
360 const peertubeLicence = VIDEO_LICENCES[key] 349 logger.error('youtube-dl update error: did not get redirect for the latest version link. Status %d', result.statusCode)
361 if (peertubeLicence.toLowerCase() === licence.toLowerCase()) return parseInt(key, 10) 350 return
362 } 351 }
363 352
364 return undefined 353 const newUrl = result.headers.location
365} 354 const newVersion = /yt-dl\.org\/downloads\/(\d{4}\.\d\d\.\d\d(\.\d)?)\/youtube-dl/.exec(newUrl)[1]
366 355
367function getCategory (categories: string[]) { 356 const downloadFileStream = got.stream(newUrl)
368 if (!categories) return undefined 357 const writeStream = createWriteStream(bin, { mode: 493 })
369 358
370 const categoryString = categories[0] 359 await pipelinePromise(
371 if (!categoryString || typeof categoryString !== 'string') return undefined 360 downloadFileStream,
361 writeStream
362 )
372 363
373 if (categoryString === 'News & Politics') return 11 364 const details = JSON.stringify({ version: newVersion, path: bin, exec: 'youtube-dl' })
365 await writeFile(detailsPath, details, { encoding: 'utf8' })
374 366
375 for (const key of Object.keys(VIDEO_CATEGORIES)) { 367 logger.info('youtube-dl updated to version %s.', newVersion)
376 const category = VIDEO_CATEGORIES[key] 368 } catch (err) {
377 if (categoryString.toLowerCase() === category.toLowerCase()) return parseInt(key, 10) 369 logger.error('Cannot update youtube-dl.', { err })
370 }
378 } 371 }
379 372
380 return undefined 373 static async safeGetYoutubeDL () {
381} 374 let youtubeDL
382
383function getLanguage (language: string) {
384 return VIDEO_LANGUAGES[language] ? language : undefined
385}
386 375
387function wrapWithProxyOptions (options: string[]) { 376 try {
388 if (CONFIG.IMPORT.VIDEOS.HTTP.PROXY.ENABLED) { 377 youtubeDL = require('youtube-dl')
389 logger.debug('Using proxy for YoutubeDL') 378 } catch (e) {
379 // Download binary
380 await this.updateYoutubeDLBinary()
381 youtubeDL = require('youtube-dl')
382 }
390 383
391 return [ '--proxy', CONFIG.IMPORT.VIDEOS.HTTP.PROXY.URL ].concat(options) 384 return youtubeDL
392 } 385 }
386}
387
388// ---------------------------------------------------------------------------
393 389
394 return options 390export {
391 YoutubeDL
395} 392}
diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts
index a93c8b7fd..911734fa0 100644
--- a/server/initializers/checker-after-init.ts
+++ b/server/initializers/checker-after-init.ts
@@ -7,7 +7,7 @@ import { RecentlyAddedStrategy } from '../../shared/models/redundancy'
7import { isProdInstance, isTestInstance, parseSemVersion } from '../helpers/core-utils' 7import { isProdInstance, isTestInstance, parseSemVersion } from '../helpers/core-utils'
8import { isArray } from '../helpers/custom-validators/misc' 8import { isArray } from '../helpers/custom-validators/misc'
9import { logger } from '../helpers/logger' 9import { logger } from '../helpers/logger'
10import { UserModel } from '../models/account/user' 10import { UserModel } from '../models/user/user'
11import { ApplicationModel, getServerActor } from '../models/application/application' 11import { ApplicationModel, getServerActor } from '../models/application/application'
12import { OAuthClientModel } from '../models/oauth/oauth-client' 12import { OAuthClientModel } from '../models/oauth/oauth-client'
13import { CONFIG, isEmailEnabled } from './config' 13import { CONFIG, isEmailEnabled } from './config'
diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts
index 2864b0287..93c019121 100644
--- a/server/initializers/checker-before-init.ts
+++ b/server/initializers/checker-before-init.ts
@@ -19,7 +19,7 @@ function checkMissedConfig () {
19 'csp.enabled', 'csp.report_only', 'csp.report_uri', 19 'csp.enabled', 'csp.report_only', 'csp.report_uri',
20 'security.frameguard.enabled', 20 'security.frameguard.enabled',
21 'cache.previews.size', 'cache.captions.size', 'cache.torrents.size', 'admin.email', 'contact_form.enabled', 21 'cache.previews.size', 'cache.captions.size', 'cache.torrents.size', 'admin.email', 'contact_form.enabled',
22 'signup.enabled', 'signup.limit', 'signup.requires_email_verification', 22 'signup.enabled', 'signup.limit', 'signup.requires_email_verification', 'signup.minimum_age',
23 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist', 23 'signup.filters.cidr.whitelist', 'signup.filters.cidr.blacklist',
24 'redundancy.videos.strategies', 'redundancy.videos.check_interval', 24 'redundancy.videos.strategies', 'redundancy.videos.check_interval',
25 'transcoding.enabled', 'transcoding.threads', 'transcoding.allow_additional_extensions', 'transcoding.hls.enabled', 25 'transcoding.enabled', 'transcoding.threads', 'transcoding.allow_additional_extensions', 'transcoding.hls.enabled',
diff --git a/server/initializers/config.ts b/server/initializers/config.ts
index 5281d3a66..30a9823b9 100644
--- a/server/initializers/config.ts
+++ b/server/initializers/config.ts
@@ -185,6 +185,7 @@ const CONFIG = {
185 get ENABLED () { return config.get<boolean>('signup.enabled') }, 185 get ENABLED () { return config.get<boolean>('signup.enabled') },
186 get LIMIT () { return config.get<number>('signup.limit') }, 186 get LIMIT () { return config.get<number>('signup.limit') },
187 get REQUIRES_EMAIL_VERIFICATION () { return config.get<boolean>('signup.requires_email_verification') }, 187 get REQUIRES_EMAIL_VERIFICATION () { return config.get<boolean>('signup.requires_email_verification') },
188 get MINIMUM_AGE () { return config.get<number>('signup.minimum_age') },
188 FILTERS: { 189 FILTERS: {
189 CIDR: { 190 CIDR: {
190 get WHITELIST () { return config.get<string[]>('signup.filters.cidr.whitelist') }, 191 get WHITELIST () { return config.get<string[]>('signup.filters.cidr.whitelist') },
diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts
index 6f388420e..ab59320eb 100644
--- a/server/initializers/constants.ts
+++ b/server/initializers/constants.ts
@@ -24,7 +24,7 @@ import { CONFIG, registerConfigChangedHandler } from './config'
24 24
25// --------------------------------------------------------------------------- 25// ---------------------------------------------------------------------------
26 26
27const LAST_MIGRATION_VERSION = 645 27const LAST_MIGRATION_VERSION = 650
28 28
29// --------------------------------------------------------------------------- 29// ---------------------------------------------------------------------------
30 30
@@ -77,6 +77,7 @@ const SORTABLE_COLUMNS = {
77 // Don't forget to update peertube-search-index with the same values 77 // Don't forget to update peertube-search-index with the same values
78 VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ], 78 VIDEOS_SEARCH: [ 'name', 'duration', 'createdAt', 'publishedAt', 'originallyPublishedAt', 'views', 'likes', 'match' ],
79 VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ], 79 VIDEO_CHANNELS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
80 VIDEO_PLAYLISTS_SEARCH: [ 'match', 'displayName', 'createdAt' ],
80 81
81 ABUSES: [ 'id', 'createdAt', 'state' ], 82 ABUSES: [ 'id', 'createdAt', 'state' ],
82 83
@@ -152,7 +153,7 @@ const JOB_ATTEMPTS: { [id in JobType]: number } = {
152const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-import'>]: number } = { 153const JOB_CONCURRENCY: { [id in Exclude<JobType, 'video-transcoding' | 'video-import'>]: number } = {
153 'activitypub-http-broadcast': 1, 154 'activitypub-http-broadcast': 1,
154 'activitypub-http-unicast': 5, 155 'activitypub-http-unicast': 5,
155 'activitypub-http-fetcher': 1, 156 'activitypub-http-fetcher': 3,
156 'activitypub-cleaner': 1, 157 'activitypub-cleaner': 1,
157 'activitypub-follow': 1, 158 'activitypub-follow': 1,
158 'video-file-import': 1, 159 'video-file-import': 1,
@@ -245,7 +246,7 @@ const CONSTRAINTS_FIELDS = {
245 CAPTION_FILE: { 246 CAPTION_FILE: {
246 EXTNAME: [ '.vtt', '.srt' ], 247 EXTNAME: [ '.vtt', '.srt' ],
247 FILE_SIZE: { 248 FILE_SIZE: {
248 max: 2 * 1024 * 1024 // 2MB 249 max: 4 * 1024 * 1024 // 4MB
249 } 250 }
250 } 251 }
251 }, 252 },
@@ -274,7 +275,7 @@ const CONSTRAINTS_FIELDS = {
274 IMAGE: { 275 IMAGE: {
275 EXTNAME: [ '.png', '.jpg', '.jpeg', '.webp' ], 276 EXTNAME: [ '.png', '.jpg', '.jpeg', '.webp' ],
276 FILE_SIZE: { 277 FILE_SIZE: {
277 max: 2 * 1024 * 1024 // 2MB 278 max: 4 * 1024 * 1024 // 4MB
278 } 279 }
279 }, 280 },
280 EXTNAME: [] as string[], 281 EXTNAME: [] as string[],
@@ -296,7 +297,7 @@ const CONSTRAINTS_FIELDS = {
296 IMAGE: { 297 IMAGE: {
297 EXTNAME: [ '.jpg', '.jpeg' ], 298 EXTNAME: [ '.jpg', '.jpeg' ],
298 FILE_SIZE: { 299 FILE_SIZE: {
299 max: 2 * 1024 * 1024 // 2MB 300 max: 4 * 1024 * 1024 // 4MB
300 } 301 }
301 } 302 }
302 }, 303 },
@@ -307,7 +308,7 @@ const CONSTRAINTS_FIELDS = {
307 IMAGE: { 308 IMAGE: {
308 EXTNAME: [ '.png', '.jpeg', '.jpg', '.gif', '.webp' ], 309 EXTNAME: [ '.png', '.jpeg', '.jpg', '.gif', '.webp' ],
309 FILE_SIZE: { 310 FILE_SIZE: {
310 max: 2 * 1024 * 1024 // 2MB 311 max: 4 * 1024 * 1024 // 4MB
311 } 312 }
312 } 313 }
313 }, 314 },
@@ -447,9 +448,10 @@ const MIMETYPES = {
447 'audio/ogg': '.ogg', 448 'audio/ogg': '.ogg',
448 'audio/x-ms-wma': '.wma', 449 'audio/x-ms-wma': '.wma',
449 'audio/wav': '.wav', 450 'audio/wav': '.wav',
451 'audio/x-wav': '.wav',
450 'audio/x-flac': '.flac', 452 'audio/x-flac': '.flac',
451 'audio/flac': '.flac', 453 'audio/flac': '.flac',
452 '‎audio/aac': '.aac', 454 'audio/aac': '.aac',
453 'audio/m4a': '.m4a', 455 'audio/m4a': '.m4a',
454 'audio/mp4': '.m4a', 456 'audio/mp4': '.m4a',
455 'audio/x-m4a': '.m4a', 457 'audio/x-m4a': '.m4a',
@@ -702,7 +704,8 @@ const CUSTOM_HTML_TAG_COMMENTS = {
702 TITLE: '<!-- title tag -->', 704 TITLE: '<!-- title tag -->',
703 DESCRIPTION: '<!-- description tag -->', 705 DESCRIPTION: '<!-- description tag -->',
704 CUSTOM_CSS: '<!-- custom css tag -->', 706 CUSTOM_CSS: '<!-- custom css tag -->',
705 META_TAGS: '<!-- meta tags -->' 707 META_TAGS: '<!-- meta tags -->',
708 SERVER_CONFIG: '<!-- server config -->'
706} 709}
707 710
708// --------------------------------------------------------------------------- 711// ---------------------------------------------------------------------------
diff --git a/server/initializers/database.ts b/server/initializers/database.ts
index edf12bc41..38e7a76d0 100644
--- a/server/initializers/database.ts
+++ b/server/initializers/database.ts
@@ -2,6 +2,9 @@ import { QueryTypes, Transaction } from 'sequelize'
2import { Sequelize as SequelizeTypescript } from 'sequelize-typescript' 2import { Sequelize as SequelizeTypescript } from 'sequelize-typescript'
3import { TrackerModel } from '@server/models/server/tracker' 3import { TrackerModel } from '@server/models/server/tracker'
4import { VideoTrackerModel } from '@server/models/server/video-tracker' 4import { VideoTrackerModel } from '@server/models/server/video-tracker'
5import { UserModel } from '@server/models/user/user'
6import { UserNotificationModel } from '@server/models/user/user-notification'
7import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
5import { isTestInstance } from '../helpers/core-utils' 8import { isTestInstance } from '../helpers/core-utils'
6import { logger } from '../helpers/logger' 9import { logger } from '../helpers/logger'
7import { AbuseModel } from '../models/abuse/abuse' 10import { AbuseModel } from '../models/abuse/abuse'
@@ -11,13 +14,9 @@ import { VideoCommentAbuseModel } from '../models/abuse/video-comment-abuse'
11import { AccountModel } from '../models/account/account' 14import { AccountModel } from '../models/account/account'
12import { AccountBlocklistModel } from '../models/account/account-blocklist' 15import { AccountBlocklistModel } from '../models/account/account-blocklist'
13import { AccountVideoRateModel } from '../models/account/account-video-rate' 16import { AccountVideoRateModel } from '../models/account/account-video-rate'
14import { ActorImageModel } from '../models/account/actor-image' 17import { ActorModel } from '../models/actor/actor'
15import { UserModel } from '../models/account/user' 18import { ActorFollowModel } from '../models/actor/actor-follow'
16import { UserNotificationModel } from '../models/account/user-notification' 19import { ActorImageModel } from '../models/actor/actor-image'
17import { UserNotificationSettingModel } from '../models/account/user-notification-setting'
18import { UserVideoHistoryModel } from '../models/account/user-video-history'
19import { ActorModel } from '../models/activitypub/actor'
20import { ActorFollowModel } from '../models/activitypub/actor-follow'
21import { ApplicationModel } from '../models/application/application' 20import { ApplicationModel } from '../models/application/application'
22import { OAuthClientModel } from '../models/oauth/oauth-client' 21import { OAuthClientModel } from '../models/oauth/oauth-client'
23import { OAuthTokenModel } from '../models/oauth/oauth-token' 22import { OAuthTokenModel } from '../models/oauth/oauth-token'
@@ -25,6 +24,7 @@ import { VideoRedundancyModel } from '../models/redundancy/video-redundancy'
25import { PluginModel } from '../models/server/plugin' 24import { PluginModel } from '../models/server/plugin'
26import { ServerModel } from '../models/server/server' 25import { ServerModel } from '../models/server/server'
27import { ServerBlocklistModel } from '../models/server/server-blocklist' 26import { ServerBlocklistModel } from '../models/server/server-blocklist'
27import { UserNotificationSettingModel } from '../models/user/user-notification-setting'
28import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update' 28import { ScheduleVideoUpdateModel } from '../models/video/schedule-video-update'
29import { TagModel } from '../models/video/tag' 29import { TagModel } from '../models/video/tag'
30import { ThumbnailModel } from '../models/video/thumbnail' 30import { ThumbnailModel } from '../models/video/thumbnail'
@@ -44,6 +44,7 @@ import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-pla
44import { VideoTagModel } from '../models/video/video-tag' 44import { VideoTagModel } from '../models/video/video-tag'
45import { VideoViewModel } from '../models/video/video-view' 45import { VideoViewModel } from '../models/video/video-view'
46import { CONFIG } from './config' 46import { CONFIG } from './config'
47import { ActorCustomPageModel } from '@server/models/account/actor-custom-page'
47 48
48require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string 49require('pg').defaults.parseInt8 = true // Avoid BIGINT to be converted to string
49 50
@@ -141,7 +142,8 @@ async function initDatabaseModels (silent: boolean) {
141 ThumbnailModel, 142 ThumbnailModel,
142 TrackerModel, 143 TrackerModel,
143 VideoTrackerModel, 144 VideoTrackerModel,
144 PluginModel 145 PluginModel,
146 ActorCustomPageModel
145 ]) 147 ])
146 148
147 // Check extensions exist in the database 149 // Check extensions exist in the database
diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts
index 8dcff64e2..676f88653 100644
--- a/server/initializers/installer.ts
+++ b/server/initializers/installer.ts
@@ -2,7 +2,7 @@ import * as passwordGenerator from 'password-generator'
2import { UserRole } from '../../shared' 2import { UserRole } from '../../shared'
3import { logger } from '../helpers/logger' 3import { logger } from '../helpers/logger'
4import { createApplicationActor, createUserAccountAndChannelAndPlaylist } from '../lib/user' 4import { createApplicationActor, createUserAccountAndChannelAndPlaylist } from '../lib/user'
5import { UserModel } from '../models/account/user' 5import { UserModel } from '../models/user/user'
6import { ApplicationModel } from '../models/application/application' 6import { ApplicationModel } from '../models/application/application'
7import { OAuthClientModel } from '../models/oauth/oauth-client' 7import { OAuthClientModel } from '../models/oauth/oauth-client'
8import { applicationExist, clientsExist, usersExist } from './checker-after-init' 8import { applicationExist, clientsExist, usersExist } from './checker-after-init'
diff --git a/server/initializers/migrations/0080-video-channels.ts b/server/initializers/migrations/0080-video-channels.ts
index 883224cb0..0e6952350 100644
--- a/server/initializers/migrations/0080-video-channels.ts
+++ b/server/initializers/migrations/0080-video-channels.ts
@@ -1,5 +1,5 @@
1import { buildUUID } from '@server/helpers/uuid'
1import * as Sequelize from 'sequelize' 2import * as Sequelize from 'sequelize'
2import { v4 as uuidv4 } from 'uuid'
3 3
4async function up (utils: { 4async function up (utils: {
5 transaction: Sequelize.Transaction 5 transaction: Sequelize.Transaction
@@ -23,7 +23,7 @@ async function up (utils: {
23 { 23 {
24 const authors = await utils.db.Author.findAll() 24 const authors = await utils.db.Author.findAll()
25 for (const author of authors) { 25 for (const author of authors) {
26 author.uuid = uuidv4() 26 author.uuid = buildUUID()
27 await author.save() 27 await author.save()
28 } 28 }
29 } 29 }
diff --git a/server/initializers/migrations/0345-video-playlists.ts b/server/initializers/migrations/0345-video-playlists.ts
index 89a14a6ee..8dd631dff 100644
--- a/server/initializers/migrations/0345-video-playlists.ts
+++ b/server/initializers/migrations/0345-video-playlists.ts
@@ -1,6 +1,6 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { buildUUID } from '@server/helpers/uuid'
2import { VideoPlaylistPrivacy, VideoPlaylistType } from '../../../shared/models/videos' 3import { VideoPlaylistPrivacy, VideoPlaylistType } from '../../../shared/models/videos'
3import { v4 as uuidv4 } from 'uuid'
4import { WEBSERVER } from '../constants' 4import { WEBSERVER } from '../constants'
5 5
6async function up (utils: { 6async function up (utils: {
@@ -57,7 +57,7 @@ CREATE TABLE IF NOT EXISTS "videoPlaylistElement"
57 const usernames = userResult.map(r => r.username) 57 const usernames = userResult.map(r => r.username)
58 58
59 for (const username of usernames) { 59 for (const username of usernames) {
60 const uuid = uuidv4() 60 const uuid = buildUUID()
61 61
62 const baseUrl = WEBSERVER.URL + '/video-playlists/' + uuid 62 const baseUrl = WEBSERVER.URL + '/video-playlists/' + uuid
63 const query = ` 63 const query = `
diff --git a/server/initializers/migrations/0560-user-feed-token.ts b/server/initializers/migrations/0560-user-feed-token.ts
index 7c61def17..042301352 100644
--- a/server/initializers/migrations/0560-user-feed-token.ts
+++ b/server/initializers/migrations/0560-user-feed-token.ts
@@ -1,5 +1,5 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { v4 as uuidv4 } from 'uuid' 2import { buildUUID } from '@server/helpers/uuid'
3 3
4async function up (utils: { 4async function up (utils: {
5 transaction: Sequelize.Transaction 5 transaction: Sequelize.Transaction
@@ -26,7 +26,7 @@ async function up (utils: {
26 const users = await utils.sequelize.query<any>(query, options) 26 const users = await utils.sequelize.query<any>(query, options)
27 27
28 for (const user of users) { 28 for (const user of users) {
29 const queryUpdate = `UPDATE "user" SET "feedToken" = '${uuidv4()}' WHERE id = ${user.id}` 29 const queryUpdate = `UPDATE "user" SET "feedToken" = '${buildUUID()}' WHERE id = ${user.id}`
30 await utils.sequelize.query(queryUpdate) 30 await utils.sequelize.query(queryUpdate)
31 } 31 }
32 } 32 }
diff --git a/server/initializers/migrations/0650-actor-custom-pages.ts b/server/initializers/migrations/0650-actor-custom-pages.ts
new file mode 100644
index 000000000..1338327e8
--- /dev/null
+++ b/server/initializers/migrations/0650-actor-custom-pages.ts
@@ -0,0 +1,33 @@
1import * as Sequelize from 'sequelize'
2
3async function up (utils: {
4 transaction: Sequelize.Transaction
5 queryInterface: Sequelize.QueryInterface
6 sequelize: Sequelize.Sequelize
7 db: any
8}): Promise<void> {
9 {
10 const query = `
11 CREATE TABLE IF NOT EXISTS "actorCustomPage" (
12 "id" serial,
13 "content" TEXT,
14 "type" varchar(255) NOT NULL,
15 "actorId" integer NOT NULL REFERENCES "actor" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
16 "createdAt" timestamp WITH time zone NOT NULL,
17 "updatedAt" timestamp WITH time zone NOT NULL,
18 PRIMARY KEY ("id")
19 );
20 `
21
22 await utils.sequelize.query(query)
23 }
24}
25
26function down (options) {
27 throw new Error('Not implemented.')
28}
29
30export {
31 up,
32 down
33}
diff --git a/server/lib/activitypub/actor.ts b/server/lib/activitypub/actor.ts
deleted file mode 100644
index 5fe7381c9..000000000
--- a/server/lib/activitypub/actor.ts
+++ /dev/null
@@ -1,594 +0,0 @@
1import * as Bluebird from 'bluebird'
2import { extname } from 'path'
3import { Op, Transaction } from 'sequelize'
4import { URL } from 'url'
5import { v4 as uuidv4 } from 'uuid'
6import { getServerActor } from '@server/models/application/application'
7import { ActorImageType } from '@shared/models'
8import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
9import { ActivityPubActor, ActivityPubActorType, ActivityPubOrderedCollection } from '../../../shared/models/activitypub'
10import { ActivityPubAttributedTo } from '../../../shared/models/activitypub/objects'
11import { checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
12import { ActorFetchByUrlType, fetchActorByUrl } from '../../helpers/actor'
13import { sanitizeAndCheckActorObject } from '../../helpers/custom-validators/activitypub/actor'
14import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
15import { retryTransactionWrapper, updateInstanceWithAnother } from '../../helpers/database-utils'
16import { logger } from '../../helpers/logger'
17import { createPrivateAndPublicKeys } from '../../helpers/peertube-crypto'
18import { doJSONRequest, PeerTubeRequestError } from '../../helpers/requests'
19import { getUrlFromWebfinger } from '../../helpers/webfinger'
20import { MIMETYPES, WEBSERVER } from '../../initializers/constants'
21import { sequelizeTypescript } from '../../initializers/database'
22import { AccountModel } from '../../models/account/account'
23import { ActorImageModel } from '../../models/account/actor-image'
24import { ActorModel } from '../../models/activitypub/actor'
25import { ServerModel } from '../../models/server/server'
26import { VideoChannelModel } from '../../models/video/video-channel'
27import {
28 MAccount,
29 MAccountDefault,
30 MActor,
31 MActorAccountChannelId,
32 MActorAccountChannelIdActor,
33 MActorAccountId,
34 MActorFull,
35 MActorFullActor,
36 MActorId,
37 MActorImage,
38 MActorImages,
39 MChannel
40} from '../../types/models'
41import { JobQueue } from '../job-queue'
42
43// Set account keys, this could be long so process after the account creation and do not block the client
44async function generateAndSaveActorKeys <T extends MActor> (actor: T) {
45 const { publicKey, privateKey } = await createPrivateAndPublicKeys()
46
47 actor.publicKey = publicKey
48 actor.privateKey = privateKey
49
50 return actor.save()
51}
52
53function getOrCreateActorAndServerAndModel (
54 activityActor: string | ActivityPubActor,
55 fetchType: 'all',
56 recurseIfNeeded?: boolean,
57 updateCollections?: boolean
58): Promise<MActorFullActor>
59
60function getOrCreateActorAndServerAndModel (
61 activityActor: string | ActivityPubActor,
62 fetchType?: 'association-ids',
63 recurseIfNeeded?: boolean,
64 updateCollections?: boolean
65): Promise<MActorAccountChannelId>
66
67async function getOrCreateActorAndServerAndModel (
68 activityActor: string | ActivityPubActor,
69 fetchType: ActorFetchByUrlType = 'association-ids',
70 recurseIfNeeded = true,
71 updateCollections = false
72): Promise<MActorFullActor | MActorAccountChannelId> {
73 const actorUrl = getAPId(activityActor)
74 let created = false
75 let accountPlaylistsUrl: string
76
77 let actor = await fetchActorByUrl(actorUrl, fetchType)
78 // Orphan actor (not associated to an account of channel) so recreate it
79 if (actor && (!actor.Account && !actor.VideoChannel)) {
80 await actor.destroy()
81 actor = null
82 }
83
84 // We don't have this actor in our database, fetch it on remote
85 if (!actor) {
86 const { result } = await fetchRemoteActor(actorUrl)
87 if (result === undefined) throw new Error('Cannot fetch remote actor ' + actorUrl)
88
89 // Create the attributed to actor
90 // In PeerTube a video channel is owned by an account
91 let ownerActor: MActorFullActor
92 if (recurseIfNeeded === true && result.actor.type === 'Group') {
93 const accountAttributedTo = result.attributedTo.find(a => a.type === 'Person')
94 if (!accountAttributedTo) throw new Error('Cannot find account attributed to video channel ' + actor.url)
95
96 if (checkUrlsSameHost(accountAttributedTo.id, actorUrl) !== true) {
97 throw new Error(`Account attributed to ${accountAttributedTo.id} does not have the same host than actor url ${actorUrl}`)
98 }
99
100 try {
101 // Don't recurse another time
102 const recurseIfNeeded = false
103 ownerActor = await getOrCreateActorAndServerAndModel(accountAttributedTo.id, 'all', recurseIfNeeded)
104 } catch (err) {
105 logger.error('Cannot get or create account attributed to video channel ' + actorUrl)
106 throw new Error(err)
107 }
108 }
109
110 actor = await retryTransactionWrapper(saveActorAndServerAndModelIfNotExist, result, ownerActor)
111 created = true
112 accountPlaylistsUrl = result.playlists
113 }
114
115 if (actor.Account) (actor as MActorAccountChannelIdActor).Account.Actor = actor
116 if (actor.VideoChannel) (actor as MActorAccountChannelIdActor).VideoChannel.Actor = actor
117
118 const { actor: actorRefreshed, refreshed } = await retryTransactionWrapper(refreshActorIfNeeded, actor, fetchType)
119 if (!actorRefreshed) throw new Error('Actor ' + actor.url + ' does not exist anymore.')
120
121 if ((created === true || refreshed === true) && updateCollections === true) {
122 const payload = { uri: actor.outboxUrl, type: 'activity' as 'activity' }
123 await JobQueue.Instance.createJobWithPromise({ type: 'activitypub-http-fetcher', payload })
124 }
125
126 // We created a new account: fetch the playlists
127 if (created === true && actor.Account && accountPlaylistsUrl) {
128 const payload = { uri: accountPlaylistsUrl, accountId: actor.Account.id, type: 'account-playlists' as 'account-playlists' }
129 await JobQueue.Instance.createJobWithPromise({ type: 'activitypub-http-fetcher', payload })
130 }
131
132 return actorRefreshed
133}
134
135function buildActorInstance (type: ActivityPubActorType, url: string, preferredUsername: string, uuid?: string) {
136 return new ActorModel({
137 type,
138 url,
139 preferredUsername,
140 uuid,
141 publicKey: null,
142 privateKey: null,
143 followersCount: 0,
144 followingCount: 0,
145 inboxUrl: url + '/inbox',
146 outboxUrl: url + '/outbox',
147 sharedInboxUrl: WEBSERVER.URL + '/inbox',
148 followersUrl: url + '/followers',
149 followingUrl: url + '/following'
150 }) as MActor
151}
152
153async function updateActorInstance (actorInstance: ActorModel, attributes: ActivityPubActor) {
154 const followersCount = await fetchActorTotalItems(attributes.followers)
155 const followingCount = await fetchActorTotalItems(attributes.following)
156
157 actorInstance.type = attributes.type
158 actorInstance.preferredUsername = attributes.preferredUsername
159 actorInstance.url = attributes.id
160 actorInstance.publicKey = attributes.publicKey.publicKeyPem
161 actorInstance.followersCount = followersCount
162 actorInstance.followingCount = followingCount
163 actorInstance.inboxUrl = attributes.inbox
164 actorInstance.outboxUrl = attributes.outbox
165 actorInstance.followersUrl = attributes.followers
166 actorInstance.followingUrl = attributes.following
167
168 if (attributes.published) actorInstance.remoteCreatedAt = new Date(attributes.published)
169
170 if (attributes.endpoints?.sharedInbox) {
171 actorInstance.sharedInboxUrl = attributes.endpoints.sharedInbox
172 }
173}
174
175type ImageInfo = {
176 name: string
177 fileUrl: string
178 height: number
179 width: number
180 onDisk?: boolean
181}
182async function updateActorImageInstance (actor: MActorImages, type: ActorImageType, imageInfo: ImageInfo | null, t: Transaction) {
183 const oldImageModel = type === ActorImageType.AVATAR
184 ? actor.Avatar
185 : actor.Banner
186
187 if (oldImageModel) {
188 // Don't update the avatar if the file URL did not change
189 if (imageInfo?.fileUrl && oldImageModel.fileUrl === imageInfo.fileUrl) return actor
190
191 try {
192 await oldImageModel.destroy({ transaction: t })
193
194 setActorImage(actor, type, null)
195 } catch (err) {
196 logger.error('Cannot remove old actor image of actor %s.', actor.url, { err })
197 }
198 }
199
200 if (imageInfo) {
201 const imageModel = await ActorImageModel.create({
202 filename: imageInfo.name,
203 onDisk: imageInfo.onDisk ?? false,
204 fileUrl: imageInfo.fileUrl,
205 height: imageInfo.height,
206 width: imageInfo.width,
207 type
208 }, { transaction: t })
209
210 setActorImage(actor, type, imageModel)
211 }
212
213 return actor
214}
215
216async function deleteActorImageInstance (actor: MActorImages, type: ActorImageType, t: Transaction) {
217 try {
218 if (type === ActorImageType.AVATAR) {
219 await actor.Avatar.destroy({ transaction: t })
220
221 actor.avatarId = null
222 actor.Avatar = null
223 } else {
224 await actor.Banner.destroy({ transaction: t })
225
226 actor.bannerId = null
227 actor.Banner = null
228 }
229 } catch (err) {
230 logger.error('Cannot remove old image of actor %s.', actor.url, { err })
231 }
232
233 return actor
234}
235
236async function fetchActorTotalItems (url: string) {
237 try {
238 const { body } = await doJSONRequest<ActivityPubOrderedCollection<unknown>>(url, { activityPub: true })
239
240 return body.totalItems || 0
241 } catch (err) {
242 logger.warn('Cannot fetch remote actor count %s.', url, { err })
243 return 0
244 }
245}
246
247function getImageInfoIfExists (actorJSON: ActivityPubActor, type: ActorImageType) {
248 const mimetypes = MIMETYPES.IMAGE
249 const icon = type === ActorImageType.AVATAR
250 ? actorJSON.icon
251 : actorJSON.image
252
253 if (!icon || icon.type !== 'Image' || !isActivityPubUrlValid(icon.url)) return undefined
254
255 let extension: string
256
257 if (icon.mediaType) {
258 extension = mimetypes.MIMETYPE_EXT[icon.mediaType]
259 } else {
260 const tmp = extname(icon.url)
261
262 if (mimetypes.EXT_MIMETYPE[tmp] !== undefined) extension = tmp
263 }
264
265 if (!extension) return undefined
266
267 return {
268 name: uuidv4() + extension,
269 fileUrl: icon.url,
270 height: icon.height,
271 width: icon.width,
272 type
273 }
274}
275
276async function addFetchOutboxJob (actor: Pick<ActorModel, 'id' | 'outboxUrl'>) {
277 // Don't fetch ourselves
278 const serverActor = await getServerActor()
279 if (serverActor.id === actor.id) {
280 logger.error('Cannot fetch our own outbox!')
281 return undefined
282 }
283
284 const payload = {
285 uri: actor.outboxUrl,
286 type: 'activity' as 'activity'
287 }
288
289 return JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
290}
291
292async function refreshActorIfNeeded <T extends MActorFull | MActorAccountChannelId> (
293 actorArg: T,
294 fetchedType: ActorFetchByUrlType
295): Promise<{ actor: T | MActorFull, refreshed: boolean }> {
296 if (!actorArg.isOutdated()) return { actor: actorArg, refreshed: false }
297
298 // We need more attributes
299 const actor = fetchedType === 'all'
300 ? actorArg as MActorFull
301 : await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorArg.url)
302
303 try {
304 let actorUrl: string
305 try {
306 actorUrl = await getUrlFromWebfinger(actor.preferredUsername + '@' + actor.getHost())
307 } catch (err) {
308 logger.warn('Cannot get actor URL from webfinger, keeping the old one.', err)
309 actorUrl = actor.url
310 }
311
312 const { result } = await fetchRemoteActor(actorUrl)
313
314 if (result === undefined) {
315 logger.warn('Cannot fetch remote actor in refresh actor.')
316 return { actor, refreshed: false }
317 }
318
319 return sequelizeTypescript.transaction(async t => {
320 updateInstanceWithAnother(actor, result.actor)
321
322 await updateActorImageInstance(actor, ActorImageType.AVATAR, result.avatar, t)
323 await updateActorImageInstance(actor, ActorImageType.BANNER, result.banner, t)
324
325 // Force update
326 actor.setDataValue('updatedAt', new Date())
327 await actor.save({ transaction: t })
328
329 if (actor.Account) {
330 actor.Account.name = result.name
331 actor.Account.description = result.summary
332
333 await actor.Account.save({ transaction: t })
334 } else if (actor.VideoChannel) {
335 actor.VideoChannel.name = result.name
336 actor.VideoChannel.description = result.summary
337 actor.VideoChannel.support = result.support
338
339 await actor.VideoChannel.save({ transaction: t })
340 }
341
342 return { refreshed: true, actor }
343 })
344 } catch (err) {
345 if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) {
346 logger.info('Deleting actor %s because there is a 404 in refresh actor.', actor.url)
347 actor.Account
348 ? await actor.Account.destroy()
349 : await actor.VideoChannel.destroy()
350
351 return { actor: undefined, refreshed: false }
352 }
353
354 logger.warn('Cannot refresh actor %s.', actor.url, { err })
355 return { actor, refreshed: false }
356 }
357}
358
359export {
360 getOrCreateActorAndServerAndModel,
361 buildActorInstance,
362 generateAndSaveActorKeys,
363 fetchActorTotalItems,
364 getImageInfoIfExists,
365 updateActorInstance,
366 deleteActorImageInstance,
367 refreshActorIfNeeded,
368 updateActorImageInstance,
369 addFetchOutboxJob
370}
371
372// ---------------------------------------------------------------------------
373
374function setActorImage (actorModel: MActorImages, type: ActorImageType, imageModel: MActorImage) {
375 const id = imageModel
376 ? imageModel.id
377 : null
378
379 if (type === ActorImageType.AVATAR) {
380 actorModel.avatarId = id
381 actorModel.Avatar = imageModel
382 } else {
383 actorModel.bannerId = id
384 actorModel.Banner = imageModel
385 }
386
387 return actorModel
388}
389
390function saveActorAndServerAndModelIfNotExist (
391 result: FetchRemoteActorResult,
392 ownerActor?: MActorFullActor,
393 t?: Transaction
394): Bluebird<MActorFullActor> | Promise<MActorFullActor> {
395 const actor = result.actor
396
397 if (t !== undefined) return save(t)
398
399 return sequelizeTypescript.transaction(t => save(t))
400
401 async function save (t: Transaction) {
402 const actorHost = new URL(actor.url).host
403
404 const serverOptions = {
405 where: {
406 host: actorHost
407 },
408 defaults: {
409 host: actorHost
410 },
411 transaction: t
412 }
413 const [ server ] = await ServerModel.findOrCreate(serverOptions)
414
415 // Save our new account in database
416 actor.serverId = server.id
417
418 // Avatar?
419 if (result.avatar) {
420 const avatar = await ActorImageModel.create({
421 filename: result.avatar.name,
422 fileUrl: result.avatar.fileUrl,
423 width: result.avatar.width,
424 height: result.avatar.height,
425 onDisk: false,
426 type: ActorImageType.AVATAR
427 }, { transaction: t })
428
429 actor.avatarId = avatar.id
430 }
431
432 // Banner?
433 if (result.banner) {
434 const banner = await ActorImageModel.create({
435 filename: result.banner.name,
436 fileUrl: result.banner.fileUrl,
437 width: result.banner.width,
438 height: result.banner.height,
439 onDisk: false,
440 type: ActorImageType.BANNER
441 }, { transaction: t })
442
443 actor.bannerId = banner.id
444 }
445
446 // Force the actor creation, sometimes Sequelize skips the save() when it thinks the instance already exists
447 // (which could be false in a retried query)
448 const [ actorCreated, created ] = await ActorModel.findOrCreate<MActorFullActor>({
449 defaults: actor.toJSON(),
450 where: {
451 [Op.or]: [
452 {
453 url: actor.url
454 },
455 {
456 serverId: actor.serverId,
457 preferredUsername: actor.preferredUsername
458 }
459 ]
460 },
461 transaction: t
462 })
463
464 // Try to fix non HTTPS accounts of remote instances that fixed their URL afterwards
465 if (created !== true && actorCreated.url !== actor.url) {
466 // Only fix http://example.com/account/djidane to https://example.com/account/djidane
467 if (actorCreated.url.replace(/^http:\/\//, '') !== actor.url.replace(/^https:\/\//, '')) {
468 throw new Error(`Actor from DB with URL ${actorCreated.url} does not correspond to actor ${actor.url}`)
469 }
470
471 actorCreated.url = actor.url
472 await actorCreated.save({ transaction: t })
473 }
474
475 if (actorCreated.type === 'Person' || actorCreated.type === 'Application') {
476 actorCreated.Account = await saveAccount(actorCreated, result, t) as MAccountDefault
477 actorCreated.Account.Actor = actorCreated
478 } else if (actorCreated.type === 'Group') { // Video channel
479 const channel = await saveVideoChannel(actorCreated, result, ownerActor, t)
480 actorCreated.VideoChannel = Object.assign(channel, { Actor: actorCreated, Account: ownerActor.Account })
481 }
482
483 actorCreated.Server = server
484
485 return actorCreated
486 }
487}
488
489type ImageResult = {
490 name: string
491 fileUrl: string
492 height: number
493 width: number
494}
495
496type FetchRemoteActorResult = {
497 actor: MActor
498 name: string
499 summary: string
500 support?: string
501 playlists?: string
502 avatar?: ImageResult
503 banner?: ImageResult
504 attributedTo: ActivityPubAttributedTo[]
505}
506async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode?: number, result: FetchRemoteActorResult }> {
507 logger.info('Fetching remote actor %s.', actorUrl)
508
509 const requestResult = await doJSONRequest<ActivityPubActor>(actorUrl, { activityPub: true })
510 const actorJSON = requestResult.body
511
512 if (sanitizeAndCheckActorObject(actorJSON) === false) {
513 logger.debug('Remote actor JSON is not valid.', { actorJSON })
514 return { result: undefined, statusCode: requestResult.statusCode }
515 }
516
517 if (checkUrlsSameHost(actorJSON.id, actorUrl) !== true) {
518 logger.warn('Actor url %s has not the same host than its AP id %s', actorUrl, actorJSON.id)
519 return { result: undefined, statusCode: requestResult.statusCode }
520 }
521
522 const followersCount = await fetchActorTotalItems(actorJSON.followers)
523 const followingCount = await fetchActorTotalItems(actorJSON.following)
524
525 const actor = new ActorModel({
526 type: actorJSON.type,
527 preferredUsername: actorJSON.preferredUsername,
528 url: actorJSON.id,
529 publicKey: actorJSON.publicKey.publicKeyPem,
530 privateKey: null,
531 followersCount: followersCount,
532 followingCount: followingCount,
533 inboxUrl: actorJSON.inbox,
534 outboxUrl: actorJSON.outbox,
535 followersUrl: actorJSON.followers,
536 followingUrl: actorJSON.following,
537
538 sharedInboxUrl: actorJSON.endpoints?.sharedInbox
539 ? actorJSON.endpoints.sharedInbox
540 : null
541 })
542
543 const avatarInfo = getImageInfoIfExists(actorJSON, ActorImageType.AVATAR)
544 const bannerInfo = getImageInfoIfExists(actorJSON, ActorImageType.BANNER)
545
546 const name = actorJSON.name || actorJSON.preferredUsername
547 return {
548 statusCode: requestResult.statusCode,
549 result: {
550 actor,
551 name,
552 avatar: avatarInfo,
553 banner: bannerInfo,
554 summary: actorJSON.summary,
555 support: actorJSON.support,
556 playlists: actorJSON.playlists,
557 attributedTo: actorJSON.attributedTo
558 }
559 }
560}
561
562async function saveAccount (actor: MActorId, result: FetchRemoteActorResult, t: Transaction) {
563 const [ accountCreated ] = await AccountModel.findOrCreate({
564 defaults: {
565 name: result.name,
566 description: result.summary,
567 actorId: actor.id
568 },
569 where: {
570 actorId: actor.id
571 },
572 transaction: t
573 })
574
575 return accountCreated as MAccount
576}
577
578async function saveVideoChannel (actor: MActorId, result: FetchRemoteActorResult, ownerActor: MActorAccountId, t: Transaction) {
579 const [ videoChannelCreated ] = await VideoChannelModel.findOrCreate({
580 defaults: {
581 name: result.name,
582 description: result.summary,
583 support: result.support,
584 actorId: actor.id,
585 accountId: ownerActor.Account.id
586 },
587 where: {
588 actorId: actor.id
589 },
590 transaction: t
591 })
592
593 return videoChannelCreated as MChannel
594}
diff --git a/server/lib/activitypub/actors/get.ts b/server/lib/activitypub/actors/get.ts
new file mode 100644
index 000000000..8681ea02a
--- /dev/null
+++ b/server/lib/activitypub/actors/get.ts
@@ -0,0 +1,122 @@
1
2import { checkUrlsSameHost, getAPId } from '@server/helpers/activitypub'
3import { retryTransactionWrapper } from '@server/helpers/database-utils'
4import { logger } from '@server/helpers/logger'
5import { JobQueue } from '@server/lib/job-queue'
6import { ActorLoadByUrlType, loadActorByUrl } from '@server/lib/model-loaders'
7import { MActor, MActorAccountChannelId, MActorAccountChannelIdActor, MActorAccountId, MActorFullActor } from '@server/types/models'
8import { ActivityPubActor } from '@shared/models'
9import { refreshActorIfNeeded } from './refresh'
10import { APActorCreator, fetchRemoteActor } from './shared'
11
12function getOrCreateAPActor (
13 activityActor: string | ActivityPubActor,
14 fetchType: 'all',
15 recurseIfNeeded?: boolean,
16 updateCollections?: boolean
17): Promise<MActorFullActor>
18
19function getOrCreateAPActor (
20 activityActor: string | ActivityPubActor,
21 fetchType?: 'association-ids',
22 recurseIfNeeded?: boolean,
23 updateCollections?: boolean
24): Promise<MActorAccountChannelId>
25
26async function getOrCreateAPActor (
27 activityActor: string | ActivityPubActor,
28 fetchType: ActorLoadByUrlType = 'association-ids',
29 recurseIfNeeded = true,
30 updateCollections = false
31): Promise<MActorFullActor | MActorAccountChannelId> {
32 const actorUrl = getAPId(activityActor)
33 let actor = await loadActorFromDB(actorUrl, fetchType)
34
35 let created = false
36 let accountPlaylistsUrl: string
37
38 // We don't have this actor in our database, fetch it on remote
39 if (!actor) {
40 const { actorObject } = await fetchRemoteActor(actorUrl)
41 if (actorObject === undefined) throw new Error('Cannot fetch remote actor ' + actorUrl)
42
43 // actorUrl is just an alias/rediraction, so process object id instead
44 if (actorObject.id !== actorUrl) return getOrCreateAPActor(actorObject, 'all', recurseIfNeeded, updateCollections)
45
46 // Create the attributed to actor
47 // In PeerTube a video channel is owned by an account
48 let ownerActor: MActorFullActor
49 if (recurseIfNeeded === true && actorObject.type === 'Group') {
50 ownerActor = await getOrCreateAPOwner(actorObject, actorUrl)
51 }
52
53 const creator = new APActorCreator(actorObject, ownerActor)
54 actor = await retryTransactionWrapper(creator.create.bind(creator))
55 created = true
56 accountPlaylistsUrl = actorObject.playlists
57 }
58
59 if (actor.Account) (actor as MActorAccountChannelIdActor).Account.Actor = actor
60 if (actor.VideoChannel) (actor as MActorAccountChannelIdActor).VideoChannel.Actor = actor
61
62 const { actor: actorRefreshed, refreshed } = await refreshActorIfNeeded({ actor, fetchedType: fetchType })
63 if (!actorRefreshed) throw new Error('Actor ' + actor.url + ' does not exist anymore.')
64
65 await scheduleOutboxFetchIfNeeded(actor, created, refreshed, updateCollections)
66 await schedulePlaylistFetchIfNeeded(actor, created, accountPlaylistsUrl)
67
68 return actorRefreshed
69}
70
71// ---------------------------------------------------------------------------
72
73export {
74 getOrCreateAPActor
75}
76
77// ---------------------------------------------------------------------------
78
79async function loadActorFromDB (actorUrl: string, fetchType: ActorLoadByUrlType) {
80 let actor = await loadActorByUrl(actorUrl, fetchType)
81
82 // Orphan actor (not associated to an account of channel) so recreate it
83 if (actor && (!actor.Account && !actor.VideoChannel)) {
84 await actor.destroy()
85 actor = null
86 }
87
88 return actor
89}
90
91function getOrCreateAPOwner (actorObject: ActivityPubActor, actorUrl: string) {
92 const accountAttributedTo = actorObject.attributedTo.find(a => a.type === 'Person')
93 if (!accountAttributedTo) throw new Error('Cannot find account attributed to video channel ' + actorUrl)
94
95 if (checkUrlsSameHost(accountAttributedTo.id, actorUrl) !== true) {
96 throw new Error(`Account attributed to ${accountAttributedTo.id} does not have the same host than actor url ${actorUrl}`)
97 }
98
99 try {
100 // Don't recurse another time
101 const recurseIfNeeded = false
102 return getOrCreateAPActor(accountAttributedTo.id, 'all', recurseIfNeeded)
103 } catch (err) {
104 logger.error('Cannot get or create account attributed to video channel ' + actorUrl)
105 throw new Error(err)
106 }
107}
108
109async function scheduleOutboxFetchIfNeeded (actor: MActor, created: boolean, refreshed: boolean, updateCollections: boolean) {
110 if ((created === true || refreshed === true) && updateCollections === true) {
111 const payload = { uri: actor.outboxUrl, type: 'activity' as 'activity' }
112 await JobQueue.Instance.createJobWithPromise({ type: 'activitypub-http-fetcher', payload })
113 }
114}
115
116async function schedulePlaylistFetchIfNeeded (actor: MActorAccountId, created: boolean, accountPlaylistsUrl: string) {
117 // We created a new account: fetch the playlists
118 if (created === true && actor.Account && accountPlaylistsUrl) {
119 const payload = { uri: accountPlaylistsUrl, type: 'account-playlists' as 'account-playlists' }
120 await JobQueue.Instance.createJobWithPromise({ type: 'activitypub-http-fetcher', payload })
121 }
122}
diff --git a/server/lib/activitypub/actors/image.ts b/server/lib/activitypub/actors/image.ts
new file mode 100644
index 000000000..443ad0a63
--- /dev/null
+++ b/server/lib/activitypub/actors/image.ts
@@ -0,0 +1,94 @@
1import { Transaction } from 'sequelize/types'
2import { logger } from '@server/helpers/logger'
3import { ActorImageModel } from '@server/models/actor/actor-image'
4import { MActorImage, MActorImages } from '@server/types/models'
5import { ActorImageType } from '@shared/models'
6
7type ImageInfo = {
8 name: string
9 fileUrl: string
10 height: number
11 width: number
12 onDisk?: boolean
13}
14
15async function updateActorImageInstance (actor: MActorImages, type: ActorImageType, imageInfo: ImageInfo | null, t: Transaction) {
16 const oldImageModel = type === ActorImageType.AVATAR
17 ? actor.Avatar
18 : actor.Banner
19
20 if (oldImageModel) {
21 // Don't update the avatar if the file URL did not change
22 if (imageInfo?.fileUrl && oldImageModel.fileUrl === imageInfo.fileUrl) return actor
23
24 try {
25 await oldImageModel.destroy({ transaction: t })
26
27 setActorImage(actor, type, null)
28 } catch (err) {
29 logger.error('Cannot remove old actor image of actor %s.', actor.url, { err })
30 }
31 }
32
33 if (imageInfo) {
34 const imageModel = await ActorImageModel.create({
35 filename: imageInfo.name,
36 onDisk: imageInfo.onDisk ?? false,
37 fileUrl: imageInfo.fileUrl,
38 height: imageInfo.height,
39 width: imageInfo.width,
40 type
41 }, { transaction: t })
42
43 setActorImage(actor, type, imageModel)
44 }
45
46 return actor
47}
48
49async function deleteActorImageInstance (actor: MActorImages, type: ActorImageType, t: Transaction) {
50 try {
51 if (type === ActorImageType.AVATAR) {
52 await actor.Avatar.destroy({ transaction: t })
53
54 actor.avatarId = null
55 actor.Avatar = null
56 } else {
57 await actor.Banner.destroy({ transaction: t })
58
59 actor.bannerId = null
60 actor.Banner = null
61 }
62 } catch (err) {
63 logger.error('Cannot remove old image of actor %s.', actor.url, { err })
64 }
65
66 return actor
67}
68
69// ---------------------------------------------------------------------------
70
71export {
72 ImageInfo,
73
74 updateActorImageInstance,
75 deleteActorImageInstance
76}
77
78// ---------------------------------------------------------------------------
79
80function setActorImage (actorModel: MActorImages, type: ActorImageType, imageModel: MActorImage) {
81 const id = imageModel
82 ? imageModel.id
83 : null
84
85 if (type === ActorImageType.AVATAR) {
86 actorModel.avatarId = id
87 actorModel.Avatar = imageModel
88 } else {
89 actorModel.bannerId = id
90 actorModel.Banner = imageModel
91 }
92
93 return actorModel
94}
diff --git a/server/lib/activitypub/actors/index.ts b/server/lib/activitypub/actors/index.ts
new file mode 100644
index 000000000..5ee2a6f1a
--- /dev/null
+++ b/server/lib/activitypub/actors/index.ts
@@ -0,0 +1,6 @@
1export * from './get'
2export * from './image'
3export * from './keys'
4export * from './refresh'
5export * from './updater'
6export * from './webfinger'
diff --git a/server/lib/activitypub/actors/keys.ts b/server/lib/activitypub/actors/keys.ts
new file mode 100644
index 000000000..c3d18abd8
--- /dev/null
+++ b/server/lib/activitypub/actors/keys.ts
@@ -0,0 +1,16 @@
1import { createPrivateAndPublicKeys } from '@server/helpers/peertube-crypto'
2import { MActor } from '@server/types/models'
3
4// Set account keys, this could be long so process after the account creation and do not block the client
5async function generateAndSaveActorKeys <T extends MActor> (actor: T) {
6 const { publicKey, privateKey } = await createPrivateAndPublicKeys()
7
8 actor.publicKey = publicKey
9 actor.privateKey = privateKey
10
11 return actor.save()
12}
13
14export {
15 generateAndSaveActorKeys
16}
diff --git a/server/lib/activitypub/actors/refresh.ts b/server/lib/activitypub/actors/refresh.ts
new file mode 100644
index 000000000..b2fe3932f
--- /dev/null
+++ b/server/lib/activitypub/actors/refresh.ts
@@ -0,0 +1,81 @@
1import { logger, loggerTagsFactory } from '@server/helpers/logger'
2import { PromiseCache } from '@server/helpers/promise-cache'
3import { PeerTubeRequestError } from '@server/helpers/requests'
4import { ActorLoadByUrlType } from '@server/lib/model-loaders'
5import { ActorModel } from '@server/models/actor/actor'
6import { MActorAccountChannelId, MActorFull } from '@server/types/models'
7import { HttpStatusCode } from '@shared/core-utils'
8import { fetchRemoteActor } from './shared'
9import { APActorUpdater } from './updater'
10import { getUrlFromWebfinger } from './webfinger'
11
12type RefreshResult <T> = Promise<{ actor: T | MActorFull, refreshed: boolean }>
13
14type RefreshOptions <T> = {
15 actor: T
16 fetchedType: ActorLoadByUrlType
17}
18
19const promiseCache = new PromiseCache(doRefresh, (options: RefreshOptions<MActorFull | MActorAccountChannelId>) => options.actor.url)
20
21function refreshActorIfNeeded <T extends MActorFull | MActorAccountChannelId> (options: RefreshOptions<T>): RefreshResult <T> {
22 const actorArg = options.actor
23 if (!actorArg.isOutdated()) return Promise.resolve({ actor: actorArg, refreshed: false })
24
25 return promiseCache.run(options)
26}
27
28export {
29 refreshActorIfNeeded
30}
31
32// ---------------------------------------------------------------------------
33
34async function doRefresh <T extends MActorFull | MActorAccountChannelId> (options: RefreshOptions<T>): RefreshResult <MActorFull> {
35 const { actor: actorArg, fetchedType } = options
36
37 // We need more attributes
38 const actor = fetchedType === 'all'
39 ? actorArg as MActorFull
40 : await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorArg.url)
41
42 const lTags = loggerTagsFactory('ap', 'actor', 'refresh', actor.url)
43
44 logger.info('Refreshing actor %s.', actor.url, lTags())
45
46 try {
47 const actorUrl = await getActorUrl(actor)
48 const { actorObject } = await fetchRemoteActor(actorUrl)
49
50 if (actorObject === undefined) {
51 logger.warn('Cannot fetch remote actor in refresh actor.')
52 return { actor, refreshed: false }
53 }
54
55 const updater = new APActorUpdater(actorObject, actor)
56 await updater.update()
57
58 return { refreshed: true, actor }
59 } catch (err) {
60 if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) {
61 logger.info('Deleting actor %s because there is a 404 in refresh actor.', actor.url, lTags())
62
63 actor.Account
64 ? await actor.Account.destroy()
65 : await actor.VideoChannel.destroy()
66
67 return { actor: undefined, refreshed: false }
68 }
69
70 logger.warn('Cannot refresh actor %s.', actor.url, { err, ...lTags() })
71 return { actor, refreshed: false }
72 }
73}
74
75function getActorUrl (actor: MActorFull) {
76 return getUrlFromWebfinger(actor.preferredUsername + '@' + actor.getHost())
77 .catch(err => {
78 logger.warn('Cannot get actor URL from webfinger, keeping the old one.', err)
79 return actor.url
80 })
81}
diff --git a/server/lib/activitypub/actors/shared/creator.ts b/server/lib/activitypub/actors/shared/creator.ts
new file mode 100644
index 000000000..999aed97d
--- /dev/null
+++ b/server/lib/activitypub/actors/shared/creator.ts
@@ -0,0 +1,149 @@
1import { Op, Transaction } from 'sequelize'
2import { sequelizeTypescript } from '@server/initializers/database'
3import { AccountModel } from '@server/models/account/account'
4import { ActorModel } from '@server/models/actor/actor'
5import { ServerModel } from '@server/models/server/server'
6import { VideoChannelModel } from '@server/models/video/video-channel'
7import { MAccount, MAccountDefault, MActor, MActorFullActor, MActorId, MActorImages, MChannel, MServer } from '@server/types/models'
8import { ActivityPubActor, ActorImageType } from '@shared/models'
9import { updateActorImageInstance } from '../image'
10import { getActorAttributesFromObject, getActorDisplayNameFromObject, getImageInfoFromObject } from './object-to-model-attributes'
11import { fetchActorFollowsCount } from './url-to-object'
12
13export class APActorCreator {
14
15 constructor (
16 private readonly actorObject: ActivityPubActor,
17 private readonly ownerActor?: MActorFullActor
18 ) {
19
20 }
21
22 async create (): Promise<MActorFullActor> {
23 const { followersCount, followingCount } = await fetchActorFollowsCount(this.actorObject)
24
25 const actorInstance = new ActorModel(getActorAttributesFromObject(this.actorObject, followersCount, followingCount))
26
27 return sequelizeTypescript.transaction(async t => {
28 const server = await this.setServer(actorInstance, t)
29
30 await this.setImageIfNeeded(actorInstance, ActorImageType.AVATAR, t)
31 await this.setImageIfNeeded(actorInstance, ActorImageType.BANNER, t)
32
33 const { actorCreated, created } = await this.saveActor(actorInstance, t)
34
35 await this.tryToFixActorUrlIfNeeded(actorCreated, actorInstance, created, t)
36
37 if (actorCreated.type === 'Person' || actorCreated.type === 'Application') { // Account or PeerTube instance
38 actorCreated.Account = await this.saveAccount(actorCreated, t) as MAccountDefault
39 actorCreated.Account.Actor = actorCreated
40 }
41
42 if (actorCreated.type === 'Group') { // Video channel
43 const channel = await this.saveVideoChannel(actorCreated, t)
44 actorCreated.VideoChannel = Object.assign(channel, { Actor: actorCreated, Account: this.ownerActor.Account })
45 }
46
47 actorCreated.Server = server
48
49 return actorCreated
50 })
51 }
52
53 private async setServer (actor: MActor, t: Transaction) {
54 const actorHost = new URL(actor.url).host
55
56 const serverOptions = {
57 where: {
58 host: actorHost
59 },
60 defaults: {
61 host: actorHost
62 },
63 transaction: t
64 }
65 const [ server ] = await ServerModel.findOrCreate(serverOptions)
66
67 // Save our new account in database
68 actor.serverId = server.id
69
70 return server as MServer
71 }
72
73 private async setImageIfNeeded (actor: MActor, type: ActorImageType, t: Transaction) {
74 const imageInfo = getImageInfoFromObject(this.actorObject, type)
75 if (!imageInfo) return
76
77 return updateActorImageInstance(actor as MActorImages, type, imageInfo, t)
78 }
79
80 private async saveActor (actor: MActor, t: Transaction) {
81 // Force the actor creation using findOrCreate() instead of save()
82 // Sometimes Sequelize skips the save() when it thinks the instance already exists
83 // (which could be false in a retried query)
84 const [ actorCreated, created ] = await ActorModel.findOrCreate<MActorFullActor>({
85 defaults: actor.toJSON(),
86 where: {
87 [Op.or]: [
88 {
89 url: actor.url
90 },
91 {
92 serverId: actor.serverId,
93 preferredUsername: actor.preferredUsername
94 }
95 ]
96 },
97 transaction: t
98 })
99
100 return { actorCreated, created }
101 }
102
103 private async tryToFixActorUrlIfNeeded (actorCreated: MActor, newActor: MActor, created: boolean, t: Transaction) {
104 // Try to fix non HTTPS accounts of remote instances that fixed their URL afterwards
105 if (created !== true && actorCreated.url !== newActor.url) {
106 // Only fix http://example.com/account/djidane to https://example.com/account/djidane
107 if (actorCreated.url.replace(/^http:\/\//, '') !== newActor.url.replace(/^https:\/\//, '')) {
108 throw new Error(`Actor from DB with URL ${actorCreated.url} does not correspond to actor ${newActor.url}`)
109 }
110
111 actorCreated.url = newActor.url
112 await actorCreated.save({ transaction: t })
113 }
114 }
115
116 private async saveAccount (actor: MActorId, t: Transaction) {
117 const [ accountCreated ] = await AccountModel.findOrCreate({
118 defaults: {
119 name: getActorDisplayNameFromObject(this.actorObject),
120 description: this.actorObject.summary,
121 actorId: actor.id
122 },
123 where: {
124 actorId: actor.id
125 },
126 transaction: t
127 })
128
129 return accountCreated as MAccount
130 }
131
132 private async saveVideoChannel (actor: MActorId, t: Transaction) {
133 const [ videoChannelCreated ] = await VideoChannelModel.findOrCreate({
134 defaults: {
135 name: getActorDisplayNameFromObject(this.actorObject),
136 description: this.actorObject.summary,
137 support: this.actorObject.support,
138 actorId: actor.id,
139 accountId: this.ownerActor.Account.id
140 },
141 where: {
142 actorId: actor.id
143 },
144 transaction: t
145 })
146
147 return videoChannelCreated as MChannel
148 }
149}
diff --git a/server/lib/activitypub/actors/shared/index.ts b/server/lib/activitypub/actors/shared/index.ts
new file mode 100644
index 000000000..52af1a8e1
--- /dev/null
+++ b/server/lib/activitypub/actors/shared/index.ts
@@ -0,0 +1,3 @@
1export * from './creator'
2export * from './object-to-model-attributes'
3export * from './url-to-object'
diff --git a/server/lib/activitypub/actors/shared/object-to-model-attributes.ts b/server/lib/activitypub/actors/shared/object-to-model-attributes.ts
new file mode 100644
index 000000000..1612b3ad0
--- /dev/null
+++ b/server/lib/activitypub/actors/shared/object-to-model-attributes.ts
@@ -0,0 +1,70 @@
1import { getLowercaseExtension } from '@server/helpers/core-utils'
2import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc'
3import { buildUUID } from '@server/helpers/uuid'
4import { MIMETYPES } from '@server/initializers/constants'
5import { ActorModel } from '@server/models/actor/actor'
6import { FilteredModelAttributes } from '@server/types'
7import { ActivityPubActor, ActorImageType } from '@shared/models'
8
9function getActorAttributesFromObject (
10 actorObject: ActivityPubActor,
11 followersCount: number,
12 followingCount: number
13): FilteredModelAttributes<ActorModel> {
14 return {
15 type: actorObject.type,
16 preferredUsername: actorObject.preferredUsername,
17 url: actorObject.id,
18 publicKey: actorObject.publicKey.publicKeyPem,
19 privateKey: null,
20 followersCount,
21 followingCount,
22 inboxUrl: actorObject.inbox,
23 outboxUrl: actorObject.outbox,
24 followersUrl: actorObject.followers,
25 followingUrl: actorObject.following,
26
27 sharedInboxUrl: actorObject.endpoints?.sharedInbox
28 ? actorObject.endpoints.sharedInbox
29 : null
30 }
31}
32
33function getImageInfoFromObject (actorObject: ActivityPubActor, type: ActorImageType) {
34 const mimetypes = MIMETYPES.IMAGE
35 const icon = type === ActorImageType.AVATAR
36 ? actorObject.icon
37 : actorObject.image
38
39 if (!icon || icon.type !== 'Image' || !isActivityPubUrlValid(icon.url)) return undefined
40
41 let extension: string
42
43 if (icon.mediaType) {
44 extension = mimetypes.MIMETYPE_EXT[icon.mediaType]
45 } else {
46 const tmp = getLowercaseExtension(icon.url)
47
48 if (mimetypes.EXT_MIMETYPE[tmp] !== undefined) extension = tmp
49 }
50
51 if (!extension) return undefined
52
53 return {
54 name: buildUUID() + extension,
55 fileUrl: icon.url,
56 height: icon.height,
57 width: icon.width,
58 type
59 }
60}
61
62function getActorDisplayNameFromObject (actorObject: ActivityPubActor) {
63 return actorObject.name || actorObject.preferredUsername
64}
65
66export {
67 getActorAttributesFromObject,
68 getImageInfoFromObject,
69 getActorDisplayNameFromObject
70}
diff --git a/server/lib/activitypub/actors/shared/url-to-object.ts b/server/lib/activitypub/actors/shared/url-to-object.ts
new file mode 100644
index 000000000..f4f16b044
--- /dev/null
+++ b/server/lib/activitypub/actors/shared/url-to-object.ts
@@ -0,0 +1,54 @@
1
2import { checkUrlsSameHost } from '@server/helpers/activitypub'
3import { sanitizeAndCheckActorObject } from '@server/helpers/custom-validators/activitypub/actor'
4import { logger } from '@server/helpers/logger'
5import { doJSONRequest } from '@server/helpers/requests'
6import { ActivityPubActor, ActivityPubOrderedCollection } from '@shared/models'
7
8async function fetchRemoteActor (actorUrl: string): Promise<{ statusCode: number, actorObject: ActivityPubActor }> {
9 logger.info('Fetching remote actor %s.', actorUrl)
10
11 const { body, statusCode } = await doJSONRequest<ActivityPubActor>(actorUrl, { activityPub: true })
12
13 if (sanitizeAndCheckActorObject(body) === false) {
14 logger.debug('Remote actor JSON is not valid.', { actorJSON: body })
15 return { actorObject: undefined, statusCode: statusCode }
16 }
17
18 if (checkUrlsSameHost(body.id, actorUrl) !== true) {
19 logger.warn('Actor url %s has not the same host than its AP id %s', actorUrl, body.id)
20 return { actorObject: undefined, statusCode: statusCode }
21 }
22
23 return {
24 statusCode,
25
26 actorObject: body
27 }
28}
29
30async function fetchActorFollowsCount (actorObject: ActivityPubActor) {
31 const followersCount = await fetchActorTotalItems(actorObject.followers)
32 const followingCount = await fetchActorTotalItems(actorObject.following)
33
34 return { followersCount, followingCount }
35}
36
37// ---------------------------------------------------------------------------
38export {
39 fetchActorFollowsCount,
40 fetchRemoteActor
41}
42
43// ---------------------------------------------------------------------------
44
45async function fetchActorTotalItems (url: string) {
46 try {
47 const { body } = await doJSONRequest<ActivityPubOrderedCollection<unknown>>(url, { activityPub: true })
48
49 return body.totalItems || 0
50 } catch (err) {
51 logger.warn('Cannot fetch remote actor count %s.', url, { err })
52 return 0
53 }
54}
diff --git a/server/lib/activitypub/actors/updater.ts b/server/lib/activitypub/actors/updater.ts
new file mode 100644
index 000000000..de5e03eee
--- /dev/null
+++ b/server/lib/activitypub/actors/updater.ts
@@ -0,0 +1,90 @@
1import { resetSequelizeInstance, runInReadCommittedTransaction } from '@server/helpers/database-utils'
2import { logger } from '@server/helpers/logger'
3import { VideoChannelModel } from '@server/models/video/video-channel'
4import { MAccount, MActor, MActorFull, MChannel } from '@server/types/models'
5import { ActivityPubActor, ActorImageType } from '@shared/models'
6import { updateActorImageInstance } from './image'
7import { fetchActorFollowsCount } from './shared'
8import { getImageInfoFromObject } from './shared/object-to-model-attributes'
9
10export class APActorUpdater {
11
12 private accountOrChannel: MAccount | MChannel
13
14 private readonly actorFieldsSave: object
15 private readonly accountOrChannelFieldsSave: object
16
17 constructor (
18 private readonly actorObject: ActivityPubActor,
19 private readonly actor: MActorFull
20 ) {
21 this.actorFieldsSave = this.actor.toJSON()
22
23 if (this.actorObject.type === 'Group') this.accountOrChannel = this.actor.VideoChannel
24 else this.accountOrChannel = this.actor.Account
25
26 this.accountOrChannelFieldsSave = this.accountOrChannel.toJSON()
27 }
28
29 async update () {
30 const avatarInfo = getImageInfoFromObject(this.actorObject, ActorImageType.AVATAR)
31 const bannerInfo = getImageInfoFromObject(this.actorObject, ActorImageType.BANNER)
32
33 try {
34 await this.updateActorInstance(this.actor, this.actorObject)
35
36 this.accountOrChannel.name = this.actorObject.name || this.actorObject.preferredUsername
37 this.accountOrChannel.description = this.actorObject.summary
38
39 if (this.accountOrChannel instanceof VideoChannelModel) this.accountOrChannel.support = this.actorObject.support
40
41 await runInReadCommittedTransaction(async t => {
42 await updateActorImageInstance(this.actor, ActorImageType.AVATAR, avatarInfo, t)
43 await updateActorImageInstance(this.actor, ActorImageType.BANNER, bannerInfo, t)
44 })
45
46 await runInReadCommittedTransaction(async t => {
47 await this.actor.save({ transaction: t })
48 await this.accountOrChannel.save({ transaction: t })
49 })
50
51 logger.info('Remote account %s updated', this.actorObject.url)
52 } catch (err) {
53 if (this.actor !== undefined && this.actorFieldsSave !== undefined) {
54 resetSequelizeInstance(this.actor, this.actorFieldsSave)
55 }
56
57 if (this.accountOrChannel !== undefined && this.accountOrChannelFieldsSave !== undefined) {
58 resetSequelizeInstance(this.accountOrChannel, this.accountOrChannelFieldsSave)
59 }
60
61 // This is just a debug because we will retry the insert
62 logger.debug('Cannot update the remote account.', { err })
63 throw err
64 }
65 }
66
67 private async updateActorInstance (actorInstance: MActor, actorObject: ActivityPubActor) {
68 const { followersCount, followingCount } = await fetchActorFollowsCount(actorObject)
69
70 actorInstance.type = actorObject.type
71 actorInstance.preferredUsername = actorObject.preferredUsername
72 actorInstance.url = actorObject.id
73 actorInstance.publicKey = actorObject.publicKey.publicKeyPem
74 actorInstance.followersCount = followersCount
75 actorInstance.followingCount = followingCount
76 actorInstance.inboxUrl = actorObject.inbox
77 actorInstance.outboxUrl = actorObject.outbox
78 actorInstance.followersUrl = actorObject.followers
79 actorInstance.followingUrl = actorObject.following
80
81 if (actorObject.published) actorInstance.remoteCreatedAt = new Date(actorObject.published)
82
83 if (actorObject.endpoints?.sharedInbox) {
84 actorInstance.sharedInboxUrl = actorObject.endpoints.sharedInbox
85 }
86
87 // Force actor update
88 actorInstance.changed('updatedAt', true)
89 }
90}
diff --git a/server/helpers/webfinger.ts b/server/lib/activitypub/actors/webfinger.ts
index da7e88077..1c7ec4717 100644
--- a/server/helpers/webfinger.ts
+++ b/server/lib/activitypub/actors/webfinger.ts
@@ -1,16 +1,16 @@
1import * as WebFinger from 'webfinger.js' 1import * as WebFinger from 'webfinger.js'
2import { WebFingerData } from '../../shared' 2import { isProdInstance } from '@server/helpers/core-utils'
3import { ActorModel } from '../models/activitypub/actor' 3import { isActivityPubUrlValid } from '@server/helpers/custom-validators/activitypub/misc'
4import { isTestInstance } from './core-utils' 4import { REQUEST_TIMEOUT, WEBSERVER } from '@server/initializers/constants'
5import { isActivityPubUrlValid } from './custom-validators/activitypub/misc' 5import { ActorModel } from '@server/models/actor/actor'
6import { WEBSERVER } from '../initializers/constants' 6import { MActorFull } from '@server/types/models'
7import { MActorFull } from '../types/models' 7import { WebFingerData } from '@shared/models'
8 8
9const webfinger = new WebFinger({ 9const webfinger = new WebFinger({
10 webfist_fallback: false, 10 webfist_fallback: false,
11 tls_only: isTestInstance(), 11 tls_only: isProdInstance(),
12 uri_fallback: false, 12 uri_fallback: false,
13 request_timeout: 3000 13 request_timeout: REQUEST_TIMEOUT
14}) 14})
15 15
16async function loadActorUrlOrGetFromWebfinger (uriArg: string) { 16async function loadActorUrlOrGetFromWebfinger (uriArg: string) {
diff --git a/server/lib/activitypub/audience.ts b/server/lib/activitypub/audience.ts
index 2986714d3..d0558f191 100644
--- a/server/lib/activitypub/audience.ts
+++ b/server/lib/activitypub/audience.ts
@@ -1,7 +1,7 @@
1import { Transaction } from 'sequelize' 1import { Transaction } from 'sequelize'
2import { ActivityAudience } from '../../../shared/models/activitypub' 2import { ActivityAudience } from '../../../shared/models/activitypub'
3import { ACTIVITY_PUB } from '../../initializers/constants' 3import { ACTIVITY_PUB } from '../../initializers/constants'
4import { ActorModel } from '../../models/activitypub/actor' 4import { ActorModel } from '../../models/actor/actor'
5import { VideoModel } from '../../models/video/video' 5import { VideoModel } from '../../models/video/video'
6import { VideoShareModel } from '../../models/video/video-share' 6import { VideoShareModel } from '../../models/video/video-share'
7import { MActorFollowersUrl, MActorLight, MActorUrl, MCommentOwner, MCommentOwnerVideo, MVideoId } from '../../types/models' 7import { MActorFollowersUrl, MActorLight, MActorUrl, MCommentOwner, MCommentOwnerVideo, MVideoId } from '../../types/models'
diff --git a/server/lib/activitypub/cache-file.ts b/server/lib/activitypub/cache-file.ts
index 2e6dd34e0..a16d2cd93 100644
--- a/server/lib/activitypub/cache-file.ts
+++ b/server/lib/activitypub/cache-file.ts
@@ -1,54 +1,27 @@
1import { CacheFileObject } from '../../../shared/index'
2import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
3import { Transaction } from 'sequelize' 1import { Transaction } from 'sequelize'
4import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
5import { MActorId, MVideoRedundancy, MVideoWithAllFiles } from '@server/types/models' 2import { MActorId, MVideoRedundancy, MVideoWithAllFiles } from '@server/types/models'
3import { CacheFileObject } from '../../../shared/index'
4import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
5import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
6 6
7function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId) { 7async function createOrUpdateCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) {
8 8 const redundancyModel = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t)
9 if (cacheFileObject.url.mediaType === 'application/x-mpegURL') {
10 const url = cacheFileObject.url
11
12 const playlist = video.VideoStreamingPlaylists.find(t => t.type === VideoStreamingPlaylistType.HLS)
13 if (!playlist) throw new Error('Cannot find HLS playlist of video ' + video.url)
14 9
15 return { 10 if (redundancyModel) {
16 expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null, 11 return updateCacheFile(cacheFileObject, redundancyModel, video, byActor, t)
17 url: cacheFileObject.id,
18 fileUrl: url.href,
19 strategy: null,
20 videoStreamingPlaylistId: playlist.id,
21 actorId: byActor.id
22 }
23 } 12 }
24 13
25 const url = cacheFileObject.url 14 return createCacheFile(cacheFileObject, video, byActor, t)
26 const videoFile = video.VideoFiles.find(f => {
27 return f.resolution === url.height && f.fps === url.fps
28 })
29
30 if (!videoFile) throw new Error(`Cannot find video file ${url.height} ${url.fps} of video ${video.url}`)
31
32 return {
33 expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
34 url: cacheFileObject.id,
35 fileUrl: url.href,
36 strategy: null,
37 videoFileId: videoFile.id,
38 actorId: byActor.id
39 }
40} 15}
41 16
42async function createOrUpdateCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) { 17// ---------------------------------------------------------------------------
43 const redundancyModel = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t)
44 18
45 if (!redundancyModel) { 19export {
46 await createCacheFile(cacheFileObject, video, byActor, t) 20 createOrUpdateCacheFile
47 } else {
48 await updateCacheFile(cacheFileObject, redundancyModel, video, byActor, t)
49 }
50} 21}
51 22
23// ---------------------------------------------------------------------------
24
52function createCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) { 25function createCacheFile (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId, t: Transaction) {
53 const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, video, byActor) 26 const attributes = cacheFileActivityObjectToDBAttributes(cacheFileObject, video, byActor)
54 27
@@ -74,9 +47,37 @@ function updateCacheFile (
74 return redundancyModel.save({ transaction: t }) 47 return redundancyModel.save({ transaction: t })
75} 48}
76 49
77export { 50function cacheFileActivityObjectToDBAttributes (cacheFileObject: CacheFileObject, video: MVideoWithAllFiles, byActor: MActorId) {
78 createOrUpdateCacheFile, 51
79 createCacheFile, 52 if (cacheFileObject.url.mediaType === 'application/x-mpegURL') {
80 updateCacheFile, 53 const url = cacheFileObject.url
81 cacheFileActivityObjectToDBAttributes 54
55 const playlist = video.VideoStreamingPlaylists.find(t => t.type === VideoStreamingPlaylistType.HLS)
56 if (!playlist) throw new Error('Cannot find HLS playlist of video ' + video.url)
57
58 return {
59 expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
60 url: cacheFileObject.id,
61 fileUrl: url.href,
62 strategy: null,
63 videoStreamingPlaylistId: playlist.id,
64 actorId: byActor.id
65 }
66 }
67
68 const url = cacheFileObject.url
69 const videoFile = video.VideoFiles.find(f => {
70 return f.resolution === url.height && f.fps === url.fps
71 })
72
73 if (!videoFile) throw new Error(`Cannot find video file ${url.height} ${url.fps} of video ${video.url}`)
74
75 return {
76 expiresOn: cacheFileObject.expires ? new Date(cacheFileObject.expires) : null,
77 url: cacheFileObject.id,
78 fileUrl: url.href,
79 strategy: null,
80 videoFileId: videoFile.id,
81 actorId: byActor.id
82 }
82} 83}
diff --git a/server/lib/activitypub/crawl.ts b/server/lib/activitypub/crawl.ts
index 278abf7de..cd117f571 100644
--- a/server/lib/activitypub/crawl.ts
+++ b/server/lib/activitypub/crawl.ts
@@ -3,7 +3,7 @@ import { URL } from 'url'
3import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub' 3import { ActivityPubOrderedCollection } from '../../../shared/models/activitypub'
4import { logger } from '../../helpers/logger' 4import { logger } from '../../helpers/logger'
5import { doJSONRequest } from '../../helpers/requests' 5import { doJSONRequest } from '../../helpers/requests'
6import { ACTIVITY_PUB, REQUEST_TIMEOUT, WEBSERVER } from '../../initializers/constants' 6import { ACTIVITY_PUB, WEBSERVER } from '../../initializers/constants'
7 7
8type HandlerFunction<T> = (items: T[]) => (Promise<any> | Bluebird<any>) 8type HandlerFunction<T> = (items: T[]) => (Promise<any> | Bluebird<any>)
9type CleanerFunction = (startedDate: Date) => (Promise<any> | Bluebird<any>) 9type CleanerFunction = (startedDate: Date) => (Promise<any> | Bluebird<any>)
@@ -13,10 +13,7 @@ async function crawlCollectionPage <T> (argUrl: string, handler: HandlerFunction
13 13
14 logger.info('Crawling ActivityPub data on %s.', url) 14 logger.info('Crawling ActivityPub data on %s.', url)
15 15
16 const options = { 16 const options = { activityPub: true }
17 activityPub: true,
18 timeout: REQUEST_TIMEOUT
19 }
20 17
21 const startDate = new Date() 18 const startDate = new Date()
22 19
diff --git a/server/lib/activitypub/follow.ts b/server/lib/activitypub/follow.ts
index 351499bd1..c1bd667e0 100644
--- a/server/lib/activitypub/follow.ts
+++ b/server/lib/activitypub/follow.ts
@@ -1,12 +1,13 @@
1import { MActorFollowActors } from '../../types/models' 1import { Transaction } from 'sequelize'
2import { getServerActor } from '@server/models/application/application'
3import { logger } from '../../helpers/logger'
2import { CONFIG } from '../../initializers/config' 4import { CONFIG } from '../../initializers/config'
3import { SERVER_ACTOR_NAME } from '../../initializers/constants' 5import { SERVER_ACTOR_NAME } from '../../initializers/constants'
4import { JobQueue } from '../job-queue'
5import { logger } from '../../helpers/logger'
6import { ServerModel } from '../../models/server/server' 6import { ServerModel } from '../../models/server/server'
7import { getServerActor } from '@server/models/application/application' 7import { MActorFollowActors } from '../../types/models'
8import { JobQueue } from '../job-queue'
8 9
9async function autoFollowBackIfNeeded (actorFollow: MActorFollowActors) { 10async function autoFollowBackIfNeeded (actorFollow: MActorFollowActors, transaction?: Transaction) {
10 if (!CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_BACK.ENABLED) return 11 if (!CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_BACK.ENABLED) return
11 12
12 const follower = actorFollow.ActorFollower 13 const follower = actorFollow.ActorFollower
@@ -16,7 +17,7 @@ async function autoFollowBackIfNeeded (actorFollow: MActorFollowActors) {
16 17
17 const me = await getServerActor() 18 const me = await getServerActor()
18 19
19 const server = await ServerModel.load(follower.serverId) 20 const server = await ServerModel.load(follower.serverId, transaction)
20 const host = server.host 21 const host = server.host
21 22
22 const payload = { 23 const payload = {
diff --git a/server/lib/activitypub/outbox.ts b/server/lib/activitypub/outbox.ts
new file mode 100644
index 000000000..ecdc33a77
--- /dev/null
+++ b/server/lib/activitypub/outbox.ts
@@ -0,0 +1,24 @@
1import { logger } from '@server/helpers/logger'
2import { ActorModel } from '@server/models/actor/actor'
3import { getServerActor } from '@server/models/application/application'
4import { JobQueue } from '../job-queue'
5
6async function addFetchOutboxJob (actor: Pick<ActorModel, 'id' | 'outboxUrl'>) {
7 // Don't fetch ourselves
8 const serverActor = await getServerActor()
9 if (serverActor.id === actor.id) {
10 logger.error('Cannot fetch our own outbox!')
11 return undefined
12 }
13
14 const payload = {
15 uri: actor.outboxUrl,
16 type: 'activity' as 'activity'
17 }
18
19 return JobQueue.Instance.createJob({ type: 'activitypub-http-fetcher', payload })
20}
21
22export {
23 addFetchOutboxJob
24}
diff --git a/server/lib/activitypub/playlist.ts b/server/lib/activitypub/playlist.ts
deleted file mode 100644
index 7166c68a6..000000000
--- a/server/lib/activitypub/playlist.ts
+++ /dev/null
@@ -1,204 +0,0 @@
1import * as Bluebird from 'bluebird'
2import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
3import { PlaylistElementObject } from '../../../shared/models/activitypub/objects/playlist-element-object'
4import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
5import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
6import { checkUrlsSameHost } from '../../helpers/activitypub'
7import { isPlaylistElementObjectValid, isPlaylistObjectValid } from '../../helpers/custom-validators/activitypub/playlist'
8import { isArray } from '../../helpers/custom-validators/misc'
9import { logger } from '../../helpers/logger'
10import { doJSONRequest, PeerTubeRequestError } from '../../helpers/requests'
11import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants'
12import { sequelizeTypescript } from '../../initializers/database'
13import { VideoPlaylistModel } from '../../models/video/video-playlist'
14import { VideoPlaylistElementModel } from '../../models/video/video-playlist-element'
15import { MAccountDefault, MAccountId, MVideoId } from '../../types/models'
16import { MVideoPlaylist, MVideoPlaylistId, MVideoPlaylistOwner } from '../../types/models/video/video-playlist'
17import { FilteredModelAttributes } from '../../types/sequelize'
18import { createPlaylistMiniatureFromUrl } from '../thumbnail'
19import { getOrCreateActorAndServerAndModel } from './actor'
20import { crawlCollectionPage } from './crawl'
21import { getOrCreateVideoAndAccountAndChannel } from './videos'
22
23function playlistObjectToDBAttributes (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) {
24 const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
25 ? VideoPlaylistPrivacy.PUBLIC
26 : VideoPlaylistPrivacy.UNLISTED
27
28 return {
29 name: playlistObject.name,
30 description: playlistObject.content,
31 privacy,
32 url: playlistObject.id,
33 uuid: playlistObject.uuid,
34 ownerAccountId: byAccount.id,
35 videoChannelId: null,
36 createdAt: new Date(playlistObject.published),
37 updatedAt: new Date(playlistObject.updated)
38 }
39}
40
41function playlistElementObjectToDBAttributes (elementObject: PlaylistElementObject, videoPlaylist: MVideoPlaylistId, video: MVideoId) {
42 return {
43 position: elementObject.position,
44 url: elementObject.id,
45 startTimestamp: elementObject.startTimestamp || null,
46 stopTimestamp: elementObject.stopTimestamp || null,
47 videoPlaylistId: videoPlaylist.id,
48 videoId: video.id
49 }
50}
51
52async function createAccountPlaylists (playlistUrls: string[], account: MAccountDefault) {
53 await Bluebird.map(playlistUrls, async playlistUrl => {
54 try {
55 const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl)
56 if (exists === true) return
57
58 // Fetch url
59 const { body } = await doJSONRequest<PlaylistObject>(playlistUrl, { activityPub: true })
60
61 if (!isPlaylistObjectValid(body)) {
62 throw new Error(`Invalid playlist object when fetch account playlists: ${JSON.stringify(body)}`)
63 }
64
65 if (!isArray(body.to)) {
66 throw new Error('Playlist does not have an audience.')
67 }
68
69 return createOrUpdateVideoPlaylist(body, account, body.to)
70 } catch (err) {
71 logger.warn('Cannot add playlist element %s.', playlistUrl, { err })
72 }
73 }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
74}
75
76async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, byAccount: MAccountId, to: string[]) {
77 const playlistAttributes = playlistObjectToDBAttributes(playlistObject, byAccount, to)
78
79 if (isArray(playlistObject.attributedTo) && playlistObject.attributedTo.length === 1) {
80 const actor = await getOrCreateActorAndServerAndModel(playlistObject.attributedTo[0])
81
82 if (actor.VideoChannel) {
83 playlistAttributes.videoChannelId = actor.VideoChannel.id
84 } else {
85 logger.warn('Attributed to of video playlist %s is not a video channel.', playlistObject.id, { playlistObject })
86 }
87 }
88
89 const [ playlist ] = await VideoPlaylistModel.upsert<MVideoPlaylist>(playlistAttributes, { returning: true })
90
91 let accItems: string[] = []
92 await crawlCollectionPage<string>(playlistObject.id, items => {
93 accItems = accItems.concat(items)
94
95 return Promise.resolve()
96 })
97
98 const refreshedPlaylist = await VideoPlaylistModel.loadWithAccountAndChannel(playlist.id, null)
99
100 if (playlistObject.icon) {
101 try {
102 const thumbnailModel = await createPlaylistMiniatureFromUrl({ downloadUrl: playlistObject.icon.url, playlist: refreshedPlaylist })
103 await refreshedPlaylist.setAndSaveThumbnail(thumbnailModel, undefined)
104 } catch (err) {
105 logger.warn('Cannot generate thumbnail of %s.', playlistObject.id, { err })
106 }
107 } else if (refreshedPlaylist.hasThumbnail()) {
108 await refreshedPlaylist.Thumbnail.destroy()
109 refreshedPlaylist.Thumbnail = null
110 }
111
112 return resetVideoPlaylistElements(accItems, refreshedPlaylist)
113}
114
115async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner): Promise<MVideoPlaylistOwner> {
116 if (!videoPlaylist.isOutdated()) return videoPlaylist
117
118 try {
119 const { playlistObject } = await fetchRemoteVideoPlaylist(videoPlaylist.url)
120
121 if (playlistObject === undefined) {
122 logger.warn('Cannot refresh remote playlist %s: invalid body.', videoPlaylist.url)
123
124 await videoPlaylist.setAsRefreshed()
125 return videoPlaylist
126 }
127
128 const byAccount = videoPlaylist.OwnerAccount
129 await createOrUpdateVideoPlaylist(playlistObject, byAccount, playlistObject.to)
130
131 return videoPlaylist
132 } catch (err) {
133 if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) {
134 logger.info('Cannot refresh remote video playlist %s: it does not exist anymore. Deleting it.', videoPlaylist.url)
135
136 await videoPlaylist.destroy()
137 return undefined
138 }
139
140 logger.warn('Cannot refresh video playlist %s.', videoPlaylist.url, { err })
141
142 await videoPlaylist.setAsRefreshed()
143 return videoPlaylist
144 }
145}
146
147// ---------------------------------------------------------------------------
148
149export {
150 createAccountPlaylists,
151 playlistObjectToDBAttributes,
152 playlistElementObjectToDBAttributes,
153 createOrUpdateVideoPlaylist,
154 refreshVideoPlaylistIfNeeded
155}
156
157// ---------------------------------------------------------------------------
158
159async function resetVideoPlaylistElements (elementUrls: string[], playlist: MVideoPlaylist) {
160 const elementsToCreate: FilteredModelAttributes<VideoPlaylistElementModel>[] = []
161
162 await Bluebird.map(elementUrls, async elementUrl => {
163 try {
164 const { body } = await doJSONRequest<PlaylistElementObject>(elementUrl, { activityPub: true })
165
166 if (!isPlaylistElementObjectValid(body)) throw new Error(`Invalid body in video get playlist element ${elementUrl}`)
167
168 if (checkUrlsSameHost(body.id, elementUrl) !== true) {
169 throw new Error(`Playlist element url ${elementUrl} host is different from the AP object id ${body.id}`)
170 }
171
172 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: { id: body.url }, fetchType: 'only-video' })
173
174 elementsToCreate.push(playlistElementObjectToDBAttributes(body, playlist, video))
175 } catch (err) {
176 logger.warn('Cannot add playlist element %s.', elementUrl, { err })
177 }
178 }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
179
180 await sequelizeTypescript.transaction(async t => {
181 await VideoPlaylistElementModel.deleteAllOf(playlist.id, t)
182
183 for (const element of elementsToCreate) {
184 await VideoPlaylistElementModel.create(element, { transaction: t })
185 }
186 })
187
188 logger.info('Reset playlist %s with %s elements.', playlist.url, elementsToCreate.length)
189
190 return undefined
191}
192
193async function fetchRemoteVideoPlaylist (playlistUrl: string): Promise<{ statusCode: number, playlistObject: PlaylistObject }> {
194 logger.info('Fetching remote playlist %s.', playlistUrl)
195
196 const { body, statusCode } = await doJSONRequest<any>(playlistUrl, { activityPub: true })
197
198 if (isPlaylistObjectValid(body) === false || checkUrlsSameHost(body.id, playlistUrl) !== true) {
199 logger.debug('Remote video playlist JSON is not valid.', { body })
200 return { statusCode, playlistObject: undefined }
201 }
202
203 return { statusCode, playlistObject: body }
204}
diff --git a/server/lib/activitypub/playlists/create-update.ts b/server/lib/activitypub/playlists/create-update.ts
new file mode 100644
index 000000000..ea3e61ac5
--- /dev/null
+++ b/server/lib/activitypub/playlists/create-update.ts
@@ -0,0 +1,156 @@
1import * as Bluebird from 'bluebird'
2import { getAPId } from '@server/helpers/activitypub'
3import { isArray } from '@server/helpers/custom-validators/misc'
4import { logger, loggerTagsFactory } from '@server/helpers/logger'
5import { CRAWL_REQUEST_CONCURRENCY } from '@server/initializers/constants'
6import { sequelizeTypescript } from '@server/initializers/database'
7import { updatePlaylistMiniatureFromUrl } from '@server/lib/thumbnail'
8import { VideoPlaylistModel } from '@server/models/video/video-playlist'
9import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
10import { FilteredModelAttributes } from '@server/types'
11import { MThumbnail, MVideoPlaylist, MVideoPlaylistFull, MVideoPlaylistVideosLength } from '@server/types/models'
12import { AttributesOnly } from '@shared/core-utils'
13import { PlaylistObject } from '@shared/models'
14import { getOrCreateAPActor } from '../actors'
15import { crawlCollectionPage } from '../crawl'
16import { getOrCreateAPVideo } from '../videos'
17import {
18 fetchRemotePlaylistElement,
19 fetchRemoteVideoPlaylist,
20 playlistElementObjectToDBAttributes,
21 playlistObjectToDBAttributes
22} from './shared'
23
24const lTags = loggerTagsFactory('ap', 'video-playlist')
25
26async function createAccountPlaylists (playlistUrls: string[]) {
27 await Bluebird.map(playlistUrls, async playlistUrl => {
28 try {
29 const exists = await VideoPlaylistModel.doesPlaylistExist(playlistUrl)
30 if (exists === true) return
31
32 const { playlistObject } = await fetchRemoteVideoPlaylist(playlistUrl)
33
34 if (playlistObject === undefined) {
35 throw new Error(`Cannot refresh remote playlist ${playlistUrl}: invalid body.`)
36 }
37
38 return createOrUpdateVideoPlaylist(playlistObject)
39 } catch (err) {
40 logger.warn('Cannot add playlist element %s.', playlistUrl, { err, ...lTags(playlistUrl) })
41 }
42 }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
43}
44
45async function createOrUpdateVideoPlaylist (playlistObject: PlaylistObject, to?: string[]) {
46 const playlistAttributes = playlistObjectToDBAttributes(playlistObject, to || playlistObject.to)
47
48 await setVideoChannel(playlistObject, playlistAttributes)
49
50 const [ upsertPlaylist ] = await VideoPlaylistModel.upsert<MVideoPlaylistVideosLength>(playlistAttributes, { returning: true })
51
52 const playlistElementUrls = await fetchElementUrls(playlistObject)
53
54 // Refetch playlist from DB since elements fetching could be long in time
55 const playlist = await VideoPlaylistModel.loadWithAccountAndChannel(upsertPlaylist.id, null)
56
57 await updatePlaylistThumbnail(playlistObject, playlist)
58
59 const elementsLength = await rebuildVideoPlaylistElements(playlistElementUrls, playlist)
60 playlist.setVideosLength(elementsLength)
61
62 return playlist
63}
64
65// ---------------------------------------------------------------------------
66
67export {
68 createAccountPlaylists,
69 createOrUpdateVideoPlaylist
70}
71
72// ---------------------------------------------------------------------------
73
74async function setVideoChannel (playlistObject: PlaylistObject, playlistAttributes: AttributesOnly<VideoPlaylistModel>) {
75 if (!isArray(playlistObject.attributedTo) || playlistObject.attributedTo.length !== 1) {
76 throw new Error('Not attributed to for playlist object ' + getAPId(playlistObject))
77 }
78
79 const actor = await getOrCreateAPActor(playlistObject.attributedTo[0], 'all')
80
81 if (!actor.VideoChannel) {
82 logger.warn('Playlist "attributedTo" %s is not a video channel.', playlistObject.id, { playlistObject, ...lTags(playlistObject.id) })
83 return
84 }
85
86 playlistAttributes.videoChannelId = actor.VideoChannel.id
87 playlistAttributes.ownerAccountId = actor.VideoChannel.Account.id
88}
89
90async function fetchElementUrls (playlistObject: PlaylistObject) {
91 let accItems: string[] = []
92 await crawlCollectionPage<string>(playlistObject.id, items => {
93 accItems = accItems.concat(items)
94
95 return Promise.resolve()
96 })
97
98 return accItems
99}
100
101async function updatePlaylistThumbnail (playlistObject: PlaylistObject, playlist: MVideoPlaylistFull) {
102 if (playlistObject.icon) {
103 let thumbnailModel: MThumbnail
104
105 try {
106 thumbnailModel = await updatePlaylistMiniatureFromUrl({ downloadUrl: playlistObject.icon.url, playlist })
107 await playlist.setAndSaveThumbnail(thumbnailModel, undefined)
108 } catch (err) {
109 logger.warn('Cannot set thumbnail of %s.', playlistObject.id, { err, ...lTags(playlistObject.id, playlist.uuid, playlist.url) })
110
111 if (thumbnailModel) await thumbnailModel.removeThumbnail()
112 }
113
114 return
115 }
116
117 // Playlist does not have an icon, destroy existing one
118 if (playlist.hasThumbnail()) {
119 await playlist.Thumbnail.destroy()
120 playlist.Thumbnail = null
121 }
122}
123
124async function rebuildVideoPlaylistElements (elementUrls: string[], playlist: MVideoPlaylist) {
125 const elementsToCreate = await buildElementsDBAttributes(elementUrls, playlist)
126
127 await sequelizeTypescript.transaction(async t => {
128 await VideoPlaylistElementModel.deleteAllOf(playlist.id, t)
129
130 for (const element of elementsToCreate) {
131 await VideoPlaylistElementModel.create(element, { transaction: t })
132 }
133 })
134
135 logger.info('Rebuilt playlist %s with %s elements.', playlist.url, elementsToCreate.length, lTags(playlist.uuid, playlist.url))
136
137 return elementsToCreate.length
138}
139
140async function buildElementsDBAttributes (elementUrls: string[], playlist: MVideoPlaylist) {
141 const elementsToCreate: FilteredModelAttributes<VideoPlaylistElementModel>[] = []
142
143 await Bluebird.map(elementUrls, async elementUrl => {
144 try {
145 const { elementObject } = await fetchRemotePlaylistElement(elementUrl)
146
147 const { video } = await getOrCreateAPVideo({ videoObject: { id: elementObject.url }, fetchType: 'only-video' })
148
149 elementsToCreate.push(playlistElementObjectToDBAttributes(elementObject, playlist, video))
150 } catch (err) {
151 logger.warn('Cannot add playlist element %s.', elementUrl, { err, ...lTags(playlist.uuid, playlist.url) })
152 }
153 }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
154
155 return elementsToCreate
156}
diff --git a/server/lib/activitypub/playlists/get.ts b/server/lib/activitypub/playlists/get.ts
new file mode 100644
index 000000000..2c19c503a
--- /dev/null
+++ b/server/lib/activitypub/playlists/get.ts
@@ -0,0 +1,35 @@
1import { getAPId } from '@server/helpers/activitypub'
2import { VideoPlaylistModel } from '@server/models/video/video-playlist'
3import { MVideoPlaylistFullSummary } from '@server/types/models'
4import { APObject } from '@shared/models'
5import { createOrUpdateVideoPlaylist } from './create-update'
6import { scheduleRefreshIfNeeded } from './refresh'
7import { fetchRemoteVideoPlaylist } from './shared'
8
9async function getOrCreateAPVideoPlaylist (playlistObjectArg: APObject): Promise<MVideoPlaylistFullSummary> {
10 const playlistUrl = getAPId(playlistObjectArg)
11
12 const playlistFromDatabase = await VideoPlaylistModel.loadByUrlWithAccountAndChannelSummary(playlistUrl)
13
14 if (playlistFromDatabase) {
15 scheduleRefreshIfNeeded(playlistFromDatabase)
16
17 return playlistFromDatabase
18 }
19
20 const { playlistObject } = await fetchRemoteVideoPlaylist(playlistUrl)
21 if (!playlistObject) throw new Error('Cannot fetch remote playlist with url: ' + playlistUrl)
22
23 // playlistUrl is just an alias/rediraction, so process object id instead
24 if (playlistObject.id !== playlistUrl) return getOrCreateAPVideoPlaylist(playlistObject)
25
26 const playlistCreated = await createOrUpdateVideoPlaylist(playlistObject)
27
28 return playlistCreated
29}
30
31// ---------------------------------------------------------------------------
32
33export {
34 getOrCreateAPVideoPlaylist
35}
diff --git a/server/lib/activitypub/playlists/index.ts b/server/lib/activitypub/playlists/index.ts
new file mode 100644
index 000000000..e2470a674
--- /dev/null
+++ b/server/lib/activitypub/playlists/index.ts
@@ -0,0 +1,3 @@
1export * from './get'
2export * from './create-update'
3export * from './refresh'
diff --git a/server/lib/activitypub/playlists/refresh.ts b/server/lib/activitypub/playlists/refresh.ts
new file mode 100644
index 000000000..ef3cb3fe4
--- /dev/null
+++ b/server/lib/activitypub/playlists/refresh.ts
@@ -0,0 +1,53 @@
1import { logger, loggerTagsFactory } from '@server/helpers/logger'
2import { PeerTubeRequestError } from '@server/helpers/requests'
3import { JobQueue } from '@server/lib/job-queue'
4import { MVideoPlaylist, MVideoPlaylistOwner } from '@server/types/models'
5import { HttpStatusCode } from '@shared/core-utils'
6import { createOrUpdateVideoPlaylist } from './create-update'
7import { fetchRemoteVideoPlaylist } from './shared'
8
9function scheduleRefreshIfNeeded (playlist: MVideoPlaylist) {
10 if (!playlist.isOutdated()) return
11
12 JobQueue.Instance.createJob({ type: 'activitypub-refresher', payload: { type: 'video-playlist', url: playlist.url } })
13}
14
15async function refreshVideoPlaylistIfNeeded (videoPlaylist: MVideoPlaylistOwner): Promise<MVideoPlaylistOwner> {
16 if (!videoPlaylist.isOutdated()) return videoPlaylist
17
18 const lTags = loggerTagsFactory('ap', 'video-playlist', 'refresh', videoPlaylist.uuid, videoPlaylist.url)
19
20 logger.info('Refreshing playlist %s.', videoPlaylist.url, lTags())
21
22 try {
23 const { playlistObject } = await fetchRemoteVideoPlaylist(videoPlaylist.url)
24
25 if (playlistObject === undefined) {
26 logger.warn('Cannot refresh remote playlist %s: invalid body.', videoPlaylist.url, lTags())
27
28 await videoPlaylist.setAsRefreshed()
29 return videoPlaylist
30 }
31
32 await createOrUpdateVideoPlaylist(playlistObject)
33
34 return videoPlaylist
35 } catch (err) {
36 if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) {
37 logger.info('Cannot refresh not existing playlist %s. Deleting it.', videoPlaylist.url, lTags())
38
39 await videoPlaylist.destroy()
40 return undefined
41 }
42
43 logger.warn('Cannot refresh video playlist %s.', videoPlaylist.url, { err, ...lTags() })
44
45 await videoPlaylist.setAsRefreshed()
46 return videoPlaylist
47 }
48}
49
50export {
51 scheduleRefreshIfNeeded,
52 refreshVideoPlaylistIfNeeded
53}
diff --git a/server/lib/activitypub/playlists/shared/index.ts b/server/lib/activitypub/playlists/shared/index.ts
new file mode 100644
index 000000000..a217f2291
--- /dev/null
+++ b/server/lib/activitypub/playlists/shared/index.ts
@@ -0,0 +1,2 @@
1export * from './object-to-model-attributes'
2export * from './url-to-object'
diff --git a/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts b/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts
new file mode 100644
index 000000000..70fd335bc
--- /dev/null
+++ b/server/lib/activitypub/playlists/shared/object-to-model-attributes.ts
@@ -0,0 +1,40 @@
1import { ACTIVITY_PUB } from '@server/initializers/constants'
2import { VideoPlaylistModel } from '@server/models/video/video-playlist'
3import { VideoPlaylistElementModel } from '@server/models/video/video-playlist-element'
4import { MVideoId, MVideoPlaylistId } from '@server/types/models'
5import { AttributesOnly } from '@shared/core-utils'
6import { PlaylistElementObject, PlaylistObject, VideoPlaylistPrivacy } from '@shared/models'
7
8function playlistObjectToDBAttributes (playlistObject: PlaylistObject, to: string[]) {
9 const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
10 ? VideoPlaylistPrivacy.PUBLIC
11 : VideoPlaylistPrivacy.UNLISTED
12
13 return {
14 name: playlistObject.name,
15 description: playlistObject.content,
16 privacy,
17 url: playlistObject.id,
18 uuid: playlistObject.uuid,
19 ownerAccountId: null,
20 videoChannelId: null,
21 createdAt: new Date(playlistObject.published),
22 updatedAt: new Date(playlistObject.updated)
23 } as AttributesOnly<VideoPlaylistModel>
24}
25
26function playlistElementObjectToDBAttributes (elementObject: PlaylistElementObject, videoPlaylist: MVideoPlaylistId, video: MVideoId) {
27 return {
28 position: elementObject.position,
29 url: elementObject.id,
30 startTimestamp: elementObject.startTimestamp || null,
31 stopTimestamp: elementObject.stopTimestamp || null,
32 videoPlaylistId: videoPlaylist.id,
33 videoId: video.id
34 } as AttributesOnly<VideoPlaylistElementModel>
35}
36
37export {
38 playlistObjectToDBAttributes,
39 playlistElementObjectToDBAttributes
40}
diff --git a/server/lib/activitypub/playlists/shared/url-to-object.ts b/server/lib/activitypub/playlists/shared/url-to-object.ts
new file mode 100644
index 000000000..ec8c01255
--- /dev/null
+++ b/server/lib/activitypub/playlists/shared/url-to-object.ts
@@ -0,0 +1,47 @@
1import { isArray } from 'lodash'
2import { checkUrlsSameHost } from '@server/helpers/activitypub'
3import { isPlaylistElementObjectValid, isPlaylistObjectValid } from '@server/helpers/custom-validators/activitypub/playlist'
4import { logger, loggerTagsFactory } from '@server/helpers/logger'
5import { doJSONRequest } from '@server/helpers/requests'
6import { PlaylistElementObject, PlaylistObject } from '@shared/models'
7
8async function fetchRemoteVideoPlaylist (playlistUrl: string): Promise<{ statusCode: number, playlistObject: PlaylistObject }> {
9 const lTags = loggerTagsFactory('ap', 'video-playlist', playlistUrl)
10
11 logger.info('Fetching remote playlist %s.', playlistUrl, lTags())
12
13 const { body, statusCode } = await doJSONRequest<any>(playlistUrl, { activityPub: true })
14
15 if (isPlaylistObjectValid(body) === false || checkUrlsSameHost(body.id, playlistUrl) !== true) {
16 logger.debug('Remote video playlist JSON is not valid.', { body, ...lTags() })
17 return { statusCode, playlistObject: undefined }
18 }
19
20 if (!isArray(body.to)) {
21 logger.debug('Remote video playlist JSON does not have a valid audience.', { body, ...lTags() })
22 return { statusCode, playlistObject: undefined }
23 }
24
25 return { statusCode, playlistObject: body }
26}
27
28async function fetchRemotePlaylistElement (elementUrl: string): Promise<{ statusCode: number, elementObject: PlaylistElementObject }> {
29 const lTags = loggerTagsFactory('ap', 'video-playlist', 'element', elementUrl)
30
31 logger.debug('Fetching remote playlist element %s.', elementUrl, lTags())
32
33 const { body, statusCode } = await doJSONRequest<PlaylistElementObject>(elementUrl, { activityPub: true })
34
35 if (!isPlaylistElementObjectValid(body)) throw new Error(`Invalid body in fetch playlist element ${elementUrl}`)
36
37 if (checkUrlsSameHost(body.id, elementUrl) !== true) {
38 throw new Error(`Playlist element url ${elementUrl} host is different from the AP object id ${body.id}`)
39 }
40
41 return { statusCode, elementObject: body }
42}
43
44export {
45 fetchRemoteVideoPlaylist,
46 fetchRemotePlaylistElement
47}
diff --git a/server/lib/activitypub/process/process-accept.ts b/server/lib/activitypub/process/process-accept.ts
index 1799829f8..077b01eda 100644
--- a/server/lib/activitypub/process/process-accept.ts
+++ b/server/lib/activitypub/process/process-accept.ts
@@ -1,8 +1,8 @@
1import { ActivityAccept } from '../../../../shared/models/activitypub' 1import { ActivityAccept } from '../../../../shared/models/activitypub'
2import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 2import { ActorFollowModel } from '../../../models/actor/actor-follow'
3import { addFetchOutboxJob } from '../actor'
4import { APProcessorOptions } from '../../../types/activitypub-processor.model' 3import { APProcessorOptions } from '../../../types/activitypub-processor.model'
5import { MActorDefault, MActorSignature } from '../../../types/models' 4import { MActorDefault, MActorSignature } from '../../../types/models'
5import { addFetchOutboxJob } from '../outbox'
6 6
7async function processAcceptActivity (options: APProcessorOptions<ActivityAccept>) { 7async function processAcceptActivity (options: APProcessorOptions<ActivityAccept>) {
8 const { byActor: targetActor, inboxActor } = options 8 const { byActor: targetActor, inboxActor } = options
diff --git a/server/lib/activitypub/process/process-announce.ts b/server/lib/activitypub/process/process-announce.ts
index 63082466e..ec23c705e 100644
--- a/server/lib/activitypub/process/process-announce.ts
+++ b/server/lib/activitypub/process/process-announce.ts
@@ -3,7 +3,7 @@ import { retryTransactionWrapper } from '../../../helpers/database-utils'
3import { sequelizeTypescript } from '../../../initializers/database' 3import { sequelizeTypescript } from '../../../initializers/database'
4import { VideoShareModel } from '../../../models/video/video-share' 4import { VideoShareModel } from '../../../models/video/video-share'
5import { forwardVideoRelatedActivity } from '../send/utils' 5import { forwardVideoRelatedActivity } from '../send/utils'
6import { getOrCreateVideoAndAccountAndChannel } from '../videos' 6import { getOrCreateAPVideo } from '../videos'
7import { Notifier } from '../../notifier' 7import { Notifier } from '../../notifier'
8import { logger } from '../../../helpers/logger' 8import { logger } from '../../../helpers/logger'
9import { APProcessorOptions } from '../../../types/activitypub-processor.model' 9import { APProcessorOptions } from '../../../types/activitypub-processor.model'
@@ -32,7 +32,7 @@ async function processVideoShare (actorAnnouncer: MActorSignature, activity: Act
32 let videoCreated: boolean 32 let videoCreated: boolean
33 33
34 try { 34 try {
35 const result = await getOrCreateVideoAndAccountAndChannel({ videoObject: objectUri }) 35 const result = await getOrCreateAPVideo({ videoObject: objectUri })
36 video = result.video 36 video = result.video
37 videoCreated = result.created 37 videoCreated = result.created
38 } catch (err) { 38 } catch (err) {
diff --git a/server/lib/activitypub/process/process-create.ts b/server/lib/activitypub/process/process-create.ts
index 9cded4dec..70e048d6e 100644
--- a/server/lib/activitypub/process/process-create.ts
+++ b/server/lib/activitypub/process/process-create.ts
@@ -1,3 +1,4 @@
1import { isBlockedByServerOrAccount } from '@server/lib/blocklist'
1import { isRedundancyAccepted } from '@server/lib/redundancy' 2import { isRedundancyAccepted } from '@server/lib/redundancy'
2import { ActivityCreate, CacheFileObject, VideoObject } from '../../../../shared' 3import { ActivityCreate, CacheFileObject, VideoObject } from '../../../../shared'
3import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object' 4import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
@@ -9,11 +10,10 @@ import { APProcessorOptions } from '../../../types/activitypub-processor.model'
9import { MActorSignature, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../../types/models' 10import { MActorSignature, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../../types/models'
10import { Notifier } from '../../notifier' 11import { Notifier } from '../../notifier'
11import { createOrUpdateCacheFile } from '../cache-file' 12import { createOrUpdateCacheFile } from '../cache-file'
12import { createOrUpdateVideoPlaylist } from '../playlist' 13import { createOrUpdateVideoPlaylist } from '../playlists'
13import { forwardVideoRelatedActivity } from '../send/utils' 14import { forwardVideoRelatedActivity } from '../send/utils'
14import { resolveThread } from '../video-comments' 15import { resolveThread } from '../video-comments'
15import { getOrCreateVideoAndAccountAndChannel } from '../videos' 16import { getOrCreateAPVideo } from '../videos'
16import { isBlockedByServerOrAccount } from '@server/lib/blocklist'
17 17
18async function processCreateActivity (options: APProcessorOptions<ActivityCreate>) { 18async function processCreateActivity (options: APProcessorOptions<ActivityCreate>) {
19 const { activity, byActor } = options 19 const { activity, byActor } = options
@@ -55,7 +55,7 @@ async function processCreateVideo (activity: ActivityCreate, notify: boolean) {
55 const videoToCreateData = activity.object as VideoObject 55 const videoToCreateData = activity.object as VideoObject
56 56
57 const syncParam = { likes: false, dislikes: false, shares: false, comments: false, thumbnail: true, refreshVideo: false } 57 const syncParam = { likes: false, dislikes: false, shares: false, comments: false, thumbnail: true, refreshVideo: false }
58 const { video, created } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoToCreateData, syncParam }) 58 const { video, created } = await getOrCreateAPVideo({ videoObject: videoToCreateData, syncParam })
59 59
60 if (created && notify) Notifier.Instance.notifyOnNewVideoIfNeeded(video) 60 if (created && notify) Notifier.Instance.notifyOnNewVideoIfNeeded(video)
61 61
@@ -67,7 +67,7 @@ async function processCreateCacheFile (activity: ActivityCreate, byActor: MActor
67 67
68 const cacheFile = activity.object as CacheFileObject 68 const cacheFile = activity.object as CacheFileObject
69 69
70 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFile.object }) 70 const { video } = await getOrCreateAPVideo({ videoObject: cacheFile.object })
71 71
72 await sequelizeTypescript.transaction(async t => { 72 await sequelizeTypescript.transaction(async t => {
73 return createOrUpdateCacheFile(cacheFile, video, byActor, t) 73 return createOrUpdateCacheFile(cacheFile, video, byActor, t)
@@ -128,5 +128,5 @@ async function processCreatePlaylist (activity: ActivityCreate, byActor: MActorS
128 128
129 if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url) 129 if (!byAccount) throw new Error('Cannot create video playlist with the non account actor ' + byActor.url)
130 130
131 await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to) 131 await createOrUpdateVideoPlaylist(playlistObject, activity.to)
132} 132}
diff --git a/server/lib/activitypub/process/process-delete.ts b/server/lib/activitypub/process/process-delete.ts
index 88a968318..1d2279df5 100644
--- a/server/lib/activitypub/process/process-delete.ts
+++ b/server/lib/activitypub/process/process-delete.ts
@@ -2,7 +2,7 @@ import { ActivityDelete } from '../../../../shared/models/activitypub'
2import { retryTransactionWrapper } from '../../../helpers/database-utils' 2import { retryTransactionWrapper } from '../../../helpers/database-utils'
3import { logger } from '../../../helpers/logger' 3import { logger } from '../../../helpers/logger'
4import { sequelizeTypescript } from '../../../initializers/database' 4import { sequelizeTypescript } from '../../../initializers/database'
5import { ActorModel } from '../../../models/activitypub/actor' 5import { ActorModel } from '../../../models/actor/actor'
6import { VideoModel } from '../../../models/video/video' 6import { VideoModel } from '../../../models/video/video'
7import { VideoCommentModel } from '../../../models/video/video-comment' 7import { VideoCommentModel } from '../../../models/video/video-comment'
8import { VideoPlaylistModel } from '../../../models/video/video-playlist' 8import { VideoPlaylistModel } from '../../../models/video/video-playlist'
@@ -16,7 +16,6 @@ import {
16 MChannelActor, 16 MChannelActor,
17 MCommentOwnerVideo 17 MCommentOwnerVideo
18} from '../../../types/models' 18} from '../../../types/models'
19import { markCommentAsDeleted } from '../../video-comment'
20import { forwardVideoRelatedActivity } from '../send/utils' 19import { forwardVideoRelatedActivity } from '../send/utils'
21 20
22async function processDeleteActivity (options: APProcessorOptions<ActivityDelete>) { 21async function processDeleteActivity (options: APProcessorOptions<ActivityDelete>) {
@@ -130,7 +129,7 @@ async function processDeleteVideoChannel (videoChannelToRemove: MChannelActor) {
130 129
131function processDeleteVideoComment (byActor: MActorSignature, videoComment: MCommentOwnerVideo, activity: ActivityDelete) { 130function processDeleteVideoComment (byActor: MActorSignature, videoComment: MCommentOwnerVideo, activity: ActivityDelete) {
132 // Already deleted 131 // Already deleted
133 if (videoComment.isDeleted()) return 132 if (videoComment.isDeleted()) return Promise.resolve()
134 133
135 logger.debug('Removing remote video comment "%s".', videoComment.url) 134 logger.debug('Removing remote video comment "%s".', videoComment.url)
136 135
@@ -139,11 +138,9 @@ function processDeleteVideoComment (byActor: MActorSignature, videoComment: MCom
139 throw new Error(`Account ${byActor.url} does not own video comment ${videoComment.url} or video ${videoComment.Video.url}`) 138 throw new Error(`Account ${byActor.url} does not own video comment ${videoComment.url} or video ${videoComment.Video.url}`)
140 } 139 }
141 140
142 await sequelizeTypescript.transaction(async t => { 141 videoComment.markAsDeleted()
143 markCommentAsDeleted(videoComment)
144 142
145 await videoComment.save() 143 await videoComment.save({ transaction: t })
146 })
147 144
148 if (videoComment.Video.isOwned()) { 145 if (videoComment.Video.isOwned()) {
149 // Don't resend the activity to the sender 146 // Don't resend the activity to the sender
diff --git a/server/lib/activitypub/process/process-dislike.ts b/server/lib/activitypub/process/process-dislike.ts
index 089c7b881..ecc57cd10 100644
--- a/server/lib/activitypub/process/process-dislike.ts
+++ b/server/lib/activitypub/process/process-dislike.ts
@@ -6,7 +6,7 @@ import { AccountVideoRateModel } from '../../../models/account/account-video-rat
6import { APProcessorOptions } from '../../../types/activitypub-processor.model' 6import { APProcessorOptions } from '../../../types/activitypub-processor.model'
7import { MActorSignature } from '../../../types/models' 7import { MActorSignature } from '../../../types/models'
8import { forwardVideoRelatedActivity } from '../send/utils' 8import { forwardVideoRelatedActivity } from '../send/utils'
9import { getOrCreateVideoAndAccountAndChannel } from '../videos' 9import { getOrCreateAPVideo } from '../videos'
10 10
11async function processDislikeActivity (options: APProcessorOptions<ActivityCreate | ActivityDislike>) { 11async function processDislikeActivity (options: APProcessorOptions<ActivityCreate | ActivityDislike>) {
12 const { activity, byActor } = options 12 const { activity, byActor } = options
@@ -30,7 +30,7 @@ async function processDislike (activity: ActivityCreate | ActivityDislike, byAct
30 30
31 if (!byAccount) throw new Error('Cannot create dislike with the non account actor ' + byActor.url) 31 if (!byAccount) throw new Error('Cannot create dislike with the non account actor ' + byActor.url)
32 32
33 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: dislikeObject }) 33 const { video } = await getOrCreateAPVideo({ videoObject: dislikeObject })
34 34
35 return sequelizeTypescript.transaction(async t => { 35 return sequelizeTypescript.transaction(async t => {
36 const existingRate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byAccount.id, video.id, activity.id, t) 36 const existingRate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byAccount.id, video.id, activity.id, t)
diff --git a/server/lib/activitypub/process/process-follow.ts b/server/lib/activitypub/process/process-follow.ts
index 38d684512..f85238f8e 100644
--- a/server/lib/activitypub/process/process-follow.ts
+++ b/server/lib/activitypub/process/process-follow.ts
@@ -1,17 +1,17 @@
1import { getServerActor } from '@server/models/application/application'
1import { ActivityFollow } from '../../../../shared/models/activitypub' 2import { ActivityFollow } from '../../../../shared/models/activitypub'
3import { getAPId } from '../../../helpers/activitypub'
2import { retryTransactionWrapper } from '../../../helpers/database-utils' 4import { retryTransactionWrapper } from '../../../helpers/database-utils'
3import { logger } from '../../../helpers/logger' 5import { logger } from '../../../helpers/logger'
4import { sequelizeTypescript } from '../../../initializers/database'
5import { ActorModel } from '../../../models/activitypub/actor'
6import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
7import { sendAccept, sendReject } from '../send'
8import { Notifier } from '../../notifier'
9import { getAPId } from '../../../helpers/activitypub'
10import { CONFIG } from '../../../initializers/config' 6import { CONFIG } from '../../../initializers/config'
7import { sequelizeTypescript } from '../../../initializers/database'
8import { ActorModel } from '../../../models/actor/actor'
9import { ActorFollowModel } from '../../../models/actor/actor-follow'
11import { APProcessorOptions } from '../../../types/activitypub-processor.model' 10import { APProcessorOptions } from '../../../types/activitypub-processor.model'
12import { MActorFollowActors, MActorSignature } from '../../../types/models' 11import { MActorFollowActors, MActorSignature } from '../../../types/models'
12import { Notifier } from '../../notifier'
13import { autoFollowBackIfNeeded } from '../follow' 13import { autoFollowBackIfNeeded } from '../follow'
14import { getServerActor } from '@server/models/application/application' 14import { sendAccept, sendReject } from '../send'
15 15
16async function processFollowActivity (options: APProcessorOptions<ActivityFollow>) { 16async function processFollowActivity (options: APProcessorOptions<ActivityFollow>) {
17 const { activity, byActor } = options 17 const { activity, byActor } = options
@@ -43,7 +43,7 @@ async function processFollow (byActor: MActorSignature, activityId: string, targ
43 if (isFollowingInstance && CONFIG.FOLLOWERS.INSTANCE.ENABLED === false) { 43 if (isFollowingInstance && CONFIG.FOLLOWERS.INSTANCE.ENABLED === false) {
44 logger.info('Rejecting %s because instance followers are disabled.', targetActor.url) 44 logger.info('Rejecting %s because instance followers are disabled.', targetActor.url)
45 45
46 await sendReject(activityId, byActor, targetActor) 46 sendReject(activityId, byActor, targetActor)
47 47
48 return { actorFollow: undefined as MActorFollowActors } 48 return { actorFollow: undefined as MActorFollowActors }
49 } 49 }
@@ -84,8 +84,9 @@ async function processFollow (byActor: MActorSignature, activityId: string, targ
84 84
85 // Target sends to actor he accepted the follow request 85 // Target sends to actor he accepted the follow request
86 if (actorFollow.state === 'accepted') { 86 if (actorFollow.state === 'accepted') {
87 await sendAccept(actorFollow) 87 sendAccept(actorFollow)
88 await autoFollowBackIfNeeded(actorFollow) 88
89 await autoFollowBackIfNeeded(actorFollow, t)
89 } 90 }
90 91
91 return { actorFollow, created, isFollowingInstance, targetActor } 92 return { actorFollow, created, isFollowingInstance, targetActor }
diff --git a/server/lib/activitypub/process/process-like.ts b/server/lib/activitypub/process/process-like.ts
index 8688b3b47..cd4e86cbb 100644
--- a/server/lib/activitypub/process/process-like.ts
+++ b/server/lib/activitypub/process/process-like.ts
@@ -6,7 +6,7 @@ import { AccountVideoRateModel } from '../../../models/account/account-video-rat
6import { APProcessorOptions } from '../../../types/activitypub-processor.model' 6import { APProcessorOptions } from '../../../types/activitypub-processor.model'
7import { MActorSignature } from '../../../types/models' 7import { MActorSignature } from '../../../types/models'
8import { forwardVideoRelatedActivity } from '../send/utils' 8import { forwardVideoRelatedActivity } from '../send/utils'
9import { getOrCreateVideoAndAccountAndChannel } from '../videos' 9import { getOrCreateAPVideo } from '../videos'
10 10
11async function processLikeActivity (options: APProcessorOptions<ActivityLike>) { 11async function processLikeActivity (options: APProcessorOptions<ActivityLike>) {
12 const { activity, byActor } = options 12 const { activity, byActor } = options
@@ -27,7 +27,7 @@ async function processLikeVideo (byActor: MActorSignature, activity: ActivityLik
27 const byAccount = byActor.Account 27 const byAccount = byActor.Account
28 if (!byAccount) throw new Error('Cannot create like with the non account actor ' + byActor.url) 28 if (!byAccount) throw new Error('Cannot create like with the non account actor ' + byActor.url)
29 29
30 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: videoUrl }) 30 const { video } = await getOrCreateAPVideo({ videoObject: videoUrl })
31 31
32 return sequelizeTypescript.transaction(async t => { 32 return sequelizeTypescript.transaction(async t => {
33 const existingRate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byAccount.id, video.id, activity.id, t) 33 const existingRate = await AccountVideoRateModel.loadByAccountAndVideoOrUrl(byAccount.id, video.id, activity.id, t)
diff --git a/server/lib/activitypub/process/process-reject.ts b/server/lib/activitypub/process/process-reject.ts
index 03b669fd9..7f7ab305f 100644
--- a/server/lib/activitypub/process/process-reject.ts
+++ b/server/lib/activitypub/process/process-reject.ts
@@ -1,6 +1,6 @@
1import { ActivityReject } from '../../../../shared/models/activitypub/activity' 1import { ActivityReject } from '../../../../shared/models/activitypub/activity'
2import { sequelizeTypescript } from '../../../initializers/database' 2import { sequelizeTypescript } from '../../../initializers/database'
3import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 3import { ActorFollowModel } from '../../../models/actor/actor-follow'
4import { APProcessorOptions } from '../../../types/activitypub-processor.model' 4import { APProcessorOptions } from '../../../types/activitypub-processor.model'
5import { MActor } from '../../../types/models' 5import { MActor } from '../../../types/models'
6 6
diff --git a/server/lib/activitypub/process/process-undo.ts b/server/lib/activitypub/process/process-undo.ts
index e520c2f0d..d4b2a795f 100644
--- a/server/lib/activitypub/process/process-undo.ts
+++ b/server/lib/activitypub/process/process-undo.ts
@@ -4,14 +4,14 @@ import { retryTransactionWrapper } from '../../../helpers/database-utils'
4import { logger } from '../../../helpers/logger' 4import { logger } from '../../../helpers/logger'
5import { sequelizeTypescript } from '../../../initializers/database' 5import { sequelizeTypescript } from '../../../initializers/database'
6import { AccountVideoRateModel } from '../../../models/account/account-video-rate' 6import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
7import { ActorModel } from '../../../models/activitypub/actor' 7import { ActorModel } from '../../../models/actor/actor'
8import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 8import { ActorFollowModel } from '../../../models/actor/actor-follow'
9import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy' 9import { VideoRedundancyModel } from '../../../models/redundancy/video-redundancy'
10import { VideoShareModel } from '../../../models/video/video-share' 10import { VideoShareModel } from '../../../models/video/video-share'
11import { APProcessorOptions } from '../../../types/activitypub-processor.model' 11import { APProcessorOptions } from '../../../types/activitypub-processor.model'
12import { MActorSignature } from '../../../types/models' 12import { MActorSignature } from '../../../types/models'
13import { forwardVideoRelatedActivity } from '../send/utils' 13import { forwardVideoRelatedActivity } from '../send/utils'
14import { getOrCreateVideoAndAccountAndChannel } from '../videos' 14import { getOrCreateAPVideo } from '../videos'
15 15
16async function processUndoActivity (options: APProcessorOptions<ActivityUndo>) { 16async function processUndoActivity (options: APProcessorOptions<ActivityUndo>) {
17 const { activity, byActor } = options 17 const { activity, byActor } = options
@@ -55,7 +55,7 @@ export {
55async function processUndoLike (byActor: MActorSignature, activity: ActivityUndo) { 55async function processUndoLike (byActor: MActorSignature, activity: ActivityUndo) {
56 const likeActivity = activity.object as ActivityLike 56 const likeActivity = activity.object as ActivityLike
57 57
58 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: likeActivity.object }) 58 const { video } = await getOrCreateAPVideo({ videoObject: likeActivity.object })
59 59
60 return sequelizeTypescript.transaction(async t => { 60 return sequelizeTypescript.transaction(async t => {
61 if (!byActor.Account) throw new Error('Unknown account ' + byActor.url) 61 if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
@@ -80,7 +80,7 @@ async function processUndoDislike (byActor: MActorSignature, activity: ActivityU
80 ? activity.object 80 ? activity.object
81 : activity.object.object as DislikeObject 81 : activity.object.object as DislikeObject
82 82
83 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: dislike.object }) 83 const { video } = await getOrCreateAPVideo({ videoObject: dislike.object })
84 84
85 return sequelizeTypescript.transaction(async t => { 85 return sequelizeTypescript.transaction(async t => {
86 if (!byActor.Account) throw new Error('Unknown account ' + byActor.url) 86 if (!byActor.Account) throw new Error('Unknown account ' + byActor.url)
@@ -103,10 +103,10 @@ async function processUndoDislike (byActor: MActorSignature, activity: ActivityU
103async function processUndoCacheFile (byActor: MActorSignature, activity: ActivityUndo) { 103async function processUndoCacheFile (byActor: MActorSignature, activity: ActivityUndo) {
104 const cacheFileObject = activity.object.object as CacheFileObject 104 const cacheFileObject = activity.object.object as CacheFileObject
105 105
106 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFileObject.object }) 106 const { video } = await getOrCreateAPVideo({ videoObject: cacheFileObject.object })
107 107
108 return sequelizeTypescript.transaction(async t => { 108 return sequelizeTypescript.transaction(async t => {
109 const cacheFile = await VideoRedundancyModel.loadByUrl(cacheFileObject.id) 109 const cacheFile = await VideoRedundancyModel.loadByUrl(cacheFileObject.id, t)
110 if (!cacheFile) { 110 if (!cacheFile) {
111 logger.debug('Cannot undo unknown video cache %s.', cacheFileObject.id) 111 logger.debug('Cannot undo unknown video cache %s.', cacheFileObject.id)
112 return 112 return
@@ -114,7 +114,7 @@ async function processUndoCacheFile (byActor: MActorSignature, activity: Activit
114 114
115 if (cacheFile.actorId !== byActor.id) throw new Error('Cannot delete redundancy ' + cacheFile.url + ' of another actor.') 115 if (cacheFile.actorId !== byActor.id) throw new Error('Cannot delete redundancy ' + cacheFile.url + ' of another actor.')
116 116
117 await cacheFile.destroy() 117 await cacheFile.destroy({ transaction: t })
118 118
119 if (video.isOwned()) { 119 if (video.isOwned()) {
120 // Don't resend the activity to the sender 120 // Don't resend the activity to the sender
diff --git a/server/lib/activitypub/process/process-update.ts b/server/lib/activitypub/process/process-update.ts
index 6df9b93b2..f40008a6b 100644
--- a/server/lib/activitypub/process/process-update.ts
+++ b/server/lib/activitypub/process/process-update.ts
@@ -1,23 +1,20 @@
1import { isRedundancyAccepted } from '@server/lib/redundancy'
1import { ActivityUpdate, CacheFileObject, VideoObject } from '../../../../shared/models/activitypub' 2import { ActivityUpdate, CacheFileObject, VideoObject } from '../../../../shared/models/activitypub'
2import { ActivityPubActor } from '../../../../shared/models/activitypub/activitypub-actor' 3import { ActivityPubActor } from '../../../../shared/models/activitypub/activitypub-actor'
3import { resetSequelizeInstance, retryTransactionWrapper } from '../../../helpers/database-utils' 4import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object'
5import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file'
6import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos'
7import { retryTransactionWrapper } from '../../../helpers/database-utils'
4import { logger } from '../../../helpers/logger' 8import { logger } from '../../../helpers/logger'
5import { sequelizeTypescript } from '../../../initializers/database' 9import { sequelizeTypescript } from '../../../initializers/database'
6import { AccountModel } from '../../../models/account/account' 10import { ActorModel } from '../../../models/actor/actor'
7import { ActorModel } from '../../../models/activitypub/actor' 11import { APProcessorOptions } from '../../../types/activitypub-processor.model'
8import { VideoChannelModel } from '../../../models/video/video-channel' 12import { MActorFull, MActorSignature } from '../../../types/models'
9import { getImageInfoIfExists, updateActorImageInstance, updateActorInstance } from '../actor' 13import { APActorUpdater } from '../actors/updater'
10import { getOrCreateVideoAndAccountAndChannel, getOrCreateVideoChannelFromVideoObject, updateVideoFromAP } from '../videos'
11import { sanitizeAndCheckVideoTorrentObject } from '../../../helpers/custom-validators/activitypub/videos'
12import { isCacheFileObjectValid } from '../../../helpers/custom-validators/activitypub/cache-file'
13import { createOrUpdateCacheFile } from '../cache-file' 14import { createOrUpdateCacheFile } from '../cache-file'
15import { createOrUpdateVideoPlaylist } from '../playlists'
14import { forwardVideoRelatedActivity } from '../send/utils' 16import { forwardVideoRelatedActivity } from '../send/utils'
15import { PlaylistObject } from '../../../../shared/models/activitypub/objects/playlist-object' 17import { APVideoUpdater, getOrCreateAPVideo } from '../videos'
16import { createOrUpdateVideoPlaylist } from '../playlist'
17import { APProcessorOptions } from '../../../types/activitypub-processor.model'
18import { MActorSignature, MAccountIdActor } from '../../../types/models'
19import { isRedundancyAccepted } from '@server/lib/redundancy'
20import { ActorImageType } from '@shared/models'
21 18
22async function processUpdateActivity (options: APProcessorOptions<ActivityUpdate>) { 19async function processUpdateActivity (options: APProcessorOptions<ActivityUpdate>) {
23 const { activity, byActor } = options 20 const { activity, byActor } = options
@@ -25,7 +22,7 @@ async function processUpdateActivity (options: APProcessorOptions<ActivityUpdate
25 const objectType = activity.object.type 22 const objectType = activity.object.type
26 23
27 if (objectType === 'Video') { 24 if (objectType === 'Video') {
28 return retryTransactionWrapper(processUpdateVideo, byActor, activity) 25 return retryTransactionWrapper(processUpdateVideo, activity)
29 } 26 }
30 27
31 if (objectType === 'Person' || objectType === 'Application' || objectType === 'Group') { 28 if (objectType === 'Person' || objectType === 'Application' || objectType === 'Group') {
@@ -55,7 +52,7 @@ export {
55 52
56// --------------------------------------------------------------------------- 53// ---------------------------------------------------------------------------
57 54
58async function processUpdateVideo (actor: MActorSignature, activity: ActivityUpdate) { 55async function processUpdateVideo (activity: ActivityUpdate) {
59 const videoObject = activity.object as VideoObject 56 const videoObject = activity.object as VideoObject
60 57
61 if (sanitizeAndCheckVideoTorrentObject(videoObject) === false) { 58 if (sanitizeAndCheckVideoTorrentObject(videoObject) === false) {
@@ -63,7 +60,7 @@ async function processUpdateVideo (actor: MActorSignature, activity: ActivityUpd
63 return undefined 60 return undefined
64 } 61 }
65 62
66 const { video, created } = await getOrCreateVideoAndAccountAndChannel({ 63 const { video, created } = await getOrCreateAPVideo({
67 videoObject: videoObject.id, 64 videoObject: videoObject.id,
68 allowRefresh: false, 65 allowRefresh: false,
69 fetchType: 'all' 66 fetchType: 'all'
@@ -71,20 +68,8 @@ async function processUpdateVideo (actor: MActorSignature, activity: ActivityUpd
71 // We did not have this video, it has been created so no need to update 68 // We did not have this video, it has been created so no need to update
72 if (created) return 69 if (created) return
73 70
74 // Load new channel 71 const updater = new APVideoUpdater(videoObject, video)
75 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject) 72 return updater.update(activity.to)
76
77 const account = actor.Account as MAccountIdActor
78 account.Actor = actor
79
80 const updateOptions = {
81 video,
82 videoObject,
83 account,
84 channel: channelActor.VideoChannel,
85 overrideTo: activity.to
86 }
87 return updateVideoFromAP(updateOptions)
88} 73}
89 74
90async function processUpdateCacheFile (byActor: MActorSignature, activity: ActivityUpdate) { 75async function processUpdateCacheFile (byActor: MActorSignature, activity: ActivityUpdate) {
@@ -97,7 +82,7 @@ async function processUpdateCacheFile (byActor: MActorSignature, activity: Activ
97 return undefined 82 return undefined
98 } 83 }
99 84
100 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: cacheFileObject.object }) 85 const { video } = await getOrCreateAPVideo({ videoObject: cacheFileObject.object })
101 86
102 await sequelizeTypescript.transaction(async t => { 87 await sequelizeTypescript.transaction(async t => {
103 await createOrUpdateCacheFile(cacheFileObject, video, byActor, t) 88 await createOrUpdateCacheFile(cacheFileObject, video, byActor, t)
@@ -111,56 +96,13 @@ async function processUpdateCacheFile (byActor: MActorSignature, activity: Activ
111 } 96 }
112} 97}
113 98
114async function processUpdateActor (actor: ActorModel, activity: ActivityUpdate) { 99async function processUpdateActor (actor: MActorFull, activity: ActivityUpdate) {
115 const actorAttributesToUpdate = activity.object as ActivityPubActor 100 const actorObject = activity.object as ActivityPubActor
116 101
117 logger.debug('Updating remote account "%s".', actorAttributesToUpdate.url) 102 logger.debug('Updating remote account "%s".', actorObject.url)
118 let accountOrChannelInstance: AccountModel | VideoChannelModel
119 let actorFieldsSave: object
120 let accountOrChannelFieldsSave: object
121 103
122 // Fetch icon? 104 const updater = new APActorUpdater(actorObject, actor)
123 const avatarInfo = getImageInfoIfExists(actorAttributesToUpdate, ActorImageType.AVATAR) 105 return updater.update()
124 const bannerInfo = getImageInfoIfExists(actorAttributesToUpdate, ActorImageType.BANNER)
125
126 try {
127 await sequelizeTypescript.transaction(async t => {
128 actorFieldsSave = actor.toJSON()
129
130 if (actorAttributesToUpdate.type === 'Group') accountOrChannelInstance = actor.VideoChannel
131 else accountOrChannelInstance = actor.Account
132
133 accountOrChannelFieldsSave = accountOrChannelInstance.toJSON()
134
135 await updateActorInstance(actor, actorAttributesToUpdate)
136
137 await updateActorImageInstance(actor, ActorImageType.AVATAR, avatarInfo, t)
138 await updateActorImageInstance(actor, ActorImageType.BANNER, bannerInfo, t)
139
140 await actor.save({ transaction: t })
141
142 accountOrChannelInstance.name = actorAttributesToUpdate.name || actorAttributesToUpdate.preferredUsername
143 accountOrChannelInstance.description = actorAttributesToUpdate.summary
144
145 if (accountOrChannelInstance instanceof VideoChannelModel) accountOrChannelInstance.support = actorAttributesToUpdate.support
146
147 await accountOrChannelInstance.save({ transaction: t })
148 })
149
150 logger.info('Remote account %s updated', actorAttributesToUpdate.url)
151 } catch (err) {
152 if (actor !== undefined && actorFieldsSave !== undefined) {
153 resetSequelizeInstance(actor, actorFieldsSave)
154 }
155
156 if (accountOrChannelInstance !== undefined && accountOrChannelFieldsSave !== undefined) {
157 resetSequelizeInstance(accountOrChannelInstance, accountOrChannelFieldsSave)
158 }
159
160 // This is just a debug because we will retry the insert
161 logger.debug('Cannot update the remote account.', { err })
162 throw err
163 }
164} 106}
165 107
166async function processUpdatePlaylist (byActor: MActorSignature, activity: ActivityUpdate) { 108async function processUpdatePlaylist (byActor: MActorSignature, activity: ActivityUpdate) {
@@ -169,5 +111,5 @@ async function processUpdatePlaylist (byActor: MActorSignature, activity: Activi
169 111
170 if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url) 112 if (!byAccount) throw new Error('Cannot update video playlist with the non account actor ' + byActor.url)
171 113
172 await createOrUpdateVideoPlaylist(playlistObject, byAccount, activity.to) 114 await createOrUpdateVideoPlaylist(playlistObject, activity.to)
173} 115}
diff --git a/server/lib/activitypub/process/process-view.ts b/server/lib/activitypub/process/process-view.ts
index 84697673b..5593ee257 100644
--- a/server/lib/activitypub/process/process-view.ts
+++ b/server/lib/activitypub/process/process-view.ts
@@ -1,10 +1,10 @@
1import { getOrCreateVideoAndAccountAndChannel } from '../videos' 1import { getOrCreateAPVideo } from '../videos'
2import { forwardVideoRelatedActivity } from '../send/utils' 2import { forwardVideoRelatedActivity } from '../send/utils'
3import { Redis } from '../../redis' 3import { Redis } from '../../redis'
4import { ActivityCreate, ActivityView, ViewObject } from '../../../../shared/models/activitypub' 4import { ActivityCreate, ActivityView, ViewObject } from '../../../../shared/models/activitypub'
5import { APProcessorOptions } from '../../../types/activitypub-processor.model' 5import { APProcessorOptions } from '../../../types/activitypub-processor.model'
6import { MActorSignature } from '../../../types/models' 6import { MActorSignature } from '../../../types/models'
7import { LiveManager } from '@server/lib/live-manager' 7import { LiveManager } from '@server/lib/live/live-manager'
8 8
9async function processViewActivity (options: APProcessorOptions<ActivityCreate | ActivityView>) { 9async function processViewActivity (options: APProcessorOptions<ActivityCreate | ActivityView>) {
10 const { activity, byActor } = options 10 const { activity, byActor } = options
@@ -24,12 +24,11 @@ async function processCreateView (activity: ActivityView | ActivityCreate, byAct
24 ? activity.object 24 ? activity.object
25 : (activity.object as ViewObject).object 25 : (activity.object as ViewObject).object
26 26
27 const options = { 27 const { video } = await getOrCreateAPVideo({
28 videoObject, 28 videoObject,
29 fetchType: 'only-video' as 'only-video', 29 fetchType: 'only-video',
30 allowRefresh: false as false 30 allowRefresh: false
31 } 31 })
32 const { video } = await getOrCreateVideoAndAccountAndChannel(options)
33 32
34 if (!video.isLive) { 33 if (!video.isLive) {
35 await Redis.Instance.addVideoView(video.id) 34 await Redis.Instance.addVideoView(video.id)
diff --git a/server/lib/activitypub/process/process.ts b/server/lib/activitypub/process/process.ts
index 5cef75665..02a23d098 100644
--- a/server/lib/activitypub/process/process.ts
+++ b/server/lib/activitypub/process/process.ts
@@ -1,22 +1,22 @@
1import { StatsManager } from '@server/lib/stat-manager'
1import { Activity, ActivityType } from '../../../../shared/models/activitypub' 2import { Activity, ActivityType } from '../../../../shared/models/activitypub'
2import { checkUrlsSameHost, getAPId } from '../../../helpers/activitypub' 3import { checkUrlsSameHost, getAPId } from '../../../helpers/activitypub'
3import { logger } from '../../../helpers/logger' 4import { logger } from '../../../helpers/logger'
5import { APProcessorOptions } from '../../../types/activitypub-processor.model'
6import { MActorDefault, MActorSignature } from '../../../types/models'
7import { getOrCreateAPActor } from '../actors'
4import { processAcceptActivity } from './process-accept' 8import { processAcceptActivity } from './process-accept'
5import { processAnnounceActivity } from './process-announce' 9import { processAnnounceActivity } from './process-announce'
6import { processCreateActivity } from './process-create' 10import { processCreateActivity } from './process-create'
7import { processDeleteActivity } from './process-delete' 11import { processDeleteActivity } from './process-delete'
12import { processDislikeActivity } from './process-dislike'
13import { processFlagActivity } from './process-flag'
8import { processFollowActivity } from './process-follow' 14import { processFollowActivity } from './process-follow'
9import { processLikeActivity } from './process-like' 15import { processLikeActivity } from './process-like'
10import { processRejectActivity } from './process-reject' 16import { processRejectActivity } from './process-reject'
11import { processUndoActivity } from './process-undo' 17import { processUndoActivity } from './process-undo'
12import { processUpdateActivity } from './process-update' 18import { processUpdateActivity } from './process-update'
13import { getOrCreateActorAndServerAndModel } from '../actor'
14import { processDislikeActivity } from './process-dislike'
15import { processFlagActivity } from './process-flag'
16import { processViewActivity } from './process-view' 19import { processViewActivity } from './process-view'
17import { APProcessorOptions } from '../../../types/activitypub-processor.model'
18import { MActorDefault, MActorSignature } from '../../../types/models'
19import { StatsManager } from '@server/lib/stat-manager'
20 20
21const processActivity: { [ P in ActivityType ]: (options: APProcessorOptions<Activity>) => Promise<any> } = { 21const processActivity: { [ P in ActivityType ]: (options: APProcessorOptions<Activity>) => Promise<any> } = {
22 Create: processCreateActivity, 22 Create: processCreateActivity,
@@ -65,7 +65,7 @@ async function processActivities (
65 continue 65 continue
66 } 66 }
67 67
68 const byActor = signatureActor || actorsCache[actorUrl] || await getOrCreateActorAndServerAndModel(actorUrl) 68 const byActor = signatureActor || actorsCache[actorUrl] || await getOrCreateAPActor(actorUrl)
69 actorsCache[actorUrl] = byActor 69 actorsCache[actorUrl] = byActor
70 70
71 const activityProcessor = processActivity[activity.type] 71 const activityProcessor = processActivity[activity.type]
diff --git a/server/lib/activitypub/send/send-delete.ts b/server/lib/activitypub/send/send-delete.ts
index e0acced18..d31f8c10b 100644
--- a/server/lib/activitypub/send/send-delete.ts
+++ b/server/lib/activitypub/send/send-delete.ts
@@ -2,7 +2,7 @@ import { Transaction } from 'sequelize'
2import { getServerActor } from '@server/models/application/application' 2import { getServerActor } from '@server/models/application/application'
3import { ActivityAudience, ActivityDelete } from '../../../../shared/models/activitypub' 3import { ActivityAudience, ActivityDelete } from '../../../../shared/models/activitypub'
4import { logger } from '../../../helpers/logger' 4import { logger } from '../../../helpers/logger'
5import { ActorModel } from '../../../models/activitypub/actor' 5import { ActorModel } from '../../../models/actor/actor'
6import { VideoCommentModel } from '../../../models/video/video-comment' 6import { VideoCommentModel } from '../../../models/video/video-comment'
7import { VideoShareModel } from '../../../models/video/video-share' 7import { VideoShareModel } from '../../../models/video/video-share'
8import { MActorUrl } from '../../../types/models' 8import { MActorUrl } from '../../../types/models'
diff --git a/server/lib/activitypub/send/send-view.ts b/server/lib/activitypub/send/send-view.ts
index 9254dc7c5..153e94295 100644
--- a/server/lib/activitypub/send/send-view.ts
+++ b/server/lib/activitypub/send/send-view.ts
@@ -2,7 +2,7 @@ import { Transaction } from 'sequelize'
2import { MActorAudience, MVideoImmutable, MVideoUrl } from '@server/types/models' 2import { MActorAudience, MVideoImmutable, MVideoUrl } from '@server/types/models'
3import { ActivityAudience, ActivityView } from '../../../../shared/models/activitypub' 3import { ActivityAudience, ActivityView } from '../../../../shared/models/activitypub'
4import { logger } from '../../../helpers/logger' 4import { logger } from '../../../helpers/logger'
5import { ActorModel } from '../../../models/activitypub/actor' 5import { ActorModel } from '../../../models/actor/actor'
6import { audiencify, getAudience } from '../audience' 6import { audiencify, getAudience } from '../audience'
7import { getLocalVideoViewActivityPubUrl } from '../url' 7import { getLocalVideoViewActivityPubUrl } from '../url'
8import { sendVideoRelatedActivity } from './utils' 8import { sendVideoRelatedActivity } from './utils'
diff --git a/server/lib/activitypub/send/utils.ts b/server/lib/activitypub/send/utils.ts
index 85a9f009d..7cd8030e1 100644
--- a/server/lib/activitypub/send/utils.ts
+++ b/server/lib/activitypub/send/utils.ts
@@ -1,14 +1,14 @@
1import { Transaction } from 'sequelize' 1import { Transaction } from 'sequelize'
2import { getServerActor } from '@server/models/application/application'
3import { ContextType } from '@shared/models/activitypub/context'
2import { Activity, ActivityAudience } from '../../../../shared/models/activitypub' 4import { Activity, ActivityAudience } from '../../../../shared/models/activitypub'
5import { afterCommitIfTransaction } from '../../../helpers/database-utils'
3import { logger } from '../../../helpers/logger' 6import { logger } from '../../../helpers/logger'
4import { ActorModel } from '../../../models/activitypub/actor' 7import { ActorModel } from '../../../models/actor/actor'
5import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 8import { ActorFollowModel } from '../../../models/actor/actor-follow'
9import { MActor, MActorId, MActorLight, MActorWithInboxes, MVideoAccountLight, MVideoId, MVideoImmutable } from '../../../types/models'
6import { JobQueue } from '../../job-queue' 10import { JobQueue } from '../../job-queue'
7import { getActorsInvolvedInVideo, getAudienceFromFollowersOf, getRemoteVideoAudience } from '../audience' 11import { getActorsInvolvedInVideo, getAudienceFromFollowersOf, getRemoteVideoAudience } from '../audience'
8import { afterCommitIfTransaction } from '../../../helpers/database-utils'
9import { MActor, MActorId, MActorLight, MActorWithInboxes, MVideoAccountLight, MVideoId, MVideoImmutable } from '../../../types/models'
10import { getServerActor } from '@server/models/application/application'
11import { ContextType } from '@shared/models/activitypub/context'
12 12
13async function sendVideoRelatedActivity (activityBuilder: (audience: ActivityAudience) => Activity, options: { 13async function sendVideoRelatedActivity (activityBuilder: (audience: ActivityAudience) => Activity, options: {
14 byActor: MActorLight 14 byActor: MActorLight
@@ -22,7 +22,9 @@ async function sendVideoRelatedActivity (activityBuilder: (audience: ActivityAud
22 22
23 // Send to origin 23 // Send to origin
24 if (video.isOwned() === false) { 24 if (video.isOwned() === false) {
25 const accountActor = (video as MVideoAccountLight).VideoChannel?.Account?.Actor || await ActorModel.loadAccountActorByVideoId(video.id) 25 let accountActor: MActorLight = (video as MVideoAccountLight).VideoChannel?.Account?.Actor
26
27 if (!accountActor) accountActor = await ActorModel.loadAccountActorByVideoId(video.id, transaction)
26 28
27 const audience = getRemoteVideoAudience(accountActor, actorsInvolvedInVideo) 29 const audience = getRemoteVideoAudience(accountActor, actorsInvolvedInVideo)
28 const activity = activityBuilder(audience) 30 const activity = activityBuilder(audience)
diff --git a/server/lib/activitypub/share.ts b/server/lib/activitypub/share.ts
index c22fa0893..1ff01a175 100644
--- a/server/lib/activitypub/share.ts
+++ b/server/lib/activitypub/share.ts
@@ -7,7 +7,7 @@ import { doJSONRequest } from '../../helpers/requests'
7import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants' 7import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants'
8import { VideoShareModel } from '../../models/video/video-share' 8import { VideoShareModel } from '../../models/video/video-share'
9import { MChannelActorLight, MVideo, MVideoAccountLight, MVideoId } from '../../types/models/video' 9import { MChannelActorLight, MVideo, MVideoAccountLight, MVideoId } from '../../types/models/video'
10import { getOrCreateActorAndServerAndModel } from './actor' 10import { getOrCreateAPActor } from './actors'
11import { sendUndoAnnounce, sendVideoAnnounce } from './send' 11import { sendUndoAnnounce, sendVideoAnnounce } from './send'
12import { getLocalVideoAnnounceActivityPubUrl } from './url' 12import { getLocalVideoAnnounceActivityPubUrl } from './url'
13 13
@@ -40,23 +40,7 @@ async function changeVideoChannelShare (
40async function addVideoShares (shareUrls: string[], video: MVideoId) { 40async function addVideoShares (shareUrls: string[], video: MVideoId) {
41 await Bluebird.map(shareUrls, async shareUrl => { 41 await Bluebird.map(shareUrls, async shareUrl => {
42 try { 42 try {
43 const { body } = await doJSONRequest<any>(shareUrl, { activityPub: true }) 43 await addVideoShare(shareUrl, video)
44 if (!body || !body.actor) throw new Error('Body or body actor is invalid')
45
46 const actorUrl = getAPId(body.actor)
47 if (checkUrlsSameHost(shareUrl, actorUrl) !== true) {
48 throw new Error(`Actor url ${actorUrl} has not the same host than the share url ${shareUrl}`)
49 }
50
51 const actor = await getOrCreateActorAndServerAndModel(actorUrl)
52
53 const entry = {
54 actorId: actor.id,
55 videoId: video.id,
56 url: shareUrl
57 }
58
59 await VideoShareModel.upsert(entry)
60 } catch (err) { 44 } catch (err) {
61 logger.warn('Cannot add share %s.', shareUrl, { err }) 45 logger.warn('Cannot add share %s.', shareUrl, { err })
62 } 46 }
@@ -71,6 +55,26 @@ export {
71 55
72// --------------------------------------------------------------------------- 56// ---------------------------------------------------------------------------
73 57
58async function addVideoShare (shareUrl: string, video: MVideoId) {
59 const { body } = await doJSONRequest<any>(shareUrl, { activityPub: true })
60 if (!body || !body.actor) throw new Error('Body or body actor is invalid')
61
62 const actorUrl = getAPId(body.actor)
63 if (checkUrlsSameHost(shareUrl, actorUrl) !== true) {
64 throw new Error(`Actor url ${actorUrl} has not the same host than the share url ${shareUrl}`)
65 }
66
67 const actor = await getOrCreateAPActor(actorUrl)
68
69 const entry = {
70 actorId: actor.id,
71 videoId: video.id,
72 url: shareUrl
73 }
74
75 await VideoShareModel.upsert(entry)
76}
77
74async function shareByServer (video: MVideo, t: Transaction) { 78async function shareByServer (video: MVideo, t: Transaction) {
75 const serverActor = await getServerActor() 79 const serverActor = await getServerActor()
76 80
diff --git a/server/lib/activitypub/video-comments.ts b/server/lib/activitypub/video-comments.ts
index e23e0c0e7..6b7f9504f 100644
--- a/server/lib/activitypub/video-comments.ts
+++ b/server/lib/activitypub/video-comments.ts
@@ -6,8 +6,8 @@ import { doJSONRequest } from '../../helpers/requests'
6import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants' 6import { ACTIVITY_PUB, CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants'
7import { VideoCommentModel } from '../../models/video/video-comment' 7import { VideoCommentModel } from '../../models/video/video-comment'
8import { MCommentOwner, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../types/models/video' 8import { MCommentOwner, MCommentOwnerVideo, MVideoAccountLightBlacklistAllFiles } from '../../types/models/video'
9import { getOrCreateActorAndServerAndModel } from './actor' 9import { getOrCreateAPActor } from './actors'
10import { getOrCreateVideoAndAccountAndChannel } from './videos' 10import { getOrCreateAPVideo } from './videos'
11 11
12type ResolveThreadParams = { 12type ResolveThreadParams = {
13 url: string 13 url: string
@@ -29,10 +29,11 @@ async function addVideoComments (commentUrls: string[]) {
29 29
30async function resolveThread (params: ResolveThreadParams): ResolveThreadResult { 30async function resolveThread (params: ResolveThreadParams): ResolveThreadResult {
31 const { url, isVideo } = params 31 const { url, isVideo } = params
32
32 if (params.commentCreated === undefined) params.commentCreated = false 33 if (params.commentCreated === undefined) params.commentCreated = false
33 if (params.comments === undefined) params.comments = [] 34 if (params.comments === undefined) params.comments = []
34 35
35 // If it is not a video, or if we don't know if it's a video 36 // If it is not a video, or if we don't know if it's a video, try to get the thread from DB
36 if (isVideo === false || isVideo === undefined) { 37 if (isVideo === false || isVideo === undefined) {
37 const result = await resolveCommentFromDB(params) 38 const result = await resolveCommentFromDB(params)
38 if (result) return result 39 if (result) return result
@@ -42,7 +43,7 @@ async function resolveThread (params: ResolveThreadParams): ResolveThreadResult
42 // If it is a video, or if we don't know if it's a video 43 // If it is a video, or if we don't know if it's a video
43 if (isVideo === true || isVideo === undefined) { 44 if (isVideo === true || isVideo === undefined) {
44 // Keep await so we catch the exception 45 // Keep await so we catch the exception
45 return await tryResolveThreadFromVideo(params) 46 return await tryToResolveThreadFromVideo(params)
46 } 47 }
47 } catch (err) { 48 } catch (err) {
48 logger.debug('Cannot resolve thread from video %s, maybe because it was not a video', url, { err }) 49 logger.debug('Cannot resolve thread from video %s, maybe because it was not a video', url, { err })
@@ -62,34 +63,32 @@ async function resolveCommentFromDB (params: ResolveThreadParams) {
62 const { url, comments, commentCreated } = params 63 const { url, comments, commentCreated } = params
63 64
64 const commentFromDatabase = await VideoCommentModel.loadByUrlAndPopulateReplyAndVideoUrlAndAccount(url) 65 const commentFromDatabase = await VideoCommentModel.loadByUrlAndPopulateReplyAndVideoUrlAndAccount(url)
65 if (commentFromDatabase) { 66 if (!commentFromDatabase) return undefined
66 let parentComments = comments.concat([ commentFromDatabase ])
67 67
68 // Speed up things and resolve directly the thread 68 let parentComments = comments.concat([ commentFromDatabase ])
69 if (commentFromDatabase.InReplyToVideoComment) {
70 const data = await VideoCommentModel.listThreadParentComments(commentFromDatabase, undefined, 'DESC')
71 69
72 parentComments = parentComments.concat(data) 70 // Speed up things and resolve directly the thread
73 } 71 if (commentFromDatabase.InReplyToVideoComment) {
72 const data = await VideoCommentModel.listThreadParentComments(commentFromDatabase, undefined, 'DESC')
74 73
75 return resolveThread({ 74 parentComments = parentComments.concat(data)
76 url: commentFromDatabase.Video.url,
77 comments: parentComments,
78 isVideo: true,
79 commentCreated
80 })
81 } 75 }
82 76
83 return undefined 77 return resolveThread({
78 url: commentFromDatabase.Video.url,
79 comments: parentComments,
80 isVideo: true,
81 commentCreated
82 })
84} 83}
85 84
86async function tryResolveThreadFromVideo (params: ResolveThreadParams) { 85async function tryToResolveThreadFromVideo (params: ResolveThreadParams) {
87 const { url, comments, commentCreated } = params 86 const { url, comments, commentCreated } = params
88 87
89 // Maybe it's a reply to a video? 88 // Maybe it's a reply to a video?
90 // If yes, it's done: we resolved all the thread 89 // If yes, it's done: we resolved all the thread
91 const syncParam = { likes: true, dislikes: true, shares: true, comments: false, thumbnail: true, refreshVideo: false } 90 const syncParam = { likes: true, dislikes: true, shares: true, comments: false, thumbnail: true, refreshVideo: false }
92 const { video } = await getOrCreateVideoAndAccountAndChannel({ videoObject: url, syncParam }) 91 const { video } = await getOrCreateAPVideo({ videoObject: url, syncParam })
93 92
94 if (video.isOwned() && !video.hasPrivacyForFederation()) { 93 if (video.isOwned() && !video.hasPrivacyForFederation()) {
95 throw new Error('Cannot resolve thread of video with privacy that is not compatible with federation') 94 throw new Error('Cannot resolve thread of video with privacy that is not compatible with federation')
@@ -148,7 +147,7 @@ async function resolveRemoteParentComment (params: ResolveThreadParams) {
148 } 147 }
149 148
150 const actor = actorUrl 149 const actor = actorUrl
151 ? await getOrCreateActorAndServerAndModel(actorUrl, 'all') 150 ? await getOrCreateAPActor(actorUrl, 'all')
152 : null 151 : null
153 152
154 const comment = new VideoCommentModel({ 153 const comment = new VideoCommentModel({
diff --git a/server/lib/activitypub/video-rates.ts b/server/lib/activitypub/video-rates.ts
index f40c07fea..9fb97ef84 100644
--- a/server/lib/activitypub/video-rates.ts
+++ b/server/lib/activitypub/video-rates.ts
@@ -3,44 +3,23 @@ import { Transaction } from 'sequelize'
3import { doJSONRequest } from '@server/helpers/requests' 3import { doJSONRequest } from '@server/helpers/requests'
4import { VideoRateType } from '../../../shared/models/videos' 4import { VideoRateType } from '../../../shared/models/videos'
5import { checkUrlsSameHost, getAPId } from '../../helpers/activitypub' 5import { checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
6import { logger } from '../../helpers/logger' 6import { logger, loggerTagsFactory } from '../../helpers/logger'
7import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants' 7import { CRAWL_REQUEST_CONCURRENCY } from '../../initializers/constants'
8import { AccountVideoRateModel } from '../../models/account/account-video-rate' 8import { AccountVideoRateModel } from '../../models/account/account-video-rate'
9import { MAccountActor, MActorUrl, MVideo, MVideoAccountLight, MVideoId } from '../../types/models' 9import { MAccountActor, MActorUrl, MVideo, MVideoAccountLight, MVideoId } from '../../types/models'
10import { getOrCreateActorAndServerAndModel } from './actor' 10import { getOrCreateAPActor } from './actors'
11import { sendLike, sendUndoDislike, sendUndoLike } from './send' 11import { sendLike, sendUndoDislike, sendUndoLike } from './send'
12import { sendDislike } from './send/send-dislike' 12import { sendDislike } from './send/send-dislike'
13import { getVideoDislikeActivityPubUrlByLocalActor, getVideoLikeActivityPubUrlByLocalActor } from './url' 13import { getVideoDislikeActivityPubUrlByLocalActor, getVideoLikeActivityPubUrlByLocalActor } from './url'
14 14
15const lTags = loggerTagsFactory('ap', 'video-rate', 'create')
16
15async function createRates (ratesUrl: string[], video: MVideo, rate: VideoRateType) { 17async function createRates (ratesUrl: string[], video: MVideo, rate: VideoRateType) {
16 await Bluebird.map(ratesUrl, async rateUrl => { 18 await Bluebird.map(ratesUrl, async rateUrl => {
17 try { 19 try {
18 // Fetch url 20 await createRate(rateUrl, video, rate)
19 const { body } = await doJSONRequest<any>(rateUrl, { activityPub: true })
20 if (!body || !body.actor) throw new Error('Body or body actor is invalid')
21
22 const actorUrl = getAPId(body.actor)
23 if (checkUrlsSameHost(actorUrl, rateUrl) !== true) {
24 throw new Error(`Rate url ${rateUrl} has not the same host than actor url ${actorUrl}`)
25 }
26
27 if (checkUrlsSameHost(body.id, rateUrl) !== true) {
28 throw new Error(`Rate url ${rateUrl} host is different from the AP object id ${body.id}`)
29 }
30
31 const actor = await getOrCreateActorAndServerAndModel(actorUrl)
32
33 const entry = {
34 videoId: video.id,
35 accountId: actor.Account.id,
36 type: rate,
37 url: body.id
38 }
39
40 // Video "likes"/"dislikes" will be updated by the caller
41 await AccountVideoRateModel.upsert(entry)
42 } catch (err) { 21 } catch (err) {
43 logger.warn('Cannot add rate %s.', rateUrl, { err }) 22 logger.info('Cannot add rate %s.', rateUrl, { err, ...lTags(rateUrl, video.uuid, video.url) })
44 } 23 }
45 }, { concurrency: CRAWL_REQUEST_CONCURRENCY }) 24 }, { concurrency: CRAWL_REQUEST_CONCURRENCY })
46} 25}
@@ -73,8 +52,39 @@ function getLocalRateUrl (rateType: VideoRateType, actor: MActorUrl, video: MVid
73 : getVideoDislikeActivityPubUrlByLocalActor(actor, video) 52 : getVideoDislikeActivityPubUrlByLocalActor(actor, video)
74} 53}
75 54
55// ---------------------------------------------------------------------------
56
76export { 57export {
77 getLocalRateUrl, 58 getLocalRateUrl,
78 createRates, 59 createRates,
79 sendVideoRateChange 60 sendVideoRateChange
80} 61}
62
63// ---------------------------------------------------------------------------
64
65async function createRate (rateUrl: string, video: MVideo, rate: VideoRateType) {
66 // Fetch url
67 const { body } = await doJSONRequest<any>(rateUrl, { activityPub: true })
68 if (!body || !body.actor) throw new Error('Body or body actor is invalid')
69
70 const actorUrl = getAPId(body.actor)
71 if (checkUrlsSameHost(actorUrl, rateUrl) !== true) {
72 throw new Error(`Rate url ${rateUrl} has not the same host than actor url ${actorUrl}`)
73 }
74
75 if (checkUrlsSameHost(body.id, rateUrl) !== true) {
76 throw new Error(`Rate url ${rateUrl} host is different from the AP object id ${body.id}`)
77 }
78
79 const actor = await getOrCreateAPActor(actorUrl)
80
81 const entry = {
82 videoId: video.id,
83 accountId: actor.Account.id,
84 type: rate,
85 url: body.id
86 }
87
88 // Video "likes"/"dislikes" will be updated by the caller
89 await AccountVideoRateModel.upsert(entry)
90}
diff --git a/server/lib/activitypub/videos.ts b/server/lib/activitypub/videos.ts
deleted file mode 100644
index 127a0dd8a..000000000
--- a/server/lib/activitypub/videos.ts
+++ /dev/null
@@ -1,931 +0,0 @@
1import * as Bluebird from 'bluebird'
2import { maxBy, minBy } from 'lodash'
3import * as magnetUtil from 'magnet-uri'
4import { basename } from 'path'
5import { Transaction } from 'sequelize/types'
6import { TrackerModel } from '@server/models/server/tracker'
7import { VideoLiveModel } from '@server/models/video/video-live'
8import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
9import {
10 ActivityHashTagObject,
11 ActivityMagnetUrlObject,
12 ActivityPlaylistSegmentHashesObject,
13 ActivityPlaylistUrlObject,
14 ActivitypubHttpFetcherPayload,
15 ActivityTagObject,
16 ActivityUrlObject,
17 ActivityVideoUrlObject
18} from '../../../shared/index'
19import { ActivityTrackerUrlObject, VideoObject } from '../../../shared/models/activitypub/objects'
20import { VideoPrivacy } from '../../../shared/models/videos'
21import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
22import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
23import { buildRemoteVideoBaseUrl, checkUrlsSameHost, getAPId } from '../../helpers/activitypub'
24import {
25 isAPVideoFileUrlMetadataObject,
26 isAPVideoTrackerUrlObject,
27 sanitizeAndCheckVideoTorrentObject
28} from '../../helpers/custom-validators/activitypub/videos'
29import { isArray } from '../../helpers/custom-validators/misc'
30import { isVideoFileInfoHashValid } from '../../helpers/custom-validators/videos'
31import { deleteNonExistingModels, resetSequelizeInstance, retryTransactionWrapper } from '../../helpers/database-utils'
32import { logger } from '../../helpers/logger'
33import { doJSONRequest, PeerTubeRequestError } from '../../helpers/requests'
34import { fetchVideoByUrl, getExtFromMimetype, VideoFetchByUrlType } from '../../helpers/video'
35import {
36 ACTIVITY_PUB,
37 MIMETYPES,
38 P2P_MEDIA_LOADER_PEER_VERSION,
39 PREVIEWS_SIZE,
40 REMOTE_SCHEME,
41 THUMBNAILS_SIZE
42} from '../../initializers/constants'
43import { sequelizeTypescript } from '../../initializers/database'
44import { AccountVideoRateModel } from '../../models/account/account-video-rate'
45import { VideoModel } from '../../models/video/video'
46import { VideoCaptionModel } from '../../models/video/video-caption'
47import { VideoCommentModel } from '../../models/video/video-comment'
48import { VideoFileModel } from '../../models/video/video-file'
49import { VideoShareModel } from '../../models/video/video-share'
50import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
51import {
52 MAccountIdActor,
53 MChannelAccountLight,
54 MChannelDefault,
55 MChannelId,
56 MStreamingPlaylist,
57 MStreamingPlaylistFilesVideo,
58 MStreamingPlaylistVideo,
59 MVideo,
60 MVideoAccountLight,
61 MVideoAccountLightBlacklistAllFiles,
62 MVideoAP,
63 MVideoAPWithoutCaption,
64 MVideoCaption,
65 MVideoFile,
66 MVideoFullLight,
67 MVideoId,
68 MVideoImmutable,
69 MVideoThumbnail,
70 MVideoWithHost
71} from '../../types/models'
72import { MThumbnail } from '../../types/models/video/thumbnail'
73import { FilteredModelAttributes } from '../../types/sequelize'
74import { ActorFollowScoreCache } from '../files-cache'
75import { JobQueue } from '../job-queue'
76import { Notifier } from '../notifier'
77import { PeerTubeSocket } from '../peertube-socket'
78import { createPlaceholderThumbnail, createVideoMiniatureFromUrl } from '../thumbnail'
79import { setVideoTags } from '../video'
80import { autoBlacklistVideoIfNeeded } from '../video-blacklist'
81import { generateTorrentFileName } from '../video-paths'
82import { getOrCreateActorAndServerAndModel } from './actor'
83import { crawlCollectionPage } from './crawl'
84import { sendCreateVideo, sendUpdateVideo } from './send'
85import { addVideoShares, shareVideoByServerAndChannel } from './share'
86import { addVideoComments } from './video-comments'
87import { createRates } from './video-rates'
88
89async function federateVideoIfNeeded (videoArg: MVideoAPWithoutCaption, isNewVideo: boolean, transaction?: Transaction) {
90 const video = videoArg as MVideoAP
91
92 if (
93 // Check this is not a blacklisted video, or unfederated blacklisted video
94 (video.isBlacklisted() === false || (isNewVideo === false && video.VideoBlacklist.unfederated === false)) &&
95 // Check the video is public/unlisted and published
96 video.hasPrivacyForFederation() && video.hasStateForFederation()
97 ) {
98 // Fetch more attributes that we will need to serialize in AP object
99 if (isArray(video.VideoCaptions) === false) {
100 video.VideoCaptions = await video.$get('VideoCaptions', {
101 attributes: [ 'filename', 'language' ],
102 transaction
103 })
104 }
105
106 if (isNewVideo) {
107 // Now we'll add the video's meta data to our followers
108 await sendCreateVideo(video, transaction)
109 await shareVideoByServerAndChannel(video, transaction)
110 } else {
111 await sendUpdateVideo(video, transaction)
112 }
113 }
114}
115
116async function fetchRemoteVideo (videoUrl: string): Promise<{ statusCode: number, videoObject: VideoObject }> {
117 logger.info('Fetching remote video %s.', videoUrl)
118
119 const { statusCode, body } = await doJSONRequest<any>(videoUrl, { activityPub: true })
120
121 if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
122 logger.debug('Remote video JSON is not valid.', { body })
123 return { statusCode, videoObject: undefined }
124 }
125
126 return { statusCode, videoObject: body }
127}
128
129async function fetchRemoteVideoDescription (video: MVideoAccountLight) {
130 const host = video.VideoChannel.Account.Actor.Server.host
131 const path = video.getDescriptionAPIPath()
132 const url = REMOTE_SCHEME.HTTP + '://' + host + path
133
134 const { body } = await doJSONRequest<any>(url)
135 return body.description || ''
136}
137
138function getOrCreateVideoChannelFromVideoObject (videoObject: VideoObject) {
139 const channel = videoObject.attributedTo.find(a => a.type === 'Group')
140 if (!channel) throw new Error('Cannot find associated video channel to video ' + videoObject.url)
141
142 if (checkUrlsSameHost(channel.id, videoObject.id) !== true) {
143 throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${videoObject.id}`)
144 }
145
146 return getOrCreateActorAndServerAndModel(channel.id, 'all')
147}
148
149type SyncParam = {
150 likes: boolean
151 dislikes: boolean
152 shares: boolean
153 comments: boolean
154 thumbnail: boolean
155 refreshVideo?: boolean
156}
157async function syncVideoExternalAttributes (video: MVideo, fetchedVideo: VideoObject, syncParam: SyncParam) {
158 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
159
160 const jobPayloads: ActivitypubHttpFetcherPayload[] = []
161
162 if (syncParam.likes === true) {
163 const handler = items => createRates(items, video, 'like')
164 const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'like' as 'like', crawlStartDate)
165
166 await crawlCollectionPage<string>(fetchedVideo.likes, handler, cleaner)
167 .catch(err => logger.error('Cannot add likes of video %s.', video.uuid, { err, rootUrl: fetchedVideo.likes }))
168 } else {
169 jobPayloads.push({ uri: fetchedVideo.likes, videoId: video.id, type: 'video-likes' as 'video-likes' })
170 }
171
172 if (syncParam.dislikes === true) {
173 const handler = items => createRates(items, video, 'dislike')
174 const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, 'dislike' as 'dislike', crawlStartDate)
175
176 await crawlCollectionPage<string>(fetchedVideo.dislikes, handler, cleaner)
177 .catch(err => logger.error('Cannot add dislikes of video %s.', video.uuid, { err, rootUrl: fetchedVideo.dislikes }))
178 } else {
179 jobPayloads.push({ uri: fetchedVideo.dislikes, videoId: video.id, type: 'video-dislikes' as 'video-dislikes' })
180 }
181
182 if (syncParam.shares === true) {
183 const handler = items => addVideoShares(items, video)
184 const cleaner = crawlStartDate => VideoShareModel.cleanOldSharesOf(video.id, crawlStartDate)
185
186 await crawlCollectionPage<string>(fetchedVideo.shares, handler, cleaner)
187 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err, rootUrl: fetchedVideo.shares }))
188 } else {
189 jobPayloads.push({ uri: fetchedVideo.shares, videoId: video.id, type: 'video-shares' as 'video-shares' })
190 }
191
192 if (syncParam.comments === true) {
193 const handler = items => addVideoComments(items)
194 const cleaner = crawlStartDate => VideoCommentModel.cleanOldCommentsOf(video.id, crawlStartDate)
195
196 await crawlCollectionPage<string>(fetchedVideo.comments, handler, cleaner)
197 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err, rootUrl: fetchedVideo.comments }))
198 } else {
199 jobPayloads.push({ uri: fetchedVideo.comments, videoId: video.id, type: 'video-comments' as 'video-comments' })
200 }
201
202 await Bluebird.map(jobPayloads, payload => JobQueue.Instance.createJobWithPromise({ type: 'activitypub-http-fetcher', payload }))
203}
204
205type GetVideoResult <T> = Promise<{
206 video: T
207 created: boolean
208 autoBlacklisted?: boolean
209}>
210
211type GetVideoParamAll = {
212 videoObject: { id: string } | string
213 syncParam?: SyncParam
214 fetchType?: 'all'
215 allowRefresh?: boolean
216}
217
218type GetVideoParamImmutable = {
219 videoObject: { id: string } | string
220 syncParam?: SyncParam
221 fetchType: 'only-immutable-attributes'
222 allowRefresh: false
223}
224
225type GetVideoParamOther = {
226 videoObject: { id: string } | string
227 syncParam?: SyncParam
228 fetchType?: 'all' | 'only-video'
229 allowRefresh?: boolean
230}
231
232function getOrCreateVideoAndAccountAndChannel (options: GetVideoParamAll): GetVideoResult<MVideoAccountLightBlacklistAllFiles>
233function getOrCreateVideoAndAccountAndChannel (options: GetVideoParamImmutable): GetVideoResult<MVideoImmutable>
234function getOrCreateVideoAndAccountAndChannel (
235 options: GetVideoParamOther
236): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail>
237async function getOrCreateVideoAndAccountAndChannel (
238 options: GetVideoParamAll | GetVideoParamImmutable | GetVideoParamOther
239): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail | MVideoImmutable> {
240 // Default params
241 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
242 const fetchType = options.fetchType || 'all'
243 const allowRefresh = options.allowRefresh !== false
244
245 // Get video url
246 const videoUrl = getAPId(options.videoObject)
247 let videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType)
248
249 if (videoFromDatabase) {
250 // If allowRefresh is true, we could not call this function using 'only-immutable-attributes' fetch type
251 if (allowRefresh === true && (videoFromDatabase as MVideoThumbnail).isOutdated()) {
252 const refreshOptions = {
253 video: videoFromDatabase as MVideoThumbnail,
254 fetchedType: fetchType,
255 syncParam
256 }
257
258 if (syncParam.refreshVideo === true) {
259 videoFromDatabase = await refreshVideoIfNeeded(refreshOptions)
260 } else {
261 await JobQueue.Instance.createJobWithPromise({
262 type: 'activitypub-refresher',
263 payload: { type: 'video', url: videoFromDatabase.url }
264 })
265 }
266 }
267
268 return { video: videoFromDatabase, created: false }
269 }
270
271 const { videoObject: fetchedVideo } = await fetchRemoteVideo(videoUrl)
272 if (!fetchedVideo) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
273
274 const actor = await getOrCreateVideoChannelFromVideoObject(fetchedVideo)
275 const videoChannel = actor.VideoChannel
276
277 try {
278 const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(createVideo, fetchedVideo, videoChannel, syncParam.thumbnail)
279
280 await syncVideoExternalAttributes(videoCreated, fetchedVideo, syncParam)
281
282 return { video: videoCreated, created: true, autoBlacklisted }
283 } catch (err) {
284 // Maybe a concurrent getOrCreateVideoAndAccountAndChannel call created this video
285 if (err.name === 'SequelizeUniqueConstraintError') {
286 const fallbackVideo = await fetchVideoByUrl(videoUrl, fetchType)
287 if (fallbackVideo) return { video: fallbackVideo, created: false }
288 }
289
290 throw err
291 }
292}
293
294async function updateVideoFromAP (options: {
295 video: MVideoAccountLightBlacklistAllFiles
296 videoObject: VideoObject
297 account: MAccountIdActor
298 channel: MChannelDefault
299 overrideTo?: string[]
300}) {
301 const { video, videoObject, account, channel, overrideTo } = options
302
303 logger.debug('Updating remote video "%s".', options.videoObject.uuid, { videoObject: options.videoObject, account, channel })
304
305 let videoFieldsSave: any
306 const wasPrivateVideo = video.privacy === VideoPrivacy.PRIVATE
307 const wasUnlistedVideo = video.privacy === VideoPrivacy.UNLISTED
308
309 try {
310 let thumbnailModel: MThumbnail
311
312 try {
313 thumbnailModel = await createVideoMiniatureFromUrl({
314 downloadUrl: getThumbnailFromIcons(videoObject).url,
315 video,
316 type: ThumbnailType.MINIATURE
317 })
318 } catch (err) {
319 logger.warn('Cannot generate thumbnail of %s.', videoObject.id, { err })
320 }
321
322 const videoUpdated = await sequelizeTypescript.transaction(async t => {
323 const sequelizeOptions = { transaction: t }
324
325 videoFieldsSave = video.toJSON()
326
327 // Check we can update the channel: we trust the remote server
328 const oldVideoChannel = video.VideoChannel
329
330 if (!oldVideoChannel.Actor.serverId || !channel.Actor.serverId) {
331 throw new Error('Cannot check old channel/new channel validity because `serverId` is null')
332 }
333
334 if (oldVideoChannel.Actor.serverId !== channel.Actor.serverId) {
335 throw new Error('New channel ' + channel.Actor.url + ' is not on the same server than new channel ' + oldVideoChannel.Actor.url)
336 }
337
338 const to = overrideTo || videoObject.to
339 const videoData = videoActivityObjectToDBAttributes(channel, videoObject, to)
340 video.name = videoData.name
341 video.uuid = videoData.uuid
342 video.url = videoData.url
343 video.category = videoData.category
344 video.licence = videoData.licence
345 video.language = videoData.language
346 video.description = videoData.description
347 video.support = videoData.support
348 video.nsfw = videoData.nsfw
349 video.commentsEnabled = videoData.commentsEnabled
350 video.downloadEnabled = videoData.downloadEnabled
351 video.waitTranscoding = videoData.waitTranscoding
352 video.state = videoData.state
353 video.duration = videoData.duration
354 video.createdAt = videoData.createdAt
355 video.publishedAt = videoData.publishedAt
356 video.originallyPublishedAt = videoData.originallyPublishedAt
357 video.privacy = videoData.privacy
358 video.channelId = videoData.channelId
359 video.views = videoData.views
360 video.isLive = videoData.isLive
361
362 // Ensures we update the updated video attribute
363 video.changed('updatedAt', true)
364
365 const videoUpdated = await video.save(sequelizeOptions) as MVideoFullLight
366
367 if (thumbnailModel) await videoUpdated.addAndSaveThumbnail(thumbnailModel, t)
368
369 const previewIcon = getPreviewFromIcons(videoObject)
370 if (videoUpdated.getPreview() && previewIcon) {
371 const previewModel = createPlaceholderThumbnail({
372 fileUrl: previewIcon.url,
373 video,
374 type: ThumbnailType.PREVIEW,
375 size: previewIcon
376 })
377 await videoUpdated.addAndSaveThumbnail(previewModel, t)
378 }
379
380 {
381 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoUpdated, videoObject.url)
382 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
383
384 // Remove video files that do not exist anymore
385 const destroyTasks = deleteNonExistingModels(videoUpdated.VideoFiles, newVideoFiles, t)
386 await Promise.all(destroyTasks)
387
388 // Update or add other one
389 const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'video', t))
390 videoUpdated.VideoFiles = await Promise.all(upsertTasks)
391 }
392
393 {
394 const streamingPlaylistAttributes = streamingPlaylistActivityUrlToDBAttributes(videoUpdated, videoObject, videoUpdated.VideoFiles)
395 const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
396
397 // Remove video playlists that do not exist anymore
398 const destroyTasks = deleteNonExistingModels(videoUpdated.VideoStreamingPlaylists, newStreamingPlaylists, t)
399 await Promise.all(destroyTasks)
400
401 let oldStreamingPlaylistFiles: MVideoFile[] = []
402 for (const videoStreamingPlaylist of videoUpdated.VideoStreamingPlaylists) {
403 oldStreamingPlaylistFiles = oldStreamingPlaylistFiles.concat(videoStreamingPlaylist.VideoFiles)
404 }
405
406 videoUpdated.VideoStreamingPlaylists = []
407
408 for (const playlistAttributes of streamingPlaylistAttributes) {
409 const streamingPlaylistModel = await VideoStreamingPlaylistModel.upsert(playlistAttributes, { returning: true, transaction: t })
410 .then(([ streamingPlaylist ]) => streamingPlaylist as MStreamingPlaylistFilesVideo)
411 streamingPlaylistModel.Video = videoUpdated
412
413 const newVideoFiles: MVideoFile[] = videoFileActivityUrlToDBAttributes(streamingPlaylistModel, playlistAttributes.tagAPObject)
414 .map(a => new VideoFileModel(a))
415 const destroyTasks = deleteNonExistingModels(oldStreamingPlaylistFiles, newVideoFiles, t)
416 await Promise.all(destroyTasks)
417
418 // Update or add other one
419 const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'streaming-playlist', t))
420 streamingPlaylistModel.VideoFiles = await Promise.all(upsertTasks)
421
422 videoUpdated.VideoStreamingPlaylists.push(streamingPlaylistModel)
423 }
424 }
425
426 {
427 // Update Tags
428 const tags = videoObject.tag
429 .filter(isAPHashTagObject)
430 .map(tag => tag.name)
431 await setVideoTags({ video: videoUpdated, tags, transaction: t })
432 }
433
434 // Update trackers
435 {
436 const trackers = getTrackerUrls(videoObject, videoUpdated)
437 await setVideoTrackers({ video: videoUpdated, trackers, transaction: t })
438 }
439
440 {
441 // Update captions
442 await VideoCaptionModel.deleteAllCaptionsOfRemoteVideo(videoUpdated.id, t)
443
444 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
445 const caption = new VideoCaptionModel({
446 videoId: videoUpdated.id,
447 filename: VideoCaptionModel.generateCaptionName(c.identifier),
448 language: c.identifier,
449 fileUrl: c.url
450 }) as MVideoCaption
451
452 return VideoCaptionModel.insertOrReplaceLanguage(caption, t)
453 })
454 await Promise.all(videoCaptionsPromises)
455 }
456
457 {
458 // Create or update existing live
459 if (video.isLive) {
460 const [ videoLive ] = await VideoLiveModel.upsert({
461 saveReplay: videoObject.liveSaveReplay,
462 permanentLive: videoObject.permanentLive,
463 videoId: video.id
464 }, { transaction: t, returning: true })
465
466 videoUpdated.VideoLive = videoLive
467 } else { // Delete existing live if it exists
468 await VideoLiveModel.destroy({
469 where: {
470 videoId: video.id
471 },
472 transaction: t
473 })
474
475 videoUpdated.VideoLive = null
476 }
477 }
478
479 return videoUpdated
480 })
481
482 await autoBlacklistVideoIfNeeded({
483 video: videoUpdated,
484 user: undefined,
485 isRemote: true,
486 isNew: false,
487 transaction: undefined
488 })
489
490 // Notify our users?
491 if (wasPrivateVideo || wasUnlistedVideo) Notifier.Instance.notifyOnNewVideoIfNeeded(videoUpdated)
492
493 if (videoUpdated.isLive) {
494 PeerTubeSocket.Instance.sendVideoLiveNewState(videoUpdated)
495 PeerTubeSocket.Instance.sendVideoViewsUpdate(videoUpdated)
496 }
497
498 logger.info('Remote video with uuid %s updated', videoObject.uuid)
499
500 return videoUpdated
501 } catch (err) {
502 if (video !== undefined && videoFieldsSave !== undefined) {
503 resetSequelizeInstance(video, videoFieldsSave)
504 }
505
506 // This is just a debug because we will retry the insert
507 logger.debug('Cannot update the remote video.', { err })
508 throw err
509 }
510}
511
512async function refreshVideoIfNeeded (options: {
513 video: MVideoThumbnail
514 fetchedType: VideoFetchByUrlType
515 syncParam: SyncParam
516}): Promise<MVideoThumbnail> {
517 if (!options.video.isOutdated()) return options.video
518
519 // We need more attributes if the argument video was fetched with not enough joints
520 const video = options.fetchedType === 'all'
521 ? options.video as MVideoAccountLightBlacklistAllFiles
522 : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
523
524 try {
525 const { videoObject } = await fetchRemoteVideo(video.url)
526
527 if (videoObject === undefined) {
528 logger.warn('Cannot refresh remote video %s: invalid body.', video.url)
529
530 await video.setAsRefreshed()
531 return video
532 }
533
534 const channelActor = await getOrCreateVideoChannelFromVideoObject(videoObject)
535
536 const updateOptions = {
537 video,
538 videoObject,
539 account: channelActor.VideoChannel.Account,
540 channel: channelActor.VideoChannel
541 }
542 await updateVideoFromAP(updateOptions)
543 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
544
545 ActorFollowScoreCache.Instance.addGoodServerId(video.VideoChannel.Actor.serverId)
546
547 return video
548 } catch (err) {
549 if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) {
550 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url)
551
552 // Video does not exist anymore
553 await video.destroy()
554 return undefined
555 }
556
557 logger.warn('Cannot refresh video %s.', options.video.url, { err })
558
559 ActorFollowScoreCache.Instance.addBadServerId(video.VideoChannel.Actor.serverId)
560
561 // Don't refresh in loop
562 await video.setAsRefreshed()
563 return video
564 }
565}
566
567export {
568 updateVideoFromAP,
569 refreshVideoIfNeeded,
570 federateVideoIfNeeded,
571 fetchRemoteVideo,
572 getOrCreateVideoAndAccountAndChannel,
573 fetchRemoteVideoDescription,
574 getOrCreateVideoChannelFromVideoObject
575}
576
577// ---------------------------------------------------------------------------
578
579function isAPVideoUrlObject (url: any): url is ActivityVideoUrlObject {
580 const urlMediaType = url.mediaType
581
582 return MIMETYPES.VIDEO.MIMETYPE_EXT[urlMediaType] && urlMediaType.startsWith('video/')
583}
584
585function isAPStreamingPlaylistUrlObject (url: any): url is ActivityPlaylistUrlObject {
586 return url && url.mediaType === 'application/x-mpegURL'
587}
588
589function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
590 return tag && tag.name === 'sha256' && tag.type === 'Link' && tag.mediaType === 'application/json'
591}
592
593function isAPMagnetUrlObject (url: any): url is ActivityMagnetUrlObject {
594 return url && url.mediaType === 'application/x-bittorrent;x-scheme-handler/magnet'
595}
596
597function isAPHashTagObject (url: any): url is ActivityHashTagObject {
598 return url && url.type === 'Hashtag'
599}
600
601async function createVideo (videoObject: VideoObject, channel: MChannelAccountLight, waitThumbnail = false) {
602 logger.debug('Adding remote video %s.', videoObject.id)
603
604 const videoData = await videoActivityObjectToDBAttributes(channel, videoObject, videoObject.to)
605 const video = VideoModel.build(videoData) as MVideoThumbnail
606
607 const promiseThumbnail = createVideoMiniatureFromUrl({
608 downloadUrl: getThumbnailFromIcons(videoObject).url,
609 video,
610 type: ThumbnailType.MINIATURE
611 }).catch(err => {
612 logger.error('Cannot create miniature from url.', { err })
613 return undefined
614 })
615
616 let thumbnailModel: MThumbnail
617 if (waitThumbnail === true) {
618 thumbnailModel = await promiseThumbnail
619 }
620
621 const { autoBlacklisted, videoCreated } = await sequelizeTypescript.transaction(async t => {
622 try {
623 const sequelizeOptions = { transaction: t }
624
625 const videoCreated = await video.save(sequelizeOptions) as MVideoFullLight
626 videoCreated.VideoChannel = channel
627
628 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
629
630 const previewIcon = getPreviewFromIcons(videoObject)
631 if (previewIcon) {
632 const previewModel = createPlaceholderThumbnail({
633 fileUrl: previewIcon.url,
634 video: videoCreated,
635 type: ThumbnailType.PREVIEW,
636 size: previewIcon
637 })
638
639 await videoCreated.addAndSaveThumbnail(previewModel, t)
640 }
641
642 // Process files
643 const videoFileAttributes = videoFileActivityUrlToDBAttributes(videoCreated, videoObject.url)
644
645 const videoFilePromises = videoFileAttributes.map(f => VideoFileModel.create(f, { transaction: t }))
646 const videoFiles = await Promise.all(videoFilePromises)
647
648 const streamingPlaylistsAttributes = streamingPlaylistActivityUrlToDBAttributes(videoCreated, videoObject, videoFiles)
649 videoCreated.VideoStreamingPlaylists = []
650
651 for (const playlistAttributes of streamingPlaylistsAttributes) {
652 const playlist = await VideoStreamingPlaylistModel.create(playlistAttributes, { transaction: t }) as MStreamingPlaylistFilesVideo
653 playlist.Video = videoCreated
654
655 const playlistFiles = videoFileActivityUrlToDBAttributes(playlist, playlistAttributes.tagAPObject)
656 const videoFilePromises = playlistFiles.map(f => VideoFileModel.create(f, { transaction: t }))
657 playlist.VideoFiles = await Promise.all(videoFilePromises)
658
659 videoCreated.VideoStreamingPlaylists.push(playlist)
660 }
661
662 // Process tags
663 const tags = videoObject.tag
664 .filter(isAPHashTagObject)
665 .map(t => t.name)
666 await setVideoTags({ video: videoCreated, tags, transaction: t })
667
668 // Process captions
669 const videoCaptionsPromises = videoObject.subtitleLanguage.map(c => {
670 const caption = new VideoCaptionModel({
671 videoId: videoCreated.id,
672 filename: VideoCaptionModel.generateCaptionName(c.identifier),
673 language: c.identifier,
674 fileUrl: c.url
675 }) as MVideoCaption
676
677 return VideoCaptionModel.insertOrReplaceLanguage(caption, t)
678 })
679 await Promise.all(videoCaptionsPromises)
680
681 // Process trackers
682 {
683 const trackers = getTrackerUrls(videoObject, videoCreated)
684 await setVideoTrackers({ video: videoCreated, trackers, transaction: t })
685 }
686
687 videoCreated.VideoFiles = videoFiles
688
689 if (videoCreated.isLive) {
690 const videoLive = new VideoLiveModel({
691 streamKey: null,
692 saveReplay: videoObject.liveSaveReplay,
693 permanentLive: videoObject.permanentLive,
694 videoId: videoCreated.id
695 })
696
697 videoCreated.VideoLive = await videoLive.save({ transaction: t })
698 }
699
700 // We added a video in this channel, set it as updated
701 await channel.setAsUpdated(t)
702
703 const autoBlacklisted = await autoBlacklistVideoIfNeeded({
704 video: videoCreated,
705 user: undefined,
706 isRemote: true,
707 isNew: true,
708 transaction: t
709 })
710
711 logger.info('Remote video with uuid %s inserted.', videoObject.uuid)
712
713 return { autoBlacklisted, videoCreated }
714 } catch (err) {
715 // FIXME: Use rollback hook when https://github.com/sequelize/sequelize/pull/13038 is released
716 // Remove thumbnail
717 if (thumbnailModel) await thumbnailModel.removeThumbnail()
718
719 throw err
720 }
721 })
722
723 if (waitThumbnail === false) {
724 // Error is already caught above
725 // eslint-disable-next-line @typescript-eslint/no-floating-promises
726 promiseThumbnail.then(thumbnailModel => {
727 if (!thumbnailModel) return
728
729 thumbnailModel = videoCreated.id
730
731 return thumbnailModel.save()
732 })
733 }
734
735 return { autoBlacklisted, videoCreated }
736}
737
738function videoActivityObjectToDBAttributes (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
739 const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
740 ? VideoPrivacy.PUBLIC
741 : VideoPrivacy.UNLISTED
742
743 const duration = videoObject.duration.replace(/[^\d]+/, '')
744 const language = videoObject.language?.identifier
745
746 const category = videoObject.category
747 ? parseInt(videoObject.category.identifier, 10)
748 : undefined
749
750 const licence = videoObject.licence
751 ? parseInt(videoObject.licence.identifier, 10)
752 : undefined
753
754 const description = videoObject.content || null
755 const support = videoObject.support || null
756
757 return {
758 name: videoObject.name,
759 uuid: videoObject.uuid,
760 url: videoObject.id,
761 category,
762 licence,
763 language,
764 description,
765 support,
766 nsfw: videoObject.sensitive,
767 commentsEnabled: videoObject.commentsEnabled,
768 downloadEnabled: videoObject.downloadEnabled,
769 waitTranscoding: videoObject.waitTranscoding,
770 isLive: videoObject.isLiveBroadcast,
771 state: videoObject.state,
772 channelId: videoChannel.id,
773 duration: parseInt(duration, 10),
774 createdAt: new Date(videoObject.published),
775 publishedAt: new Date(videoObject.published),
776
777 originallyPublishedAt: videoObject.originallyPublishedAt
778 ? new Date(videoObject.originallyPublishedAt)
779 : null,
780
781 updatedAt: new Date(videoObject.updated),
782 views: videoObject.views,
783 likes: 0,
784 dislikes: 0,
785 remote: true,
786 privacy
787 }
788}
789
790function videoFileActivityUrlToDBAttributes (
791 videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
792 urls: (ActivityTagObject | ActivityUrlObject)[]
793) {
794 const fileUrls = urls.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
795
796 if (fileUrls.length === 0) return []
797
798 const attributes: FilteredModelAttributes<VideoFileModel>[] = []
799 for (const fileUrl of fileUrls) {
800 // Fetch associated magnet uri
801 const magnet = urls.filter(isAPMagnetUrlObject)
802 .find(u => u.height === fileUrl.height)
803
804 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
805
806 const parsed = magnetUtil.decode(magnet.href)
807 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
808 throw new Error('Cannot parse magnet URI ' + magnet.href)
809 }
810
811 const torrentUrl = Array.isArray(parsed.xs)
812 ? parsed.xs[0]
813 : parsed.xs
814
815 // Fetch associated metadata url, if any
816 const metadata = urls.filter(isAPVideoFileUrlMetadataObject)
817 .find(u => {
818 return u.height === fileUrl.height &&
819 u.fps === fileUrl.fps &&
820 u.rel.includes(fileUrl.mediaType)
821 })
822
823 const extname = getExtFromMimetype(MIMETYPES.VIDEO.MIMETYPE_EXT, fileUrl.mediaType)
824 const resolution = fileUrl.height
825 const videoId = (videoOrPlaylist as MStreamingPlaylist).playlistUrl ? null : videoOrPlaylist.id
826 const videoStreamingPlaylistId = (videoOrPlaylist as MStreamingPlaylist).playlistUrl ? videoOrPlaylist.id : null
827
828 const attribute = {
829 extname,
830 infoHash: parsed.infoHash,
831 resolution,
832 size: fileUrl.size,
833 fps: fileUrl.fps || -1,
834 metadataUrl: metadata?.href,
835
836 // Use the name of the remote file because we don't proxify video file requests
837 filename: basename(fileUrl.href),
838 fileUrl: fileUrl.href,
839
840 torrentUrl,
841 // Use our own torrent name since we proxify torrent requests
842 torrentFilename: generateTorrentFileName(videoOrPlaylist, resolution),
843
844 // This is a video file owned by a video or by a streaming playlist
845 videoId,
846 videoStreamingPlaylistId
847 }
848
849 attributes.push(attribute)
850 }
851
852 return attributes
853}
854
855function streamingPlaylistActivityUrlToDBAttributes (video: MVideoId, videoObject: VideoObject, videoFiles: MVideoFile[]) {
856 const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
857 if (playlistUrls.length === 0) return []
858
859 const attributes: (FilteredModelAttributes<VideoStreamingPlaylistModel> & { tagAPObject?: ActivityTagObject[] })[] = []
860 for (const playlistUrlObject of playlistUrls) {
861 const segmentsSha256UrlObject = playlistUrlObject.tag.find(isAPPlaylistSegmentHashesUrlObject)
862
863 let files: unknown[] = playlistUrlObject.tag.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
864
865 // FIXME: backward compatibility introduced in v2.1.0
866 if (files.length === 0) files = videoFiles
867
868 if (!segmentsSha256UrlObject) {
869 logger.warn('No segment sha256 URL found in AP playlist object.', { playlistUrl: playlistUrlObject })
870 continue
871 }
872
873 const attribute = {
874 type: VideoStreamingPlaylistType.HLS,
875 playlistUrl: playlistUrlObject.href,
876 segmentsSha256Url: segmentsSha256UrlObject.href,
877 p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, files),
878 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
879 videoId: video.id,
880 tagAPObject: playlistUrlObject.tag
881 }
882
883 attributes.push(attribute)
884 }
885
886 return attributes
887}
888
889function getThumbnailFromIcons (videoObject: VideoObject) {
890 let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth)
891 // Fallback if there are not valid icons
892 if (validIcons.length === 0) validIcons = videoObject.icon
893
894 return minBy(validIcons, 'width')
895}
896
897function getPreviewFromIcons (videoObject: VideoObject) {
898 const validIcons = videoObject.icon.filter(i => i.width > PREVIEWS_SIZE.minWidth)
899
900 return maxBy(validIcons, 'width')
901}
902
903function getTrackerUrls (object: VideoObject, video: MVideoWithHost) {
904 let wsFound = false
905
906 const trackers = object.url.filter(u => isAPVideoTrackerUrlObject(u))
907 .map((u: ActivityTrackerUrlObject) => {
908 if (isArray(u.rel) && u.rel.includes('websocket')) wsFound = true
909
910 return u.href
911 })
912
913 if (wsFound) return trackers
914
915 return [
916 buildRemoteVideoBaseUrl(video, '/tracker/socket', REMOTE_SCHEME.WS),
917 buildRemoteVideoBaseUrl(video, '/tracker/announce')
918 ]
919}
920
921async function setVideoTrackers (options: {
922 video: MVideo
923 trackers: string[]
924 transaction?: Transaction
925}) {
926 const { video, trackers, transaction } = options
927
928 const trackerInstances = await TrackerModel.findOrCreateTrackers(trackers, transaction)
929
930 await video.$set('Trackers', trackerInstances, { transaction })
931}
diff --git a/server/lib/activitypub/videos/federate.ts b/server/lib/activitypub/videos/federate.ts
new file mode 100644
index 000000000..bd0c54b0c
--- /dev/null
+++ b/server/lib/activitypub/videos/federate.ts
@@ -0,0 +1,36 @@
1import { Transaction } from 'sequelize/types'
2import { isArray } from '@server/helpers/custom-validators/misc'
3import { MVideoAP, MVideoAPWithoutCaption } from '@server/types/models'
4import { sendCreateVideo, sendUpdateVideo } from '../send'
5import { shareVideoByServerAndChannel } from '../share'
6
7async function federateVideoIfNeeded (videoArg: MVideoAPWithoutCaption, isNewVideo: boolean, transaction?: Transaction) {
8 const video = videoArg as MVideoAP
9
10 if (
11 // Check this is not a blacklisted video, or unfederated blacklisted video
12 (video.isBlacklisted() === false || (isNewVideo === false && video.VideoBlacklist.unfederated === false)) &&
13 // Check the video is public/unlisted and published
14 video.hasPrivacyForFederation() && video.hasStateForFederation()
15 ) {
16 // Fetch more attributes that we will need to serialize in AP object
17 if (isArray(video.VideoCaptions) === false) {
18 video.VideoCaptions = await video.$get('VideoCaptions', {
19 attributes: [ 'filename', 'language' ],
20 transaction
21 })
22 }
23
24 if (isNewVideo) {
25 // Now we'll add the video's meta data to our followers
26 await sendCreateVideo(video, transaction)
27 await shareVideoByServerAndChannel(video, transaction)
28 } else {
29 await sendUpdateVideo(video, transaction)
30 }
31 }
32}
33
34export {
35 federateVideoIfNeeded
36}
diff --git a/server/lib/activitypub/videos/get.ts b/server/lib/activitypub/videos/get.ts
new file mode 100644
index 000000000..f3e2f0625
--- /dev/null
+++ b/server/lib/activitypub/videos/get.ts
@@ -0,0 +1,113 @@
1import { getAPId } from '@server/helpers/activitypub'
2import { retryTransactionWrapper } from '@server/helpers/database-utils'
3import { JobQueue } from '@server/lib/job-queue'
4import { loadVideoByUrl, VideoLoadByUrlType } from '@server/lib/model-loaders'
5import { MVideoAccountLightBlacklistAllFiles, MVideoImmutable, MVideoThumbnail } from '@server/types/models'
6import { APObject } from '@shared/models'
7import { refreshVideoIfNeeded } from './refresh'
8import { APVideoCreator, fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared'
9
10type GetVideoResult <T> = Promise<{
11 video: T
12 created: boolean
13 autoBlacklisted?: boolean
14}>
15
16type GetVideoParamAll = {
17 videoObject: APObject
18 syncParam?: SyncParam
19 fetchType?: 'all'
20 allowRefresh?: boolean
21}
22
23type GetVideoParamImmutable = {
24 videoObject: APObject
25 syncParam?: SyncParam
26 fetchType: 'only-immutable-attributes'
27 allowRefresh: false
28}
29
30type GetVideoParamOther = {
31 videoObject: APObject
32 syncParam?: SyncParam
33 fetchType?: 'all' | 'only-video'
34 allowRefresh?: boolean
35}
36
37function getOrCreateAPVideo (options: GetVideoParamAll): GetVideoResult<MVideoAccountLightBlacklistAllFiles>
38function getOrCreateAPVideo (options: GetVideoParamImmutable): GetVideoResult<MVideoImmutable>
39function getOrCreateAPVideo (options: GetVideoParamOther): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail>
40
41async function getOrCreateAPVideo (
42 options: GetVideoParamAll | GetVideoParamImmutable | GetVideoParamOther
43): GetVideoResult<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail | MVideoImmutable> {
44 // Default params
45 const syncParam = options.syncParam || { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true, refreshVideo: false }
46 const fetchType = options.fetchType || 'all'
47 const allowRefresh = options.allowRefresh !== false
48
49 // Get video url
50 const videoUrl = getAPId(options.videoObject)
51 let videoFromDatabase = await loadVideoByUrl(videoUrl, fetchType)
52
53 if (videoFromDatabase) {
54 if (allowRefresh === true) {
55 // Typings ensure allowRefresh === false in only-immutable-attributes fetch type
56 videoFromDatabase = await scheduleRefresh(videoFromDatabase as MVideoThumbnail, fetchType, syncParam)
57 }
58
59 return { video: videoFromDatabase, created: false }
60 }
61
62 const { videoObject } = await fetchRemoteVideo(videoUrl)
63 if (!videoObject) throw new Error('Cannot fetch remote video with url: ' + videoUrl)
64
65 // videoUrl is just an alias/rediraction, so process object id instead
66 if (videoObject.id !== videoUrl) return getOrCreateAPVideo({ ...options, fetchType: 'all', videoObject })
67
68 try {
69 const creator = new APVideoCreator(videoObject)
70 const { autoBlacklisted, videoCreated } = await retryTransactionWrapper(creator.create.bind(creator), syncParam.thumbnail)
71
72 await syncVideoExternalAttributes(videoCreated, videoObject, syncParam)
73
74 return { video: videoCreated, created: true, autoBlacklisted }
75 } catch (err) {
76 // Maybe a concurrent getOrCreateAPVideo call created this video
77 if (err.name === 'SequelizeUniqueConstraintError') {
78 const alreadyCreatedVideo = await loadVideoByUrl(videoUrl, fetchType)
79 if (alreadyCreatedVideo) return { video: alreadyCreatedVideo, created: false }
80 }
81
82 throw err
83 }
84}
85
86// ---------------------------------------------------------------------------
87
88export {
89 getOrCreateAPVideo
90}
91
92// ---------------------------------------------------------------------------
93
94async function scheduleRefresh (video: MVideoThumbnail, fetchType: VideoLoadByUrlType, syncParam: SyncParam) {
95 if (!video.isOutdated()) return video
96
97 const refreshOptions = {
98 video,
99 fetchedType: fetchType,
100 syncParam
101 }
102
103 if (syncParam.refreshVideo === true) {
104 return refreshVideoIfNeeded(refreshOptions)
105 }
106
107 await JobQueue.Instance.createJobWithPromise({
108 type: 'activitypub-refresher',
109 payload: { type: 'video', url: video.url }
110 })
111
112 return video
113}
diff --git a/server/lib/activitypub/videos/index.ts b/server/lib/activitypub/videos/index.ts
new file mode 100644
index 000000000..b22062598
--- /dev/null
+++ b/server/lib/activitypub/videos/index.ts
@@ -0,0 +1,4 @@
1export * from './federate'
2export * from './get'
3export * from './refresh'
4export * from './updater'
diff --git a/server/lib/activitypub/videos/refresh.ts b/server/lib/activitypub/videos/refresh.ts
new file mode 100644
index 000000000..a7b82f286
--- /dev/null
+++ b/server/lib/activitypub/videos/refresh.ts
@@ -0,0 +1,68 @@
1import { logger, loggerTagsFactory } from '@server/helpers/logger'
2import { PeerTubeRequestError } from '@server/helpers/requests'
3import { ActorFollowScoreCache } from '@server/lib/files-cache'
4import { VideoLoadByUrlType } from '@server/lib/model-loaders'
5import { VideoModel } from '@server/models/video/video'
6import { MVideoAccountLightBlacklistAllFiles, MVideoThumbnail } from '@server/types/models'
7import { HttpStatusCode } from '@shared/core-utils'
8import { fetchRemoteVideo, SyncParam, syncVideoExternalAttributes } from './shared'
9import { APVideoUpdater } from './updater'
10
11async function refreshVideoIfNeeded (options: {
12 video: MVideoThumbnail
13 fetchedType: VideoLoadByUrlType
14 syncParam: SyncParam
15}): Promise<MVideoThumbnail> {
16 if (!options.video.isOutdated()) return options.video
17
18 // We need more attributes if the argument video was fetched with not enough joints
19 const video = options.fetchedType === 'all'
20 ? options.video as MVideoAccountLightBlacklistAllFiles
21 : await VideoModel.loadByUrlAndPopulateAccount(options.video.url)
22
23 const lTags = loggerTagsFactory('ap', 'video', 'refresh', video.uuid, video.url)
24
25 logger.info('Refreshing video %s.', video.url, lTags())
26
27 try {
28 const { videoObject } = await fetchRemoteVideo(video.url)
29
30 if (videoObject === undefined) {
31 logger.warn('Cannot refresh remote video %s: invalid body.', video.url, lTags())
32
33 await video.setAsRefreshed()
34 return video
35 }
36
37 const videoUpdater = new APVideoUpdater(videoObject, video)
38 await videoUpdater.update()
39
40 await syncVideoExternalAttributes(video, videoObject, options.syncParam)
41
42 ActorFollowScoreCache.Instance.addGoodServerId(video.VideoChannel.Actor.serverId)
43
44 return video
45 } catch (err) {
46 if ((err as PeerTubeRequestError).statusCode === HttpStatusCode.NOT_FOUND_404) {
47 logger.info('Cannot refresh remote video %s: video does not exist anymore. Deleting it.', video.url, lTags())
48
49 // Video does not exist anymore
50 await video.destroy()
51 return undefined
52 }
53
54 logger.warn('Cannot refresh video %s.', options.video.url, { err, ...lTags() })
55
56 ActorFollowScoreCache.Instance.addBadServerId(video.VideoChannel.Actor.serverId)
57
58 // Don't refresh in loop
59 await video.setAsRefreshed()
60 return video
61 }
62}
63
64// ---------------------------------------------------------------------------
65
66export {
67 refreshVideoIfNeeded
68}
diff --git a/server/lib/activitypub/videos/shared/abstract-builder.ts b/server/lib/activitypub/videos/shared/abstract-builder.ts
new file mode 100644
index 000000000..e89c94bcd
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/abstract-builder.ts
@@ -0,0 +1,173 @@
1import { Transaction } from 'sequelize/types'
2import { checkUrlsSameHost } from '@server/helpers/activitypub'
3import { deleteNonExistingModels } from '@server/helpers/database-utils'
4import { logger, LoggerTagsFn } from '@server/helpers/logger'
5import { updatePlaceholderThumbnail, updateVideoMiniatureFromUrl } from '@server/lib/thumbnail'
6import { setVideoTags } from '@server/lib/video'
7import { VideoCaptionModel } from '@server/models/video/video-caption'
8import { VideoFileModel } from '@server/models/video/video-file'
9import { VideoLiveModel } from '@server/models/video/video-live'
10import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
11import { MStreamingPlaylistFilesVideo, MThumbnail, MVideoCaption, MVideoFile, MVideoFullLight, MVideoThumbnail } from '@server/types/models'
12import { ActivityTagObject, ThumbnailType, VideoObject, VideoStreamingPlaylistType } from '@shared/models'
13import { getOrCreateAPActor } from '../../actors'
14import {
15 getCaptionAttributesFromObject,
16 getFileAttributesFromUrl,
17 getLiveAttributesFromObject,
18 getPreviewFromIcons,
19 getStreamingPlaylistAttributesFromObject,
20 getTagsFromObject,
21 getThumbnailFromIcons
22} from './object-to-model-attributes'
23import { getTrackerUrls, setVideoTrackers } from './trackers'
24
25export abstract class APVideoAbstractBuilder {
26 protected abstract videoObject: VideoObject
27 protected abstract lTags: LoggerTagsFn
28
29 protected async getOrCreateVideoChannelFromVideoObject () {
30 const channel = this.videoObject.attributedTo.find(a => a.type === 'Group')
31 if (!channel) throw new Error('Cannot find associated video channel to video ' + this.videoObject.url)
32
33 if (checkUrlsSameHost(channel.id, this.videoObject.id) !== true) {
34 throw new Error(`Video channel url ${channel.id} does not have the same host than video object id ${this.videoObject.id}`)
35 }
36
37 return getOrCreateAPActor(channel.id, 'all')
38 }
39
40 protected tryToGenerateThumbnail (video: MVideoThumbnail): Promise<MThumbnail> {
41 return updateVideoMiniatureFromUrl({
42 downloadUrl: getThumbnailFromIcons(this.videoObject).url,
43 video,
44 type: ThumbnailType.MINIATURE
45 }).catch(err => {
46 logger.warn('Cannot generate thumbnail of %s.', this.videoObject.id, { err, ...this.lTags() })
47
48 return undefined
49 })
50 }
51
52 protected async setPreview (video: MVideoFullLight, t?: Transaction) {
53 // Don't fetch the preview that could be big, create a placeholder instead
54 const previewIcon = getPreviewFromIcons(this.videoObject)
55 if (!previewIcon) return
56
57 const previewModel = updatePlaceholderThumbnail({
58 fileUrl: previewIcon.url,
59 video,
60 type: ThumbnailType.PREVIEW,
61 size: previewIcon
62 })
63
64 await video.addAndSaveThumbnail(previewModel, t)
65 }
66
67 protected async setTags (video: MVideoFullLight, t: Transaction) {
68 const tags = getTagsFromObject(this.videoObject)
69 await setVideoTags({ video, tags, transaction: t })
70 }
71
72 protected async setTrackers (video: MVideoFullLight, t: Transaction) {
73 const trackers = getTrackerUrls(this.videoObject, video)
74 await setVideoTrackers({ video, trackers, transaction: t })
75 }
76
77 protected async insertOrReplaceCaptions (video: MVideoFullLight, t: Transaction) {
78 const existingCaptions = await VideoCaptionModel.listVideoCaptions(video.id, t)
79
80 let captionsToCreate = getCaptionAttributesFromObject(video, this.videoObject)
81 .map(a => new VideoCaptionModel(a) as MVideoCaption)
82
83 for (const existingCaption of existingCaptions) {
84 // Only keep captions that do not already exist
85 const filtered = captionsToCreate.filter(c => !c.isEqual(existingCaption))
86
87 // This caption already exists, we don't need to destroy and create it
88 if (filtered.length !== captionsToCreate.length) {
89 captionsToCreate = filtered
90 continue
91 }
92
93 // Destroy this caption that does not exist anymore
94 await existingCaption.destroy({ transaction: t })
95 }
96
97 for (const captionToCreate of captionsToCreate) {
98 await captionToCreate.save({ transaction: t })
99 }
100 }
101
102 protected async insertOrReplaceLive (video: MVideoFullLight, transaction: Transaction) {
103 const attributes = getLiveAttributesFromObject(video, this.videoObject)
104 const [ videoLive ] = await VideoLiveModel.upsert(attributes, { transaction, returning: true })
105
106 video.VideoLive = videoLive
107 }
108
109 protected async setWebTorrentFiles (video: MVideoFullLight, t: Transaction) {
110 const videoFileAttributes = getFileAttributesFromUrl(video, this.videoObject.url)
111 const newVideoFiles = videoFileAttributes.map(a => new VideoFileModel(a))
112
113 // Remove video files that do not exist anymore
114 const destroyTasks = deleteNonExistingModels(video.VideoFiles || [], newVideoFiles, t)
115 await Promise.all(destroyTasks)
116
117 // Update or add other one
118 const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'video', t))
119 video.VideoFiles = await Promise.all(upsertTasks)
120 }
121
122 protected async setStreamingPlaylists (video: MVideoFullLight, t: Transaction) {
123 const streamingPlaylistAttributes = getStreamingPlaylistAttributesFromObject(video, this.videoObject, video.VideoFiles || [])
124 const newStreamingPlaylists = streamingPlaylistAttributes.map(a => new VideoStreamingPlaylistModel(a))
125
126 // Remove video playlists that do not exist anymore
127 const destroyTasks = deleteNonExistingModels(video.VideoStreamingPlaylists || [], newStreamingPlaylists, t)
128 await Promise.all(destroyTasks)
129
130 video.VideoStreamingPlaylists = []
131
132 for (const playlistAttributes of streamingPlaylistAttributes) {
133
134 const streamingPlaylistModel = await this.insertOrReplaceStreamingPlaylist(playlistAttributes, t)
135 streamingPlaylistModel.Video = video
136
137 await this.setStreamingPlaylistFiles(video, streamingPlaylistModel, playlistAttributes.tagAPObject, t)
138
139 video.VideoStreamingPlaylists.push(streamingPlaylistModel)
140 }
141 }
142
143 private async insertOrReplaceStreamingPlaylist (attributes: VideoStreamingPlaylistModel['_creationAttributes'], t: Transaction) {
144 const [ streamingPlaylist ] = await VideoStreamingPlaylistModel.upsert(attributes, { returning: true, transaction: t })
145
146 return streamingPlaylist as MStreamingPlaylistFilesVideo
147 }
148
149 private getStreamingPlaylistFiles (video: MVideoFullLight, type: VideoStreamingPlaylistType) {
150 const playlist = video.VideoStreamingPlaylists.find(s => s.type === type)
151 if (!playlist) return []
152
153 return playlist.VideoFiles
154 }
155
156 private async setStreamingPlaylistFiles (
157 video: MVideoFullLight,
158 playlistModel: MStreamingPlaylistFilesVideo,
159 tagObjects: ActivityTagObject[],
160 t: Transaction
161 ) {
162 const oldStreamingPlaylistFiles = this.getStreamingPlaylistFiles(video, playlistModel.type)
163
164 const newVideoFiles: MVideoFile[] = getFileAttributesFromUrl(playlistModel, tagObjects).map(a => new VideoFileModel(a))
165
166 const destroyTasks = deleteNonExistingModels(oldStreamingPlaylistFiles, newVideoFiles, t)
167 await Promise.all(destroyTasks)
168
169 // Update or add other one
170 const upsertTasks = newVideoFiles.map(f => VideoFileModel.customUpsert(f, 'streaming-playlist', t))
171 playlistModel.VideoFiles = await Promise.all(upsertTasks)
172 }
173}
diff --git a/server/lib/activitypub/videos/shared/creator.ts b/server/lib/activitypub/videos/shared/creator.ts
new file mode 100644
index 000000000..ad3b88936
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/creator.ts
@@ -0,0 +1,88 @@
1
2import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger'
3import { sequelizeTypescript } from '@server/initializers/database'
4import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
5import { VideoModel } from '@server/models/video/video'
6import { MThumbnail, MVideoFullLight, MVideoThumbnail } from '@server/types/models'
7import { VideoObject } from '@shared/models'
8import { APVideoAbstractBuilder } from './abstract-builder'
9import { getVideoAttributesFromObject } from './object-to-model-attributes'
10
11export class APVideoCreator extends APVideoAbstractBuilder {
12 protected lTags: LoggerTagsFn
13
14 constructor (protected readonly videoObject: VideoObject) {
15 super()
16
17 this.lTags = loggerTagsFactory('ap', 'video', 'create', this.videoObject.uuid, this.videoObject.id)
18 }
19
20 async create (waitThumbnail = false) {
21 logger.debug('Adding remote video %s.', this.videoObject.id, this.lTags())
22
23 const channelActor = await this.getOrCreateVideoChannelFromVideoObject()
24 const channel = channelActor.VideoChannel
25
26 const videoData = getVideoAttributesFromObject(channel, this.videoObject, this.videoObject.to)
27 const video = VideoModel.build(videoData) as MVideoThumbnail
28
29 const promiseThumbnail = this.tryToGenerateThumbnail(video)
30
31 let thumbnailModel: MThumbnail
32 if (waitThumbnail === true) {
33 thumbnailModel = await promiseThumbnail
34 }
35
36 const { autoBlacklisted, videoCreated } = await sequelizeTypescript.transaction(async t => {
37 try {
38 const videoCreated = await video.save({ transaction: t }) as MVideoFullLight
39 videoCreated.VideoChannel = channel
40
41 if (thumbnailModel) await videoCreated.addAndSaveThumbnail(thumbnailModel, t)
42
43 await this.setPreview(videoCreated, t)
44 await this.setWebTorrentFiles(videoCreated, t)
45 await this.setStreamingPlaylists(videoCreated, t)
46 await this.setTags(videoCreated, t)
47 await this.setTrackers(videoCreated, t)
48 await this.insertOrReplaceCaptions(videoCreated, t)
49 await this.insertOrReplaceLive(videoCreated, t)
50
51 // We added a video in this channel, set it as updated
52 await channel.setAsUpdated(t)
53
54 const autoBlacklisted = await autoBlacklistVideoIfNeeded({
55 video: videoCreated,
56 user: undefined,
57 isRemote: true,
58 isNew: true,
59 transaction: t
60 })
61
62 logger.info('Remote video with uuid %s inserted.', this.videoObject.uuid, this.lTags())
63
64 return { autoBlacklisted, videoCreated }
65 } catch (err) {
66 // FIXME: Use rollback hook when https://github.com/sequelize/sequelize/pull/13038 is released
67 // Remove thumbnail
68 if (thumbnailModel) await thumbnailModel.removeThumbnail()
69
70 throw err
71 }
72 })
73
74 if (waitThumbnail === false) {
75 // Error is already caught above
76 // eslint-disable-next-line @typescript-eslint/no-floating-promises
77 promiseThumbnail.then(thumbnailModel => {
78 if (!thumbnailModel) return
79
80 thumbnailModel = videoCreated.id
81
82 return thumbnailModel.save()
83 })
84 }
85
86 return { autoBlacklisted, videoCreated }
87 }
88}
diff --git a/server/lib/activitypub/videos/shared/index.ts b/server/lib/activitypub/videos/shared/index.ts
new file mode 100644
index 000000000..951403493
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/index.ts
@@ -0,0 +1,6 @@
1export * from './abstract-builder'
2export * from './creator'
3export * from './object-to-model-attributes'
4export * from './trackers'
5export * from './url-to-object'
6export * from './video-sync-attributes'
diff --git a/server/lib/activitypub/videos/shared/object-to-model-attributes.ts b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
new file mode 100644
index 000000000..85548428c
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/object-to-model-attributes.ts
@@ -0,0 +1,256 @@
1import { maxBy, minBy } from 'lodash'
2import * as magnetUtil from 'magnet-uri'
3import { basename } from 'path'
4import { isAPVideoFileUrlMetadataObject } from '@server/helpers/custom-validators/activitypub/videos'
5import { isVideoFileInfoHashValid } from '@server/helpers/custom-validators/videos'
6import { logger } from '@server/helpers/logger'
7import { getExtFromMimetype } from '@server/helpers/video'
8import { ACTIVITY_PUB, MIMETYPES, P2P_MEDIA_LOADER_PEER_VERSION, PREVIEWS_SIZE, THUMBNAILS_SIZE } from '@server/initializers/constants'
9import { generateTorrentFileName } from '@server/lib/video-paths'
10import { VideoFileModel } from '@server/models/video/video-file'
11import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
12import { FilteredModelAttributes } from '@server/types'
13import { MChannelId, MStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoFile, MVideoId } from '@server/types/models'
14import {
15 ActivityHashTagObject,
16 ActivityMagnetUrlObject,
17 ActivityPlaylistSegmentHashesObject,
18 ActivityPlaylistUrlObject,
19 ActivityTagObject,
20 ActivityUrlObject,
21 ActivityVideoUrlObject,
22 VideoObject,
23 VideoPrivacy,
24 VideoStreamingPlaylistType
25} from '@shared/models'
26import { VideoCaptionModel } from '@server/models/video/video-caption'
27
28function getThumbnailFromIcons (videoObject: VideoObject) {
29 let validIcons = videoObject.icon.filter(i => i.width > THUMBNAILS_SIZE.minWidth)
30 // Fallback if there are not valid icons
31 if (validIcons.length === 0) validIcons = videoObject.icon
32
33 return minBy(validIcons, 'width')
34}
35
36function getPreviewFromIcons (videoObject: VideoObject) {
37 const validIcons = videoObject.icon.filter(i => i.width > PREVIEWS_SIZE.minWidth)
38
39 return maxBy(validIcons, 'width')
40}
41
42function getTagsFromObject (videoObject: VideoObject) {
43 return videoObject.tag
44 .filter(isAPHashTagObject)
45 .map(t => t.name)
46}
47
48function getFileAttributesFromUrl (
49 videoOrPlaylist: MVideo | MStreamingPlaylistVideo,
50 urls: (ActivityTagObject | ActivityUrlObject)[]
51) {
52 const fileUrls = urls.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
53
54 if (fileUrls.length === 0) return []
55
56 const attributes: FilteredModelAttributes<VideoFileModel>[] = []
57 for (const fileUrl of fileUrls) {
58 // Fetch associated magnet uri
59 const magnet = urls.filter(isAPMagnetUrlObject)
60 .find(u => u.height === fileUrl.height)
61
62 if (!magnet) throw new Error('Cannot find associated magnet uri for file ' + fileUrl.href)
63
64 const parsed = magnetUtil.decode(magnet.href)
65 if (!parsed || isVideoFileInfoHashValid(parsed.infoHash) === false) {
66 throw new Error('Cannot parse magnet URI ' + magnet.href)
67 }
68
69 const torrentUrl = Array.isArray(parsed.xs)
70 ? parsed.xs[0]
71 : parsed.xs
72
73 // Fetch associated metadata url, if any
74 const metadata = urls.filter(isAPVideoFileUrlMetadataObject)
75 .find(u => {
76 return u.height === fileUrl.height &&
77 u.fps === fileUrl.fps &&
78 u.rel.includes(fileUrl.mediaType)
79 })
80
81 const extname = getExtFromMimetype(MIMETYPES.VIDEO.MIMETYPE_EXT, fileUrl.mediaType)
82 const resolution = fileUrl.height
83 const videoId = (videoOrPlaylist as MStreamingPlaylist).playlistUrl ? null : videoOrPlaylist.id
84 const videoStreamingPlaylistId = (videoOrPlaylist as MStreamingPlaylist).playlistUrl ? videoOrPlaylist.id : null
85
86 const attribute = {
87 extname,
88 infoHash: parsed.infoHash,
89 resolution,
90 size: fileUrl.size,
91 fps: fileUrl.fps || -1,
92 metadataUrl: metadata?.href,
93
94 // Use the name of the remote file because we don't proxify video file requests
95 filename: basename(fileUrl.href),
96 fileUrl: fileUrl.href,
97
98 torrentUrl,
99 // Use our own torrent name since we proxify torrent requests
100 torrentFilename: generateTorrentFileName(videoOrPlaylist, resolution),
101
102 // This is a video file owned by a video or by a streaming playlist
103 videoId,
104 videoStreamingPlaylistId
105 }
106
107 attributes.push(attribute)
108 }
109
110 return attributes
111}
112
113function getStreamingPlaylistAttributesFromObject (video: MVideoId, videoObject: VideoObject, videoFiles: MVideoFile[]) {
114 const playlistUrls = videoObject.url.filter(u => isAPStreamingPlaylistUrlObject(u)) as ActivityPlaylistUrlObject[]
115 if (playlistUrls.length === 0) return []
116
117 const attributes: (FilteredModelAttributes<VideoStreamingPlaylistModel> & { tagAPObject?: ActivityTagObject[] })[] = []
118 for (const playlistUrlObject of playlistUrls) {
119 const segmentsSha256UrlObject = playlistUrlObject.tag.find(isAPPlaylistSegmentHashesUrlObject)
120
121 let files: unknown[] = playlistUrlObject.tag.filter(u => isAPVideoUrlObject(u)) as ActivityVideoUrlObject[]
122
123 // FIXME: backward compatibility introduced in v2.1.0
124 if (files.length === 0) files = videoFiles
125
126 if (!segmentsSha256UrlObject) {
127 logger.warn('No segment sha256 URL found in AP playlist object.', { playlistUrl: playlistUrlObject })
128 continue
129 }
130
131 const attribute = {
132 type: VideoStreamingPlaylistType.HLS,
133 playlistUrl: playlistUrlObject.href,
134 segmentsSha256Url: segmentsSha256UrlObject.href,
135 p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrlObject.href, files),
136 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
137 videoId: video.id,
138
139 tagAPObject: playlistUrlObject.tag
140 }
141
142 attributes.push(attribute)
143 }
144
145 return attributes
146}
147
148function getLiveAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
149 return {
150 saveReplay: videoObject.liveSaveReplay,
151 permanentLive: videoObject.permanentLive,
152 videoId: video.id
153 }
154}
155
156function getCaptionAttributesFromObject (video: MVideoId, videoObject: VideoObject) {
157 return videoObject.subtitleLanguage.map(c => ({
158 videoId: video.id,
159 filename: VideoCaptionModel.generateCaptionName(c.identifier),
160 language: c.identifier,
161 fileUrl: c.url
162 }))
163}
164
165function getVideoAttributesFromObject (videoChannel: MChannelId, videoObject: VideoObject, to: string[] = []) {
166 const privacy = to.includes(ACTIVITY_PUB.PUBLIC)
167 ? VideoPrivacy.PUBLIC
168 : VideoPrivacy.UNLISTED
169
170 const duration = videoObject.duration.replace(/[^\d]+/, '')
171 const language = videoObject.language?.identifier
172
173 const category = videoObject.category
174 ? parseInt(videoObject.category.identifier, 10)
175 : undefined
176
177 const licence = videoObject.licence
178 ? parseInt(videoObject.licence.identifier, 10)
179 : undefined
180
181 const description = videoObject.content || null
182 const support = videoObject.support || null
183
184 return {
185 name: videoObject.name,
186 uuid: videoObject.uuid,
187 url: videoObject.id,
188 category,
189 licence,
190 language,
191 description,
192 support,
193 nsfw: videoObject.sensitive,
194 commentsEnabled: videoObject.commentsEnabled,
195 downloadEnabled: videoObject.downloadEnabled,
196 waitTranscoding: videoObject.waitTranscoding,
197 isLive: videoObject.isLiveBroadcast,
198 state: videoObject.state,
199 channelId: videoChannel.id,
200 duration: parseInt(duration, 10),
201 createdAt: new Date(videoObject.published),
202 publishedAt: new Date(videoObject.published),
203
204 originallyPublishedAt: videoObject.originallyPublishedAt
205 ? new Date(videoObject.originallyPublishedAt)
206 : null,
207
208 updatedAt: new Date(videoObject.updated),
209 views: videoObject.views,
210 likes: 0,
211 dislikes: 0,
212 remote: true,
213 privacy
214 }
215}
216
217// ---------------------------------------------------------------------------
218
219export {
220 getThumbnailFromIcons,
221 getPreviewFromIcons,
222
223 getTagsFromObject,
224
225 getFileAttributesFromUrl,
226 getStreamingPlaylistAttributesFromObject,
227
228 getLiveAttributesFromObject,
229 getCaptionAttributesFromObject,
230
231 getVideoAttributesFromObject
232}
233
234// ---------------------------------------------------------------------------
235
236function isAPVideoUrlObject (url: any): url is ActivityVideoUrlObject {
237 const urlMediaType = url.mediaType
238
239 return MIMETYPES.VIDEO.MIMETYPE_EXT[urlMediaType] && urlMediaType.startsWith('video/')
240}
241
242function isAPStreamingPlaylistUrlObject (url: any): url is ActivityPlaylistUrlObject {
243 return url && url.mediaType === 'application/x-mpegURL'
244}
245
246function isAPPlaylistSegmentHashesUrlObject (tag: any): tag is ActivityPlaylistSegmentHashesObject {
247 return tag && tag.name === 'sha256' && tag.type === 'Link' && tag.mediaType === 'application/json'
248}
249
250function isAPMagnetUrlObject (url: any): url is ActivityMagnetUrlObject {
251 return url && url.mediaType === 'application/x-bittorrent;x-scheme-handler/magnet'
252}
253
254function isAPHashTagObject (url: any): url is ActivityHashTagObject {
255 return url && url.type === 'Hashtag'
256}
diff --git a/server/lib/activitypub/videos/shared/trackers.ts b/server/lib/activitypub/videos/shared/trackers.ts
new file mode 100644
index 000000000..1c5fc4f84
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/trackers.ts
@@ -0,0 +1,43 @@
1import { Transaction } from 'sequelize/types'
2import { buildRemoteVideoBaseUrl } from '@server/helpers/activitypub'
3import { isAPVideoTrackerUrlObject } from '@server/helpers/custom-validators/activitypub/videos'
4import { isArray } from '@server/helpers/custom-validators/misc'
5import { REMOTE_SCHEME } from '@server/initializers/constants'
6import { TrackerModel } from '@server/models/server/tracker'
7import { MVideo, MVideoWithHost } from '@server/types/models'
8import { ActivityTrackerUrlObject, VideoObject } from '@shared/models'
9
10function getTrackerUrls (object: VideoObject, video: MVideoWithHost) {
11 let wsFound = false
12
13 const trackers = object.url.filter(u => isAPVideoTrackerUrlObject(u))
14 .map((u: ActivityTrackerUrlObject) => {
15 if (isArray(u.rel) && u.rel.includes('websocket')) wsFound = true
16
17 return u.href
18 })
19
20 if (wsFound) return trackers
21
22 return [
23 buildRemoteVideoBaseUrl(video, '/tracker/socket', REMOTE_SCHEME.WS),
24 buildRemoteVideoBaseUrl(video, '/tracker/announce')
25 ]
26}
27
28async function setVideoTrackers (options: {
29 video: MVideo
30 trackers: string[]
31 transaction: Transaction
32}) {
33 const { video, trackers, transaction } = options
34
35 const trackerInstances = await TrackerModel.findOrCreateTrackers(trackers, transaction)
36
37 await video.$set('Trackers', trackerInstances, { transaction })
38}
39
40export {
41 getTrackerUrls,
42 setVideoTrackers
43}
diff --git a/server/lib/activitypub/videos/shared/url-to-object.ts b/server/lib/activitypub/videos/shared/url-to-object.ts
new file mode 100644
index 000000000..dba3e9480
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/url-to-object.ts
@@ -0,0 +1,25 @@
1import { checkUrlsSameHost } from '@server/helpers/activitypub'
2import { sanitizeAndCheckVideoTorrentObject } from '@server/helpers/custom-validators/activitypub/videos'
3import { logger, loggerTagsFactory } from '@server/helpers/logger'
4import { doJSONRequest } from '@server/helpers/requests'
5import { VideoObject } from '@shared/models'
6
7const lTags = loggerTagsFactory('ap', 'video')
8
9async function fetchRemoteVideo (videoUrl: string): Promise<{ statusCode: number, videoObject: VideoObject }> {
10 logger.info('Fetching remote video %s.', videoUrl, lTags(videoUrl))
11
12 const { statusCode, body } = await doJSONRequest<any>(videoUrl, { activityPub: true })
13
14 if (sanitizeAndCheckVideoTorrentObject(body) === false || checkUrlsSameHost(body.id, videoUrl) !== true) {
15 logger.debug('Remote video JSON is not valid.', { body, ...lTags(videoUrl) })
16
17 return { statusCode, videoObject: undefined }
18 }
19
20 return { statusCode, videoObject: body }
21}
22
23export {
24 fetchRemoteVideo
25}
diff --git a/server/lib/activitypub/videos/shared/video-sync-attributes.ts b/server/lib/activitypub/videos/shared/video-sync-attributes.ts
new file mode 100644
index 000000000..c4e101005
--- /dev/null
+++ b/server/lib/activitypub/videos/shared/video-sync-attributes.ts
@@ -0,0 +1,94 @@
1import { logger, loggerTagsFactory } from '@server/helpers/logger'
2import { JobQueue } from '@server/lib/job-queue'
3import { AccountVideoRateModel } from '@server/models/account/account-video-rate'
4import { VideoCommentModel } from '@server/models/video/video-comment'
5import { VideoShareModel } from '@server/models/video/video-share'
6import { MVideo } from '@server/types/models'
7import { ActivitypubHttpFetcherPayload, VideoObject } from '@shared/models'
8import { crawlCollectionPage } from '../../crawl'
9import { addVideoShares } from '../../share'
10import { addVideoComments } from '../../video-comments'
11import { createRates } from '../../video-rates'
12
13const lTags = loggerTagsFactory('ap', 'video')
14
15type SyncParam = {
16 likes: boolean
17 dislikes: boolean
18 shares: boolean
19 comments: boolean
20 thumbnail: boolean
21 refreshVideo?: boolean
22}
23
24async function syncVideoExternalAttributes (video: MVideo, fetchedVideo: VideoObject, syncParam: SyncParam) {
25 logger.info('Adding likes/dislikes/shares/comments of video %s.', video.uuid)
26
27 await syncRates('like', video, fetchedVideo, syncParam.likes)
28 await syncRates('dislike', video, fetchedVideo, syncParam.dislikes)
29
30 await syncShares(video, fetchedVideo, syncParam.shares)
31
32 await syncComments(video, fetchedVideo, syncParam.comments)
33}
34
35// ---------------------------------------------------------------------------
36
37export {
38 SyncParam,
39 syncVideoExternalAttributes
40}
41
42// ---------------------------------------------------------------------------
43
44function createJob (payload: ActivitypubHttpFetcherPayload) {
45 return JobQueue.Instance.createJobWithPromise({ type: 'activitypub-http-fetcher', payload })
46}
47
48function syncRates (type: 'like' | 'dislike', video: MVideo, fetchedVideo: VideoObject, isSync: boolean) {
49 const uri = type === 'like'
50 ? fetchedVideo.likes
51 : fetchedVideo.dislikes
52
53 if (!isSync) {
54 const jobType = type === 'like'
55 ? 'video-likes'
56 : 'video-dislikes'
57
58 return createJob({ uri, videoId: video.id, type: jobType })
59 }
60
61 const handler = items => createRates(items, video, type)
62 const cleaner = crawlStartDate => AccountVideoRateModel.cleanOldRatesOf(video.id, type, crawlStartDate)
63
64 return crawlCollectionPage<string>(uri, handler, cleaner)
65 .catch(err => logger.error('Cannot add rate of video %s.', video.uuid, { err, rootUrl: uri, ...lTags(video.uuid, video.url) }))
66}
67
68function syncShares (video: MVideo, fetchedVideo: VideoObject, isSync: boolean) {
69 const uri = fetchedVideo.shares
70
71 if (!isSync) {
72 return createJob({ uri, videoId: video.id, type: 'video-shares' })
73 }
74
75 const handler = items => addVideoShares(items, video)
76 const cleaner = crawlStartDate => VideoShareModel.cleanOldSharesOf(video.id, crawlStartDate)
77
78 return crawlCollectionPage<string>(uri, handler, cleaner)
79 .catch(err => logger.error('Cannot add shares of video %s.', video.uuid, { err, rootUrl: uri, ...lTags(video.uuid, video.url) }))
80}
81
82function syncComments (video: MVideo, fetchedVideo: VideoObject, isSync: boolean) {
83 const uri = fetchedVideo.comments
84
85 if (!isSync) {
86 return createJob({ uri, videoId: video.id, type: 'video-comments' })
87 }
88
89 const handler = items => addVideoComments(items)
90 const cleaner = crawlStartDate => VideoCommentModel.cleanOldCommentsOf(video.id, crawlStartDate)
91
92 return crawlCollectionPage<string>(uri, handler, cleaner)
93 .catch(err => logger.error('Cannot add comments of video %s.', video.uuid, { err, rootUrl: uri, ...lTags(video.uuid, video.url) }))
94}
diff --git a/server/lib/activitypub/videos/updater.ts b/server/lib/activitypub/videos/updater.ts
new file mode 100644
index 000000000..157569414
--- /dev/null
+++ b/server/lib/activitypub/videos/updater.ts
@@ -0,0 +1,166 @@
1import { Transaction } from 'sequelize/types'
2import { resetSequelizeInstance, runInReadCommittedTransaction } from '@server/helpers/database-utils'
3import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger'
4import { Notifier } from '@server/lib/notifier'
5import { PeerTubeSocket } from '@server/lib/peertube-socket'
6import { autoBlacklistVideoIfNeeded } from '@server/lib/video-blacklist'
7import { VideoLiveModel } from '@server/models/video/video-live'
8import { MActor, MChannelAccountLight, MChannelId, MVideoAccountLightBlacklistAllFiles, MVideoFullLight } from '@server/types/models'
9import { VideoObject, VideoPrivacy } from '@shared/models'
10import { APVideoAbstractBuilder, getVideoAttributesFromObject } from './shared'
11
12export class APVideoUpdater extends APVideoAbstractBuilder {
13 private readonly wasPrivateVideo: boolean
14 private readonly wasUnlistedVideo: boolean
15
16 private readonly videoFieldsSave: any
17
18 private readonly oldVideoChannel: MChannelAccountLight
19
20 protected lTags: LoggerTagsFn
21
22 constructor (
23 protected readonly videoObject: VideoObject,
24 private readonly video: MVideoAccountLightBlacklistAllFiles
25 ) {
26 super()
27
28 this.wasPrivateVideo = this.video.privacy === VideoPrivacy.PRIVATE
29 this.wasUnlistedVideo = this.video.privacy === VideoPrivacy.UNLISTED
30
31 this.oldVideoChannel = this.video.VideoChannel
32
33 this.videoFieldsSave = this.video.toJSON()
34
35 this.lTags = loggerTagsFactory('ap', 'video', 'update', video.uuid, video.url)
36 }
37
38 async update (overrideTo?: string[]) {
39 logger.debug(
40 'Updating remote video "%s".', this.videoObject.uuid,
41 { videoObject: this.videoObject, ...this.lTags() }
42 )
43
44 try {
45 const channelActor = await this.getOrCreateVideoChannelFromVideoObject()
46
47 const thumbnailModel = await this.tryToGenerateThumbnail(this.video)
48
49 this.checkChannelUpdateOrThrow(channelActor)
50
51 const videoUpdated = await this.updateVideo(channelActor.VideoChannel, undefined, overrideTo)
52
53 if (thumbnailModel) await videoUpdated.addAndSaveThumbnail(thumbnailModel)
54
55 await runInReadCommittedTransaction(async t => {
56 await this.setWebTorrentFiles(videoUpdated, t)
57 await this.setStreamingPlaylists(videoUpdated, t)
58 })
59
60 await Promise.all([
61 runInReadCommittedTransaction(t => this.setTags(videoUpdated, t)),
62 runInReadCommittedTransaction(t => this.setTrackers(videoUpdated, t)),
63 this.setOrDeleteLive(videoUpdated),
64 this.setPreview(videoUpdated)
65 ])
66
67 await runInReadCommittedTransaction(t => this.setCaptions(videoUpdated, t))
68
69 await autoBlacklistVideoIfNeeded({
70 video: videoUpdated,
71 user: undefined,
72 isRemote: true,
73 isNew: false,
74 transaction: undefined
75 })
76
77 // Notify our users?
78 if (this.wasPrivateVideo || this.wasUnlistedVideo) {
79 Notifier.Instance.notifyOnNewVideoIfNeeded(videoUpdated)
80 }
81
82 if (videoUpdated.isLive) {
83 PeerTubeSocket.Instance.sendVideoLiveNewState(videoUpdated)
84 PeerTubeSocket.Instance.sendVideoViewsUpdate(videoUpdated)
85 }
86
87 logger.info('Remote video with uuid %s updated', this.videoObject.uuid, this.lTags())
88
89 return videoUpdated
90 } catch (err) {
91 this.catchUpdateError(err)
92 }
93 }
94
95 // Check we can update the channel: we trust the remote server
96 private checkChannelUpdateOrThrow (newChannelActor: MActor) {
97 if (!this.oldVideoChannel.Actor.serverId || !newChannelActor.serverId) {
98 throw new Error('Cannot check old channel/new channel validity because `serverId` is null')
99 }
100
101 if (this.oldVideoChannel.Actor.serverId !== newChannelActor.serverId) {
102 throw new Error(`New channel ${newChannelActor.url} is not on the same server than new channel ${this.oldVideoChannel.Actor.url}`)
103 }
104 }
105
106 private updateVideo (channel: MChannelId, transaction?: Transaction, overrideTo?: string[]) {
107 const to = overrideTo || this.videoObject.to
108 const videoData = getVideoAttributesFromObject(channel, this.videoObject, to)
109 this.video.name = videoData.name
110 this.video.uuid = videoData.uuid
111 this.video.url = videoData.url
112 this.video.category = videoData.category
113 this.video.licence = videoData.licence
114 this.video.language = videoData.language
115 this.video.description = videoData.description
116 this.video.support = videoData.support
117 this.video.nsfw = videoData.nsfw
118 this.video.commentsEnabled = videoData.commentsEnabled
119 this.video.downloadEnabled = videoData.downloadEnabled
120 this.video.waitTranscoding = videoData.waitTranscoding
121 this.video.state = videoData.state
122 this.video.duration = videoData.duration
123 this.video.createdAt = videoData.createdAt
124 this.video.publishedAt = videoData.publishedAt
125 this.video.originallyPublishedAt = videoData.originallyPublishedAt
126 this.video.privacy = videoData.privacy
127 this.video.channelId = videoData.channelId
128 this.video.views = videoData.views
129 this.video.isLive = videoData.isLive
130
131 // Ensures we update the updatedAt attribute, even if main attributes did not change
132 this.video.changed('updatedAt', true)
133
134 return this.video.save({ transaction }) as Promise<MVideoFullLight>
135 }
136
137 private async setCaptions (videoUpdated: MVideoFullLight, t: Transaction) {
138 await this.insertOrReplaceCaptions(videoUpdated, t)
139 }
140
141 private async setOrDeleteLive (videoUpdated: MVideoFullLight, transaction?: Transaction) {
142 if (!this.video.isLive) return
143
144 if (this.video.isLive) return this.insertOrReplaceLive(videoUpdated, transaction)
145
146 // Delete existing live if it exists
147 await VideoLiveModel.destroy({
148 where: {
149 videoId: this.video.id
150 },
151 transaction
152 })
153
154 videoUpdated.VideoLive = null
155 }
156
157 private catchUpdateError (err: Error) {
158 if (this.video !== undefined && this.videoFieldsSave !== undefined) {
159 resetSequelizeInstance(this.video, this.videoFieldsSave)
160 }
161
162 // This is just a debug because we will retry the insert
163 logger.debug('Cannot update the remote video.', { err, ...this.lTags() })
164 throw err
165 }
166}
diff --git a/server/lib/auth/oauth-model.ts b/server/lib/auth/oauth-model.ts
index b9c69eb2d..ae728d080 100644
--- a/server/lib/auth/oauth-model.ts
+++ b/server/lib/auth/oauth-model.ts
@@ -1,7 +1,7 @@
1import * as express from 'express' 1import * as express from 'express'
2import { AccessDeniedError } from 'oauth2-server' 2import { AccessDeniedError } from 'oauth2-server'
3import { PluginManager } from '@server/lib/plugins/plugin-manager' 3import { PluginManager } from '@server/lib/plugins/plugin-manager'
4import { ActorModel } from '@server/models/activitypub/actor' 4import { ActorModel } from '@server/models/actor/actor'
5import { MOAuthClient } from '@server/types/models' 5import { MOAuthClient } from '@server/types/models'
6import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' 6import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
7import { MUser } from '@server/types/models/user/user' 7import { MUser } from '@server/types/models/user/user'
@@ -9,7 +9,7 @@ import { UserAdminFlag } from '@shared/models/users/user-flag.model'
9import { UserRole } from '@shared/models/users/user-role' 9import { UserRole } from '@shared/models/users/user-role'
10import { logger } from '../../helpers/logger' 10import { logger } from '../../helpers/logger'
11import { CONFIG } from '../../initializers/config' 11import { CONFIG } from '../../initializers/config'
12import { UserModel } from '../../models/account/user' 12import { UserModel } from '../../models/user/user'
13import { OAuthClientModel } from '../../models/oauth/oauth-client' 13import { OAuthClientModel } from '../../models/oauth/oauth-client'
14import { OAuthTokenModel } from '../../models/oauth/oauth-token' 14import { OAuthTokenModel } from '../../models/oauth/oauth-token'
15import { createUserAccountAndChannelAndPlaylist } from '../user' 15import { createUserAccountAndChannelAndPlaylist } from '../user'
diff --git a/server/lib/client-html.ts b/server/lib/client-html.ts
index 203bd3893..72194416d 100644
--- a/server/lib/client-html.ts
+++ b/server/lib/client-html.ts
@@ -2,12 +2,14 @@ import * as express from 'express'
2import { readFile } from 'fs-extra' 2import { readFile } from 'fs-extra'
3import { join } from 'path' 3import { join } from 'path'
4import validator from 'validator' 4import validator from 'validator'
5import { escapeHTML } from '@shared/core-utils/renderer'
6import { HTMLServerConfig } from '@shared/models'
5import { buildFileLocale, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '../../shared/core-utils/i18n/i18n' 7import { buildFileLocale, getDefaultLocale, is18nLocale, POSSIBLE_LOCALES } from '../../shared/core-utils/i18n/i18n'
6import { HttpStatusCode } from '../../shared/core-utils/miscs/http-error-codes' 8import { HttpStatusCode } from '../../shared/core-utils/miscs/http-error-codes'
7import { VideoPlaylistPrivacy, VideoPrivacy } from '../../shared/models/videos' 9import { VideoPlaylistPrivacy, VideoPrivacy } from '../../shared/models/videos'
8import { isTestInstance, sha256 } from '../helpers/core-utils' 10import { isTestInstance, sha256 } from '../helpers/core-utils'
9import { escapeHTML } from '@shared/core-utils/renderer'
10import { logger } from '../helpers/logger' 11import { logger } from '../helpers/logger'
12import { mdToPlainText } from '../helpers/markdown'
11import { CONFIG } from '../initializers/config' 13import { CONFIG } from '../initializers/config'
12import { 14import {
13 ACCEPT_HEADERS, 15 ACCEPT_HEADERS,
@@ -19,12 +21,13 @@ import {
19 WEBSERVER 21 WEBSERVER
20} from '../initializers/constants' 22} from '../initializers/constants'
21import { AccountModel } from '../models/account/account' 23import { AccountModel } from '../models/account/account'
24import { getActivityStreamDuration } from '../models/video/formatter/video-format-utils'
22import { VideoModel } from '../models/video/video' 25import { VideoModel } from '../models/video/video'
23import { VideoChannelModel } from '../models/video/video-channel' 26import { VideoChannelModel } from '../models/video/video-channel'
24import { getActivityStreamDuration } from '../models/video/video-format-utils'
25import { VideoPlaylistModel } from '../models/video/video-playlist' 27import { VideoPlaylistModel } from '../models/video/video-playlist'
26import { MAccountActor, MChannelActor } from '../types/models' 28import { MAccountActor, MChannelActor } from '../types/models'
27import { mdToPlainText } from '../helpers/markdown' 29import { ServerConfigManager } from './server-config-manager'
30import { toCompleteUUID } from '@server/helpers/custom-validators/misc'
28 31
29type Tags = { 32type Tags = {
30 ogType: string 33 ogType: string
@@ -76,7 +79,9 @@ class ClientHtml {
76 return customHtml 79 return customHtml
77 } 80 }
78 81
79 static async getWatchHTMLPage (videoId: string, req: express.Request, res: express.Response) { 82 static async getWatchHTMLPage (videoIdArg: string, req: express.Request, res: express.Response) {
83 const videoId = toCompleteUUID(videoIdArg)
84
80 // Let Angular application handle errors 85 // Let Angular application handle errors
81 if (!validator.isInt(videoId) && !validator.isUUID(videoId, 4)) { 86 if (!validator.isInt(videoId) && !validator.isUUID(videoId, 4)) {
82 res.status(HttpStatusCode.NOT_FOUND_404) 87 res.status(HttpStatusCode.NOT_FOUND_404)
@@ -134,7 +139,9 @@ class ClientHtml {
134 return customHtml 139 return customHtml
135 } 140 }
136 141
137 static async getWatchPlaylistHTMLPage (videoPlaylistId: string, req: express.Request, res: express.Response) { 142 static async getWatchPlaylistHTMLPage (videoPlaylistIdArg: string, req: express.Request, res: express.Response) {
143 const videoPlaylistId = toCompleteUUID(videoPlaylistIdArg)
144
138 // Let Angular application handle errors 145 // Let Angular application handle errors
139 if (!validator.isInt(videoPlaylistId) && !validator.isUUID(videoPlaylistId, 4)) { 146 if (!validator.isInt(videoPlaylistId) && !validator.isUUID(videoPlaylistId, 4)) {
140 res.status(HttpStatusCode.NOT_FOUND_404) 147 res.status(HttpStatusCode.NOT_FOUND_404)
@@ -196,11 +203,22 @@ class ClientHtml {
196 } 203 }
197 204
198 static async getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) { 205 static async getAccountHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
199 return this.getAccountOrChannelHTMLPage(() => AccountModel.loadByNameWithHost(nameWithHost), req, res) 206 const accountModelPromise = AccountModel.loadByNameWithHost(nameWithHost)
207 return this.getAccountOrChannelHTMLPage(() => accountModelPromise, req, res)
200 } 208 }
201 209
202 static async getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) { 210 static async getVideoChannelHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
203 return this.getAccountOrChannelHTMLPage(() => VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost), req, res) 211 const videoChannelModelPromise = VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
212 return this.getAccountOrChannelHTMLPage(() => videoChannelModelPromise, req, res)
213 }
214
215 static async getActorHTMLPage (nameWithHost: string, req: express.Request, res: express.Response) {
216 const [ account, channel ] = await Promise.all([
217 AccountModel.loadByNameWithHost(nameWithHost),
218 VideoChannelModel.loadByNameWithHostAndPopulateAccount(nameWithHost)
219 ])
220
221 return this.getAccountOrChannelHTMLPage(() => Promise.resolve(account || channel), req, res)
204 } 222 }
205 223
206 static async getEmbedHTML () { 224 static async getEmbedHTML () {
@@ -209,11 +227,14 @@ class ClientHtml {
209 if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path] 227 if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
210 228
211 const buffer = await readFile(path) 229 const buffer = await readFile(path)
230 const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
212 231
213 let html = buffer.toString() 232 let html = buffer.toString()
214 html = await ClientHtml.addAsyncPluginCSS(html) 233 html = await ClientHtml.addAsyncPluginCSS(html)
215 html = ClientHtml.addCustomCSS(html) 234 html = ClientHtml.addCustomCSS(html)
216 html = ClientHtml.addTitleTag(html) 235 html = ClientHtml.addTitleTag(html)
236 html = ClientHtml.addDescriptionTag(html)
237 html = ClientHtml.addServerConfig(html, serverConfig)
217 238
218 ClientHtml.htmlCache[path] = html 239 ClientHtml.htmlCache[path] = html
219 240
@@ -275,6 +296,7 @@ class ClientHtml {
275 if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path] 296 if (!isTestInstance() && ClientHtml.htmlCache[path]) return ClientHtml.htmlCache[path]
276 297
277 const buffer = await readFile(path) 298 const buffer = await readFile(path)
299 const serverConfig = await ServerConfigManager.Instance.getHTMLServerConfig()
278 300
279 let html = buffer.toString() 301 let html = buffer.toString()
280 302
@@ -283,6 +305,7 @@ class ClientHtml {
283 html = ClientHtml.addFaviconContentHash(html) 305 html = ClientHtml.addFaviconContentHash(html)
284 html = ClientHtml.addLogoContentHash(html) 306 html = ClientHtml.addLogoContentHash(html)
285 html = ClientHtml.addCustomCSS(html) 307 html = ClientHtml.addCustomCSS(html)
308 html = ClientHtml.addServerConfig(html, serverConfig)
286 html = await ClientHtml.addAsyncPluginCSS(html) 309 html = await ClientHtml.addAsyncPluginCSS(html)
287 310
288 ClientHtml.htmlCache[path] = html 311 ClientHtml.htmlCache[path] = html
@@ -355,6 +378,13 @@ class ClientHtml {
355 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.CUSTOM_CSS, styleTag) 378 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.CUSTOM_CSS, styleTag)
356 } 379 }
357 380
381 private static addServerConfig (htmlStringPage: string, serverConfig: HTMLServerConfig) {
382 const serverConfigString = JSON.stringify(serverConfig)
383 const configScriptTag = `<script type="application/javascript">window.PeerTubeServerConfig = '${serverConfigString}'</script>`
384
385 return htmlStringPage.replace(CUSTOM_HTML_TAG_COMMENTS.SERVER_CONFIG, configScriptTag)
386 }
387
358 private static async addAsyncPluginCSS (htmlStringPage: string) { 388 private static async addAsyncPluginCSS (htmlStringPage: string) {
359 const globalCSSContent = await readFile(PLUGIN_GLOBAL_CSS_PATH) 389 const globalCSSContent = await readFile(PLUGIN_GLOBAL_CSS_PATH)
360 if (globalCSSContent.byteLength === 0) return htmlStringPage 390 if (globalCSSContent.byteLength === 0) return htmlStringPage
@@ -524,11 +554,11 @@ async function serveIndexHTML (req: express.Request, res: express.Response) {
524 return 554 return
525 } catch (err) { 555 } catch (err) {
526 logger.error('Cannot generate HTML page.', err) 556 logger.error('Cannot generate HTML page.', err)
527 return res.sendStatus(HttpStatusCode.INTERNAL_SERVER_ERROR_500) 557 return res.status(HttpStatusCode.INTERNAL_SERVER_ERROR_500).end()
528 } 558 }
529 } 559 }
530 560
531 return res.sendStatus(HttpStatusCode.NOT_ACCEPTABLE_406) 561 return res.status(HttpStatusCode.NOT_ACCEPTABLE_406).end()
532} 562}
533 563
534// --------------------------------------------------------------------------- 564// ---------------------------------------------------------------------------
diff --git a/server/lib/config.ts b/server/lib/config.ts
deleted file mode 100644
index b4c4c9299..000000000
--- a/server/lib/config.ts
+++ /dev/null
@@ -1,255 +0,0 @@
1import { isSignupAllowed, isSignupAllowedForCurrentIP } from '@server/helpers/signup'
2import { getServerCommit } from '@server/helpers/utils'
3import { CONFIG, isEmailEnabled } from '@server/initializers/config'
4import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION } from '@server/initializers/constants'
5import { RegisteredExternalAuthConfig, RegisteredIdAndPassAuthConfig, ServerConfig } from '@shared/models'
6import { Hooks } from './plugins/hooks'
7import { PluginManager } from './plugins/plugin-manager'
8import { getThemeOrDefault } from './plugins/theme-utils'
9import { getEnabledResolutions } from './video-transcoding'
10import { VideoTranscodingProfilesManager } from './video-transcoding-profiles'
11
12let serverCommit: string
13
14async function getServerConfig (ip?: string): Promise<ServerConfig> {
15 if (serverCommit === undefined) serverCommit = await getServerCommit()
16
17 const { allowed } = await Hooks.wrapPromiseFun(
18 isSignupAllowed,
19 {
20 ip
21 },
22 'filter:api.user.signup.allowed.result'
23 )
24
25 const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip)
26 const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
27
28 return {
29 instance: {
30 name: CONFIG.INSTANCE.NAME,
31 shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
32 isNSFW: CONFIG.INSTANCE.IS_NSFW,
33 defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
34 defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE,
35 customizations: {
36 javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT,
37 css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
38 }
39 },
40 search: {
41 remoteUri: {
42 users: CONFIG.SEARCH.REMOTE_URI.USERS,
43 anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
44 },
45 searchIndex: {
46 enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED,
47 url: CONFIG.SEARCH.SEARCH_INDEX.URL,
48 disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH,
49 isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH
50 }
51 },
52 plugin: {
53 registered: getRegisteredPlugins(),
54 registeredExternalAuths: getExternalAuthsPlugins(),
55 registeredIdAndPassAuths: getIdAndPassAuthPlugins()
56 },
57 theme: {
58 registered: getRegisteredThemes(),
59 default: defaultTheme
60 },
61 email: {
62 enabled: isEmailEnabled()
63 },
64 contactForm: {
65 enabled: CONFIG.CONTACT_FORM.ENABLED
66 },
67 serverVersion: PEERTUBE_VERSION,
68 serverCommit,
69 signup: {
70 allowed,
71 allowedForCurrentIP,
72 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
73 },
74 transcoding: {
75 hls: {
76 enabled: CONFIG.TRANSCODING.HLS.ENABLED
77 },
78 webtorrent: {
79 enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
80 },
81 enabledResolutions: getEnabledResolutions('vod'),
82 profile: CONFIG.TRANSCODING.PROFILE,
83 availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('vod')
84 },
85 live: {
86 enabled: CONFIG.LIVE.ENABLED,
87
88 allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
89 maxDuration: CONFIG.LIVE.MAX_DURATION,
90 maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
91 maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,
92
93 transcoding: {
94 enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
95 enabledResolutions: getEnabledResolutions('live'),
96 profile: CONFIG.LIVE.TRANSCODING.PROFILE,
97 availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('live')
98 },
99
100 rtmp: {
101 port: CONFIG.LIVE.RTMP.PORT
102 }
103 },
104 import: {
105 videos: {
106 http: {
107 enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED
108 },
109 torrent: {
110 enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED
111 }
112 }
113 },
114 autoBlacklist: {
115 videos: {
116 ofUsers: {
117 enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED
118 }
119 }
120 },
121 avatar: {
122 file: {
123 size: {
124 max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
125 },
126 extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
127 }
128 },
129 banner: {
130 file: {
131 size: {
132 max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
133 },
134 extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
135 }
136 },
137 video: {
138 image: {
139 extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME,
140 size: {
141 max: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max
142 }
143 },
144 file: {
145 extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME
146 }
147 },
148 videoCaption: {
149 file: {
150 size: {
151 max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
152 },
153 extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
154 }
155 },
156 user: {
157 videoQuota: CONFIG.USER.VIDEO_QUOTA,
158 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
159 },
160 trending: {
161 videos: {
162 intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS,
163 algorithms: {
164 enabled: CONFIG.TRENDING.VIDEOS.ALGORITHMS.ENABLED,
165 default: CONFIG.TRENDING.VIDEOS.ALGORITHMS.DEFAULT
166 }
167 }
168 },
169 tracker: {
170 enabled: CONFIG.TRACKER.ENABLED
171 },
172
173 followings: {
174 instance: {
175 autoFollowIndex: {
176 indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
177 }
178 }
179 },
180
181 broadcastMessage: {
182 enabled: CONFIG.BROADCAST_MESSAGE.ENABLED,
183 message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
184 level: CONFIG.BROADCAST_MESSAGE.LEVEL,
185 dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
186 }
187 }
188}
189
190function getRegisteredThemes () {
191 return PluginManager.Instance.getRegisteredThemes()
192 .map(t => ({
193 name: t.name,
194 version: t.version,
195 description: t.description,
196 css: t.css,
197 clientScripts: t.clientScripts
198 }))
199}
200
201function getRegisteredPlugins () {
202 return PluginManager.Instance.getRegisteredPlugins()
203 .map(p => ({
204 name: p.name,
205 version: p.version,
206 description: p.description,
207 clientScripts: p.clientScripts
208 }))
209}
210
211// ---------------------------------------------------------------------------
212
213export {
214 getServerConfig,
215 getRegisteredThemes,
216 getRegisteredPlugins
217}
218
219// ---------------------------------------------------------------------------
220
221function getIdAndPassAuthPlugins () {
222 const result: RegisteredIdAndPassAuthConfig[] = []
223
224 for (const p of PluginManager.Instance.getIdAndPassAuths()) {
225 for (const auth of p.idAndPassAuths) {
226 result.push({
227 npmName: p.npmName,
228 name: p.name,
229 version: p.version,
230 authName: auth.authName,
231 weight: auth.getWeight()
232 })
233 }
234 }
235
236 return result
237}
238
239function getExternalAuthsPlugins () {
240 const result: RegisteredExternalAuthConfig[] = []
241
242 for (const p of PluginManager.Instance.getExternalAuths()) {
243 for (const auth of p.externalAuths) {
244 result.push({
245 npmName: p.npmName,
246 name: p.name,
247 version: p.version,
248 authName: auth.authName,
249 authDisplayName: auth.authDisplayName()
250 })
251 }
252 }
253
254 return result
255}
diff --git a/server/lib/job-queue/handlers/activitypub-follow.ts b/server/lib/job-queue/handlers/activitypub-follow.ts
index 82c95be80..f896d7af4 100644
--- a/server/lib/job-queue/handlers/activitypub-follow.ts
+++ b/server/lib/job-queue/handlers/activitypub-follow.ts
@@ -1,18 +1,17 @@
1import * as Bull from 'bull' 1import * as Bull from 'bull'
2import { logger } from '../../../helpers/logger' 2import { getLocalActorFollowActivityPubUrl } from '@server/lib/activitypub/url'
3import { REMOTE_SCHEME, WEBSERVER } from '../../../initializers/constants' 3import { ActivitypubFollowPayload } from '@shared/models'
4import { sendFollow } from '../../activitypub/send'
5import { sanitizeHost } from '../../../helpers/core-utils' 4import { sanitizeHost } from '../../../helpers/core-utils'
6import { loadActorUrlOrGetFromWebfinger } from '../../../helpers/webfinger'
7import { getOrCreateActorAndServerAndModel } from '../../activitypub/actor'
8import { retryTransactionWrapper } from '../../../helpers/database-utils' 5import { retryTransactionWrapper } from '../../../helpers/database-utils'
9import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 6import { logger } from '../../../helpers/logger'
10import { ActorModel } from '../../../models/activitypub/actor' 7import { REMOTE_SCHEME, WEBSERVER } from '../../../initializers/constants'
11import { Notifier } from '../../notifier'
12import { sequelizeTypescript } from '../../../initializers/database' 8import { sequelizeTypescript } from '../../../initializers/database'
9import { ActorModel } from '../../../models/actor/actor'
10import { ActorFollowModel } from '../../../models/actor/actor-follow'
13import { MActor, MActorFollowActors, MActorFull } from '../../../types/models' 11import { MActor, MActorFollowActors, MActorFull } from '../../../types/models'
14import { ActivitypubFollowPayload } from '@shared/models' 12import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../../activitypub/actors'
15import { getLocalActorFollowActivityPubUrl } from '@server/lib/activitypub/url' 13import { sendFollow } from '../../activitypub/send'
14import { Notifier } from '../../notifier'
16 15
17async function processActivityPubFollow (job: Bull.Job) { 16async function processActivityPubFollow (job: Bull.Job) {
18 const payload = job.data as ActivitypubFollowPayload 17 const payload = job.data as ActivitypubFollowPayload
@@ -26,7 +25,7 @@ async function processActivityPubFollow (job: Bull.Job) {
26 } else { 25 } else {
27 const sanitizedHost = sanitizeHost(host, REMOTE_SCHEME.HTTP) 26 const sanitizedHost = sanitizeHost(host, REMOTE_SCHEME.HTTP)
28 const actorUrl = await loadActorUrlOrGetFromWebfinger(payload.name + '@' + sanitizedHost) 27 const actorUrl = await loadActorUrlOrGetFromWebfinger(payload.name + '@' + sanitizedHost)
29 targetActor = await getOrCreateActorAndServerAndModel(actorUrl, 'all') 28 targetActor = await getOrCreateAPActor(actorUrl, 'all')
30 } 29 }
31 30
32 if (payload.assertIsChannel && !targetActor.VideoChannel) { 31 if (payload.assertIsChannel && !targetActor.VideoChannel) {
diff --git a/server/lib/job-queue/handlers/activitypub-http-broadcast.ts b/server/lib/job-queue/handlers/activitypub-http-broadcast.ts
index c69ff9e83..d4b328635 100644
--- a/server/lib/job-queue/handlers/activitypub-http-broadcast.ts
+++ b/server/lib/job-queue/handlers/activitypub-http-broadcast.ts
@@ -3,7 +3,7 @@ import * as Bull from 'bull'
3import { ActivitypubHttpBroadcastPayload } from '@shared/models' 3import { ActivitypubHttpBroadcastPayload } from '@shared/models'
4import { logger } from '../../../helpers/logger' 4import { logger } from '../../../helpers/logger'
5import { doRequest } from '../../../helpers/requests' 5import { doRequest } from '../../../helpers/requests'
6import { BROADCAST_CONCURRENCY, REQUEST_TIMEOUT } from '../../../initializers/constants' 6import { BROADCAST_CONCURRENCY } from '../../../initializers/constants'
7import { ActorFollowScoreCache } from '../../files-cache' 7import { ActorFollowScoreCache } from '../../files-cache'
8import { buildGlobalHeaders, buildSignedRequestOptions, computeBody } from './utils/activitypub-http-utils' 8import { buildGlobalHeaders, buildSignedRequestOptions, computeBody } from './utils/activitypub-http-utils'
9 9
@@ -19,7 +19,6 @@ async function processActivityPubHttpBroadcast (job: Bull.Job) {
19 method: 'POST' as 'POST', 19 method: 'POST' as 'POST',
20 json: body, 20 json: body,
21 httpSignature: httpSignatureOptions, 21 httpSignature: httpSignatureOptions,
22 timeout: REQUEST_TIMEOUT,
23 headers: buildGlobalHeaders(body) 22 headers: buildGlobalHeaders(body)
24 } 23 }
25 24
diff --git a/server/lib/job-queue/handlers/activitypub-http-fetcher.ts b/server/lib/job-queue/handlers/activitypub-http-fetcher.ts
index e210ac3ef..ab9675cae 100644
--- a/server/lib/job-queue/handlers/activitypub-http-fetcher.ts
+++ b/server/lib/job-queue/handlers/activitypub-http-fetcher.ts
@@ -1,14 +1,13 @@
1import * as Bull from 'bull' 1import * as Bull from 'bull'
2import { ActivitypubHttpFetcherPayload, FetchType } from '@shared/models' 2import { ActivitypubHttpFetcherPayload, FetchType } from '@shared/models'
3import { logger } from '../../../helpers/logger' 3import { logger } from '../../../helpers/logger'
4import { AccountModel } from '../../../models/account/account'
5import { AccountVideoRateModel } from '../../../models/account/account-video-rate' 4import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
6import { VideoModel } from '../../../models/video/video' 5import { VideoModel } from '../../../models/video/video'
7import { VideoCommentModel } from '../../../models/video/video-comment' 6import { VideoCommentModel } from '../../../models/video/video-comment'
8import { VideoShareModel } from '../../../models/video/video-share' 7import { VideoShareModel } from '../../../models/video/video-share'
9import { MAccountDefault, MVideoFullLight } from '../../../types/models' 8import { MVideoFullLight } from '../../../types/models'
10import { crawlCollectionPage } from '../../activitypub/crawl' 9import { crawlCollectionPage } from '../../activitypub/crawl'
11import { createAccountPlaylists } from '../../activitypub/playlist' 10import { createAccountPlaylists } from '../../activitypub/playlists'
12import { processActivities } from '../../activitypub/process' 11import { processActivities } from '../../activitypub/process'
13import { addVideoShares } from '../../activitypub/share' 12import { addVideoShares } from '../../activitypub/share'
14import { addVideoComments } from '../../activitypub/video-comments' 13import { addVideoComments } from '../../activitypub/video-comments'
@@ -22,16 +21,13 @@ async function processActivityPubHttpFetcher (job: Bull.Job) {
22 let video: MVideoFullLight 21 let video: MVideoFullLight
23 if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId) 22 if (payload.videoId) video = await VideoModel.loadAndPopulateAccountAndServerAndTags(payload.videoId)
24 23
25 let account: MAccountDefault
26 if (payload.accountId) account = await AccountModel.load(payload.accountId)
27
28 const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise<any> } = { 24 const fetcherType: { [ id in FetchType ]: (items: any[]) => Promise<any> } = {
29 'activity': items => processActivities(items, { outboxUrl: payload.uri, fromFetch: true }), 25 'activity': items => processActivities(items, { outboxUrl: payload.uri, fromFetch: true }),
30 'video-likes': items => createRates(items, video, 'like'), 26 'video-likes': items => createRates(items, video, 'like'),
31 'video-dislikes': items => createRates(items, video, 'dislike'), 27 'video-dislikes': items => createRates(items, video, 'dislike'),
32 'video-shares': items => addVideoShares(items, video), 28 'video-shares': items => addVideoShares(items, video),
33 'video-comments': items => addVideoComments(items), 29 'video-comments': items => addVideoComments(items),
34 'account-playlists': items => createAccountPlaylists(items, account) 30 'account-playlists': items => createAccountPlaylists(items)
35 } 31 }
36 32
37 const cleanerType: { [ id in FetchType ]?: (crawlStartDate: Date) => Promise<any> } = { 33 const cleanerType: { [ id in FetchType ]?: (crawlStartDate: Date) => Promise<any> } = {
diff --git a/server/lib/job-queue/handlers/activitypub-http-unicast.ts b/server/lib/job-queue/handlers/activitypub-http-unicast.ts
index 585dad671..9e561c6b7 100644
--- a/server/lib/job-queue/handlers/activitypub-http-unicast.ts
+++ b/server/lib/job-queue/handlers/activitypub-http-unicast.ts
@@ -2,7 +2,6 @@ import * as Bull from 'bull'
2import { ActivitypubHttpUnicastPayload } from '@shared/models' 2import { ActivitypubHttpUnicastPayload } from '@shared/models'
3import { logger } from '../../../helpers/logger' 3import { logger } from '../../../helpers/logger'
4import { doRequest } from '../../../helpers/requests' 4import { doRequest } from '../../../helpers/requests'
5import { REQUEST_TIMEOUT } from '../../../initializers/constants'
6import { ActorFollowScoreCache } from '../../files-cache' 5import { ActorFollowScoreCache } from '../../files-cache'
7import { buildGlobalHeaders, buildSignedRequestOptions, computeBody } from './utils/activitypub-http-utils' 6import { buildGlobalHeaders, buildSignedRequestOptions, computeBody } from './utils/activitypub-http-utils'
8 7
@@ -19,7 +18,6 @@ async function processActivityPubHttpUnicast (job: Bull.Job) {
19 method: 'POST' as 'POST', 18 method: 'POST' as 'POST',
20 json: body, 19 json: body,
21 httpSignature: httpSignatureOptions, 20 httpSignature: httpSignatureOptions,
22 timeout: REQUEST_TIMEOUT,
23 headers: buildGlobalHeaders(body) 21 headers: buildGlobalHeaders(body)
24 } 22 }
25 23
diff --git a/server/lib/job-queue/handlers/activitypub-refresher.ts b/server/lib/job-queue/handlers/activitypub-refresher.ts
index 666e56868..d97e50ebc 100644
--- a/server/lib/job-queue/handlers/activitypub-refresher.ts
+++ b/server/lib/job-queue/handlers/activitypub-refresher.ts
@@ -1,12 +1,12 @@
1import * as Bull from 'bull' 1import * as Bull from 'bull'
2import { refreshVideoPlaylistIfNeeded } from '@server/lib/activitypub/playlists'
3import { refreshVideoIfNeeded } from '@server/lib/activitypub/videos'
4import { loadVideoByUrl } from '@server/lib/model-loaders'
5import { RefreshPayload } from '@shared/models'
2import { logger } from '../../../helpers/logger' 6import { logger } from '../../../helpers/logger'
3import { fetchVideoByUrl } from '../../../helpers/video' 7import { ActorModel } from '../../../models/actor/actor'
4import { refreshActorIfNeeded } from '../../activitypub/actor'
5import { refreshVideoIfNeeded } from '../../activitypub/videos'
6import { ActorModel } from '../../../models/activitypub/actor'
7import { VideoPlaylistModel } from '../../../models/video/video-playlist' 8import { VideoPlaylistModel } from '../../../models/video/video-playlist'
8import { RefreshPayload } from '@shared/models' 9import { refreshActorIfNeeded } from '../../activitypub/actors'
9import { refreshVideoPlaylistIfNeeded } from '@server/lib/activitypub/playlist'
10 10
11async function refreshAPObject (job: Bull.Job) { 11async function refreshAPObject (job: Bull.Job) {
12 const payload = job.data as RefreshPayload 12 const payload = job.data as RefreshPayload
@@ -30,7 +30,7 @@ async function refreshVideo (videoUrl: string) {
30 const fetchType = 'all' as 'all' 30 const fetchType = 'all' as 'all'
31 const syncParam = { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true } 31 const syncParam = { likes: true, dislikes: true, shares: true, comments: true, thumbnail: true }
32 32
33 const videoFromDatabase = await fetchVideoByUrl(videoUrl, fetchType) 33 const videoFromDatabase = await loadVideoByUrl(videoUrl, fetchType)
34 if (videoFromDatabase) { 34 if (videoFromDatabase) {
35 const refreshOptions = { 35 const refreshOptions = {
36 video: videoFromDatabase, 36 video: videoFromDatabase,
@@ -47,7 +47,7 @@ async function refreshActor (actorUrl: string) {
47 const actor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorUrl) 47 const actor = await ActorModel.loadByUrlAndPopulateAccountAndChannel(actorUrl)
48 48
49 if (actor) { 49 if (actor) {
50 await refreshActorIfNeeded(actor, fetchType) 50 await refreshActorIfNeeded({ actor, fetchedType: fetchType })
51 } 51 }
52} 52}
53 53
diff --git a/server/lib/job-queue/handlers/actor-keys.ts b/server/lib/job-queue/handlers/actor-keys.ts
index 125307843..60ac61afd 100644
--- a/server/lib/job-queue/handlers/actor-keys.ts
+++ b/server/lib/job-queue/handlers/actor-keys.ts
@@ -1,6 +1,6 @@
1import * as Bull from 'bull' 1import * as Bull from 'bull'
2import { generateAndSaveActorKeys } from '@server/lib/activitypub/actor' 2import { generateAndSaveActorKeys } from '@server/lib/activitypub/actors'
3import { ActorModel } from '@server/models/activitypub/actor' 3import { ActorModel } from '@server/models/actor/actor'
4import { ActorKeysPayload } from '@shared/models' 4import { ActorKeysPayload } from '@shared/models'
5import { logger } from '../../../helpers/logger' 5import { logger } from '../../../helpers/logger'
6 6
diff --git a/server/lib/job-queue/handlers/utils/activitypub-http-utils.ts b/server/lib/job-queue/handlers/utils/activitypub-http-utils.ts
index e8a91450d..37e7c1fad 100644
--- a/server/lib/job-queue/handlers/utils/activitypub-http-utils.ts
+++ b/server/lib/job-queue/handlers/utils/activitypub-http-utils.ts
@@ -1,10 +1,10 @@
1import { buildDigest } from '@server/helpers/peertube-crypto'
2import { getServerActor } from '@server/models/application/application'
3import { ContextType } from '@shared/models/activitypub/context'
1import { buildSignedActivity } from '../../../../helpers/activitypub' 4import { buildSignedActivity } from '../../../../helpers/activitypub'
2import { ActorModel } from '../../../../models/activitypub/actor'
3import { ACTIVITY_PUB, HTTP_SIGNATURE } from '../../../../initializers/constants' 5import { ACTIVITY_PUB, HTTP_SIGNATURE } from '../../../../initializers/constants'
6import { ActorModel } from '../../../../models/actor/actor'
4import { MActor } from '../../../../types/models' 7import { MActor } from '../../../../types/models'
5import { getServerActor } from '@server/models/application/application'
6import { buildDigest } from '@server/helpers/peertube-crypto'
7import { ContextType } from '@shared/models/activitypub/context'
8 8
9type Payload <T> = { body: T, contextType?: ContextType, signatureActorId?: number } 9type Payload <T> = { body: T, contextType?: ContextType, signatureActorId?: number }
10 10
diff --git a/server/lib/job-queue/handlers/video-file-import.ts b/server/lib/job-queue/handlers/video-file-import.ts
index 71f2cafcd..187cb652e 100644
--- a/server/lib/job-queue/handlers/video-file-import.ts
+++ b/server/lib/job-queue/handlers/video-file-import.ts
@@ -1,9 +1,9 @@
1import * as Bull from 'bull' 1import * as Bull from 'bull'
2import { copy, stat } from 'fs-extra' 2import { copy, stat } from 'fs-extra'
3import { extname } from 'path' 3import { getLowercaseExtension } from '@server/helpers/core-utils'
4import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' 4import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
5import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths' 5import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
6import { UserModel } from '@server/models/account/user' 6import { UserModel } from '@server/models/user/user'
7import { MVideoFullLight } from '@server/types/models' 7import { MVideoFullLight } from '@server/types/models'
8import { VideoFileImportPayload } from '@shared/models' 8import { VideoFileImportPayload } from '@shared/models'
9import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils' 9import { getVideoFileFPS, getVideoFileResolution } from '../../../helpers/ffprobe-utils'
@@ -55,7 +55,7 @@ async function updateVideoFile (video: MVideoFullLight, inputFilePath: string) {
55 const { size } = await stat(inputFilePath) 55 const { size } = await stat(inputFilePath)
56 const fps = await getVideoFileFPS(inputFilePath) 56 const fps = await getVideoFileFPS(inputFilePath)
57 57
58 const fileExt = extname(inputFilePath) 58 const fileExt = getLowercaseExtension(inputFilePath)
59 59
60 const currentVideoFile = video.VideoFiles.find(videoFile => videoFile.resolution === videoFileResolution) 60 const currentVideoFile = video.VideoFiles.find(videoFile => videoFile.resolution === videoFileResolution)
61 61
diff --git a/server/lib/job-queue/handlers/video-import.ts b/server/lib/job-queue/handlers/video-import.ts
index ed2c5eac0..55498003d 100644
--- a/server/lib/job-queue/handlers/video-import.ts
+++ b/server/lib/job-queue/handlers/video-import.ts
@@ -1,9 +1,11 @@
1import * as Bull from 'bull' 1import * as Bull from 'bull'
2import { move, remove, stat } from 'fs-extra' 2import { move, remove, stat } from 'fs-extra'
3import { extname } from 'path' 3import { getLowercaseExtension } from '@server/helpers/core-utils'
4import { retryTransactionWrapper } from '@server/helpers/database-utils' 4import { retryTransactionWrapper } from '@server/helpers/database-utils'
5import { YoutubeDL } from '@server/helpers/youtube-dl'
5import { isPostImportVideoAccepted } from '@server/lib/moderation' 6import { isPostImportVideoAccepted } from '@server/lib/moderation'
6import { Hooks } from '@server/lib/plugins/hooks' 7import { Hooks } from '@server/lib/plugins/hooks'
8import { ServerConfigManager } from '@server/lib/server-config-manager'
7import { isAbleToUploadVideo } from '@server/lib/user' 9import { isAbleToUploadVideo } from '@server/lib/user'
8import { addOptimizeOrMergeAudioJob } from '@server/lib/video' 10import { addOptimizeOrMergeAudioJob } from '@server/lib/video'
9import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths' 11import { generateVideoFilename, getVideoFilePath } from '@server/lib/video-paths'
@@ -23,7 +25,6 @@ import { getDurationFromVideoFile, getVideoFileFPS, getVideoFileResolution } fro
23import { logger } from '../../../helpers/logger' 25import { logger } from '../../../helpers/logger'
24import { getSecureTorrentName } from '../../../helpers/utils' 26import { getSecureTorrentName } from '../../../helpers/utils'
25import { createTorrentAndSetInfoHash, downloadWebTorrentVideo } from '../../../helpers/webtorrent' 27import { createTorrentAndSetInfoHash, downloadWebTorrentVideo } from '../../../helpers/webtorrent'
26import { downloadYoutubeDLVideo } from '../../../helpers/youtube-dl'
27import { CONFIG } from '../../../initializers/config' 28import { CONFIG } from '../../../initializers/config'
28import { VIDEO_IMPORT_TIMEOUT } from '../../../initializers/constants' 29import { VIDEO_IMPORT_TIMEOUT } from '../../../initializers/constants'
29import { sequelizeTypescript } from '../../../initializers/database' 30import { sequelizeTypescript } from '../../../initializers/database'
@@ -75,8 +76,10 @@ async function processYoutubeDLImport (job: Bull.Job, payload: VideoImportYoutub
75 videoImportId: videoImport.id 76 videoImportId: videoImport.id
76 } 77 }
77 78
79 const youtubeDL = new YoutubeDL(videoImport.targetUrl, ServerConfigManager.Instance.getEnabledResolutions('vod'))
80
78 return processFile( 81 return processFile(
79 () => downloadYoutubeDLVideo(videoImport.targetUrl, payload.fileExt, VIDEO_IMPORT_TIMEOUT), 82 () => youtubeDL.downloadYoutubeDLVideo(payload.fileExt, VIDEO_IMPORT_TIMEOUT),
80 videoImport, 83 videoImport,
81 options 84 options
82 ) 85 )
@@ -116,7 +119,7 @@ async function processFile (downloader: () => Promise<string>, videoImport: MVid
116 const duration = await getDurationFromVideoFile(tempVideoPath) 119 const duration = await getDurationFromVideoFile(tempVideoPath)
117 120
118 // Prepare video file object for creation in database 121 // Prepare video file object for creation in database
119 const fileExt = extname(tempVideoPath) 122 const fileExt = getLowercaseExtension(tempVideoPath)
120 const videoFileData = { 123 const videoFileData = {
121 extname: fileExt, 124 extname: fileExt,
122 resolution: videoFileResolution, 125 resolution: videoFileResolution,
diff --git a/server/lib/job-queue/handlers/video-live-ending.ts b/server/lib/job-queue/handlers/video-live-ending.ts
index d57202ca5..9eba41bf8 100644
--- a/server/lib/job-queue/handlers/video-live-ending.ts
+++ b/server/lib/job-queue/handlers/video-live-ending.ts
@@ -3,16 +3,16 @@ import { pathExists, readdir, remove } from 'fs-extra'
3import { join } from 'path' 3import { join } from 'path'
4import { ffprobePromise, getAudioStream, getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils' 4import { ffprobePromise, getAudioStream, getDurationFromVideoFile, getVideoFileResolution } from '@server/helpers/ffprobe-utils'
5import { VIDEO_LIVE } from '@server/initializers/constants' 5import { VIDEO_LIVE } from '@server/initializers/constants'
6import { LiveManager } from '@server/lib/live-manager' 6import { buildConcatenatedName, cleanupLive, LiveSegmentShaStore } from '@server/lib/live'
7import { generateVideoMiniature } from '@server/lib/thumbnail' 7import { generateVideoMiniature } from '@server/lib/thumbnail'
8import { generateHlsPlaylistResolutionFromTS } from '@server/lib/transcoding/video-transcoding'
8import { publishAndFederateIfNeeded } from '@server/lib/video' 9import { publishAndFederateIfNeeded } from '@server/lib/video'
9import { getHLSDirectory } from '@server/lib/video-paths' 10import { getHLSDirectory } from '@server/lib/video-paths'
10import { generateHlsPlaylistResolutionFromTS } from '@server/lib/video-transcoding'
11import { VideoModel } from '@server/models/video/video' 11import { VideoModel } from '@server/models/video/video'
12import { VideoFileModel } from '@server/models/video/video-file' 12import { VideoFileModel } from '@server/models/video/video-file'
13import { VideoLiveModel } from '@server/models/video/video-live' 13import { VideoLiveModel } from '@server/models/video/video-live'
14import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist' 14import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
15import { MStreamingPlaylist, MVideo, MVideoLive } from '@server/types/models' 15import { MVideo, MVideoLive } from '@server/types/models'
16import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models' 16import { ThumbnailType, VideoLiveEndingPayload, VideoState } from '@shared/models'
17import { logger } from '../../../helpers/logger' 17import { logger } from '../../../helpers/logger'
18 18
@@ -37,7 +37,7 @@ async function processVideoLiveEnding (job: Bull.Job) {
37 return 37 return
38 } 38 }
39 39
40 LiveManager.Instance.cleanupShaSegments(video.uuid) 40 LiveSegmentShaStore.Instance.cleanupShaSegments(video.uuid)
41 41
42 if (live.saveReplay !== true) { 42 if (live.saveReplay !== true) {
43 return cleanupLive(video, streamingPlaylist) 43 return cleanupLive(video, streamingPlaylist)
@@ -46,19 +46,10 @@ async function processVideoLiveEnding (job: Bull.Job) {
46 return saveLive(video, live) 46 return saveLive(video, live)
47} 47}
48 48
49async function cleanupLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) {
50 const hlsDirectory = getHLSDirectory(video)
51
52 await remove(hlsDirectory)
53
54 await streamingPlaylist.destroy()
55}
56
57// --------------------------------------------------------------------------- 49// ---------------------------------------------------------------------------
58 50
59export { 51export {
60 processVideoLiveEnding, 52 processVideoLiveEnding
61 cleanupLive
62} 53}
63 54
64// --------------------------------------------------------------------------- 55// ---------------------------------------------------------------------------
@@ -94,7 +85,7 @@ async function saveLive (video: MVideo, live: MVideoLive) {
94 let durationDone = false 85 let durationDone = false
95 86
96 for (const playlistFile of playlistFiles) { 87 for (const playlistFile of playlistFiles) {
97 const concatenatedTsFile = LiveManager.Instance.buildConcatenatedName(playlistFile) 88 const concatenatedTsFile = buildConcatenatedName(playlistFile)
98 const concatenatedTsFilePath = join(replayDirectory, concatenatedTsFile) 89 const concatenatedTsFilePath = join(replayDirectory, concatenatedTsFile)
99 90
100 const probe = await ffprobePromise(concatenatedTsFilePath) 91 const probe = await ffprobePromise(concatenatedTsFilePath)
diff --git a/server/lib/job-queue/handlers/video-transcoding.ts b/server/lib/job-queue/handlers/video-transcoding.ts
index 010b95b05..f5ba6f435 100644
--- a/server/lib/job-queue/handlers/video-transcoding.ts
+++ b/server/lib/job-queue/handlers/video-transcoding.ts
@@ -2,7 +2,7 @@ import * as Bull from 'bull'
2import { TranscodeOptionsType } from '@server/helpers/ffmpeg-utils' 2import { TranscodeOptionsType } from '@server/helpers/ffmpeg-utils'
3import { getTranscodingJobPriority, publishAndFederateIfNeeded } from '@server/lib/video' 3import { getTranscodingJobPriority, publishAndFederateIfNeeded } from '@server/lib/video'
4import { getVideoFilePath } from '@server/lib/video-paths' 4import { getVideoFilePath } from '@server/lib/video-paths'
5import { UserModel } from '@server/models/account/user' 5import { UserModel } from '@server/models/user/user'
6import { MUser, MUserId, MVideoFullLight, MVideoUUID, MVideoWithFile } from '@server/types/models' 6import { MUser, MUserId, MVideoFullLight, MVideoUUID, MVideoWithFile } from '@server/types/models'
7import { 7import {
8 HLSTranscodingPayload, 8 HLSTranscodingPayload,
@@ -15,7 +15,6 @@ import { retryTransactionWrapper } from '../../../helpers/database-utils'
15import { computeResolutionsToTranscode } from '../../../helpers/ffprobe-utils' 15import { computeResolutionsToTranscode } from '../../../helpers/ffprobe-utils'
16import { logger } from '../../../helpers/logger' 16import { logger } from '../../../helpers/logger'
17import { CONFIG } from '../../../initializers/config' 17import { CONFIG } from '../../../initializers/config'
18import { sequelizeTypescript } from '../../../initializers/database'
19import { VideoModel } from '../../../models/video/video' 18import { VideoModel } from '../../../models/video/video'
20import { federateVideoIfNeeded } from '../../activitypub/videos' 19import { federateVideoIfNeeded } from '../../activitypub/videos'
21import { Notifier } from '../../notifier' 20import { Notifier } from '../../notifier'
@@ -24,7 +23,7 @@ import {
24 mergeAudioVideofile, 23 mergeAudioVideofile,
25 optimizeOriginalVideofile, 24 optimizeOriginalVideofile,
26 transcodeNewWebTorrentResolution 25 transcodeNewWebTorrentResolution
27} from '../../video-transcoding' 26} from '../../transcoding/video-transcoding'
28import { JobQueue } from '../job-queue' 27import { JobQueue } from '../job-queue'
29 28
30type HandlerFunction = (job: Bull.Job, payload: VideoTranscodingPayload, video: MVideoFullLight, user: MUser) => Promise<any> 29type HandlerFunction = (job: Bull.Job, payload: VideoTranscodingPayload, video: MVideoFullLight, user: MUser) => Promise<any>
@@ -151,35 +150,31 @@ async function onVideoFileOptimizer (
151 // Outside the transaction (IO on disk) 150 // Outside the transaction (IO on disk)
152 const { videoFileResolution, isPortraitMode } = await videoArg.getMaxQualityResolution() 151 const { videoFileResolution, isPortraitMode } = await videoArg.getMaxQualityResolution()
153 152
154 const { videoDatabase, videoPublished } = await sequelizeTypescript.transaction(async t => { 153 // Maybe the video changed in database, refresh it
155 // Maybe the video changed in database, refresh it 154 const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoArg.uuid)
156 const videoDatabase = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoArg.uuid, t) 155 // Video does not exist anymore
157 // Video does not exist anymore 156 if (!videoDatabase) return undefined
158 if (!videoDatabase) return undefined
159
160 let videoPublished = false
161
162 // Generate HLS version of the original file
163 const originalFileHLSPayload = Object.assign({}, payload, {
164 isPortraitMode,
165 resolution: videoDatabase.getMaxQualityFile().resolution,
166 // If we quick transcoded original file, force transcoding for HLS to avoid some weird playback issues
167 copyCodecs: transcodeType !== 'quick-transcode',
168 isMaxQuality: true
169 })
170 const hasHls = await createHlsJobIfEnabled(user, originalFileHLSPayload)
171
172 const hasNewResolutions = await createLowerResolutionsJobs(videoDatabase, user, videoFileResolution, isPortraitMode, 'webtorrent')
173
174 if (!hasHls && !hasNewResolutions) {
175 // No transcoding to do, it's now published
176 videoPublished = await videoDatabase.publishIfNeededAndSave(t)
177 }
178 157
179 await federateVideoIfNeeded(videoDatabase, payload.isNewVideo, t) 158 let videoPublished = false
180 159
181 return { videoDatabase, videoPublished } 160 // Generate HLS version of the original file
161 const originalFileHLSPayload = Object.assign({}, payload, {
162 isPortraitMode,
163 resolution: videoDatabase.getMaxQualityFile().resolution,
164 // If we quick transcoded original file, force transcoding for HLS to avoid some weird playback issues
165 copyCodecs: transcodeType !== 'quick-transcode',
166 isMaxQuality: true
182 }) 167 })
168 const hasHls = await createHlsJobIfEnabled(user, originalFileHLSPayload)
169
170 const hasNewResolutions = await createLowerResolutionsJobs(videoDatabase, user, videoFileResolution, isPortraitMode, 'webtorrent')
171
172 if (!hasHls && !hasNewResolutions) {
173 // No transcoding to do, it's now published
174 videoPublished = await videoDatabase.publishIfNeededAndSave(undefined)
175 }
176
177 await federateVideoIfNeeded(videoDatabase, payload.isNewVideo)
183 178
184 if (payload.isNewVideo) Notifier.Instance.notifyOnNewVideoIfNeeded(videoDatabase) 179 if (payload.isNewVideo) Notifier.Instance.notifyOnNewVideoIfNeeded(videoDatabase)
185 if (videoPublished) Notifier.Instance.notifyOnVideoPublishedAfterTranscoding(videoDatabase) 180 if (videoPublished) Notifier.Instance.notifyOnVideoPublishedAfterTranscoding(videoDatabase)
diff --git a/server/lib/job-queue/handlers/video-views.ts b/server/lib/job-queue/handlers/video-views.ts
index 897235ec0..86d0a271f 100644
--- a/server/lib/job-queue/handlers/video-views.ts
+++ b/server/lib/job-queue/handlers/video-views.ts
@@ -36,8 +36,8 @@ async function processVideosViews () {
36 } 36 }
37 37
38 await VideoViewModel.create({ 38 await VideoViewModel.create({
39 startDate, 39 startDate: new Date(startDate),
40 endDate, 40 endDate: new Date(endDate),
41 views, 41 views,
42 videoId 42 videoId
43 }) 43 })
diff --git a/server/lib/live-manager.ts b/server/lib/live-manager.ts
deleted file mode 100644
index 66b5d119b..000000000
--- a/server/lib/live-manager.ts
+++ /dev/null
@@ -1,621 +0,0 @@
1
2import * as Bluebird from 'bluebird'
3import * as chokidar from 'chokidar'
4import { FfmpegCommand } from 'fluent-ffmpeg'
5import { appendFile, ensureDir, readFile, stat } from 'fs-extra'
6import { createServer, Server } from 'net'
7import { basename, join } from 'path'
8import { isTestInstance } from '@server/helpers/core-utils'
9import { getLiveMuxingCommand, getLiveTranscodingCommand } from '@server/helpers/ffmpeg-utils'
10import { computeResolutionsToTranscode, getVideoFileFPS, getVideoFileResolution } from '@server/helpers/ffprobe-utils'
11import { logger } from '@server/helpers/logger'
12import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
13import { MEMOIZE_TTL, P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE, VIEW_LIFETIME, WEBSERVER } from '@server/initializers/constants'
14import { UserModel } from '@server/models/account/user'
15import { VideoModel } from '@server/models/video/video'
16import { VideoFileModel } from '@server/models/video/video-file'
17import { VideoLiveModel } from '@server/models/video/video-live'
18import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
19import { MStreamingPlaylist, MStreamingPlaylistVideo, MUserId, MVideoLive, MVideoLiveVideo } from '@server/types/models'
20import { VideoState, VideoStreamingPlaylistType } from '@shared/models'
21import { federateVideoIfNeeded } from './activitypub/videos'
22import { buildSha256Segment } from './hls'
23import { JobQueue } from './job-queue'
24import { cleanupLive } from './job-queue/handlers/video-live-ending'
25import { PeerTubeSocket } from './peertube-socket'
26import { isAbleToUploadVideo } from './user'
27import { getHLSDirectory } from './video-paths'
28import { VideoTranscodingProfilesManager } from './video-transcoding-profiles'
29
30import memoizee = require('memoizee')
31const NodeRtmpSession = require('node-media-server/node_rtmp_session')
32const context = require('node-media-server/node_core_ctx')
33const nodeMediaServerLogger = require('node-media-server/node_core_logger')
34
35// Disable node media server logs
36nodeMediaServerLogger.setLogType(0)
37
38const config = {
39 rtmp: {
40 port: CONFIG.LIVE.RTMP.PORT,
41 chunk_size: VIDEO_LIVE.RTMP.CHUNK_SIZE,
42 gop_cache: VIDEO_LIVE.RTMP.GOP_CACHE,
43 ping: VIDEO_LIVE.RTMP.PING,
44 ping_timeout: VIDEO_LIVE.RTMP.PING_TIMEOUT
45 },
46 transcoding: {
47 ffmpeg: 'ffmpeg'
48 }
49}
50
51class LiveManager {
52
53 private static instance: LiveManager
54
55 private readonly transSessions = new Map<string, FfmpegCommand>()
56 private readonly videoSessions = new Map<number, string>()
57 // Values are Date().getTime()
58 private readonly watchersPerVideo = new Map<number, number[]>()
59 private readonly segmentsSha256 = new Map<string, Map<string, string>>()
60 private readonly livesPerUser = new Map<number, { liveId: number, videoId: number, size: number }[]>()
61
62 private readonly isAbleToUploadVideoWithCache = memoizee((userId: number) => {
63 return isAbleToUploadVideo(userId, 1000)
64 }, { maxAge: MEMOIZE_TTL.LIVE_ABLE_TO_UPLOAD })
65
66 private readonly hasClientSocketsInBadHealthWithCache = memoizee((sessionId: string) => {
67 return this.hasClientSocketsInBadHealth(sessionId)
68 }, { maxAge: MEMOIZE_TTL.LIVE_CHECK_SOCKET_HEALTH })
69
70 private rtmpServer: Server
71
72 private constructor () {
73 }
74
75 init () {
76 const events = this.getContext().nodeEvent
77 events.on('postPublish', (sessionId: string, streamPath: string) => {
78 logger.debug('RTMP received stream', { id: sessionId, streamPath })
79
80 const splittedPath = streamPath.split('/')
81 if (splittedPath.length !== 3 || splittedPath[1] !== VIDEO_LIVE.RTMP.BASE_PATH) {
82 logger.warn('Live path is incorrect.', { streamPath })
83 return this.abortSession(sessionId)
84 }
85
86 this.handleSession(sessionId, streamPath, splittedPath[2])
87 .catch(err => logger.error('Cannot handle sessions.', { err }))
88 })
89
90 events.on('donePublish', sessionId => {
91 logger.info('Live session ended.', { sessionId })
92 })
93
94 registerConfigChangedHandler(() => {
95 if (!this.rtmpServer && CONFIG.LIVE.ENABLED === true) {
96 this.run()
97 return
98 }
99
100 if (this.rtmpServer && CONFIG.LIVE.ENABLED === false) {
101 this.stop()
102 }
103 })
104
105 // Cleanup broken lives, that were terminated by a server restart for example
106 this.handleBrokenLives()
107 .catch(err => logger.error('Cannot handle broken lives.', { err }))
108
109 setInterval(() => this.updateLiveViews(), VIEW_LIFETIME.LIVE)
110 }
111
112 run () {
113 logger.info('Running RTMP server on port %d', config.rtmp.port)
114
115 this.rtmpServer = createServer(socket => {
116 const session = new NodeRtmpSession(config, socket)
117
118 session.run()
119 })
120
121 this.rtmpServer.on('error', err => {
122 logger.error('Cannot run RTMP server.', { err })
123 })
124
125 this.rtmpServer.listen(CONFIG.LIVE.RTMP.PORT)
126 }
127
128 stop () {
129 logger.info('Stopping RTMP server.')
130
131 this.rtmpServer.close()
132 this.rtmpServer = undefined
133
134 // Sessions is an object
135 this.getContext().sessions.forEach((session: any) => {
136 if (session instanceof NodeRtmpSession) {
137 session.stop()
138 }
139 })
140 }
141
142 isRunning () {
143 return !!this.rtmpServer
144 }
145
146 getSegmentsSha256 (videoUUID: string) {
147 return this.segmentsSha256.get(videoUUID)
148 }
149
150 stopSessionOf (videoId: number) {
151 const sessionId = this.videoSessions.get(videoId)
152 if (!sessionId) return
153
154 this.videoSessions.delete(videoId)
155 this.abortSession(sessionId)
156 }
157
158 getLiveQuotaUsedByUser (userId: number) {
159 const currentLives = this.livesPerUser.get(userId)
160 if (!currentLives) return 0
161
162 return currentLives.reduce((sum, obj) => sum + obj.size, 0)
163 }
164
165 addViewTo (videoId: number) {
166 if (this.videoSessions.has(videoId) === false) return
167
168 let watchers = this.watchersPerVideo.get(videoId)
169
170 if (!watchers) {
171 watchers = []
172 this.watchersPerVideo.set(videoId, watchers)
173 }
174
175 watchers.push(new Date().getTime())
176 }
177
178 cleanupShaSegments (videoUUID: string) {
179 this.segmentsSha256.delete(videoUUID)
180 }
181
182 addSegmentToReplay (hlsVideoPath: string, segmentPath: string) {
183 const segmentName = basename(segmentPath)
184 const dest = join(hlsVideoPath, VIDEO_LIVE.REPLAY_DIRECTORY, this.buildConcatenatedName(segmentName))
185
186 return readFile(segmentPath)
187 .then(data => appendFile(dest, data))
188 .catch(err => logger.error('Cannot copy segment %s to repay directory.', segmentPath, { err }))
189 }
190
191 buildConcatenatedName (segmentOrPlaylistPath: string) {
192 const num = basename(segmentOrPlaylistPath).match(/^(\d+)(-|\.)/)
193
194 return 'concat-' + num[1] + '.ts'
195 }
196
197 private processSegments (hlsVideoPath: string, videoUUID: string, videoLive: MVideoLive, segmentPaths: string[]) {
198 Bluebird.mapSeries(segmentPaths, async previousSegment => {
199 // Add sha hash of previous segments, because ffmpeg should have finished generating them
200 await this.addSegmentSha(videoUUID, previousSegment)
201
202 if (videoLive.saveReplay) {
203 await this.addSegmentToReplay(hlsVideoPath, previousSegment)
204 }
205 }).catch(err => logger.error('Cannot process segments in %s', hlsVideoPath, { err }))
206 }
207
208 private getContext () {
209 return context
210 }
211
212 private abortSession (id: string) {
213 const session = this.getContext().sessions.get(id)
214 if (session) {
215 session.stop()
216 this.getContext().sessions.delete(id)
217 }
218
219 const transSession = this.transSessions.get(id)
220 if (transSession) {
221 transSession.kill('SIGINT')
222 this.transSessions.delete(id)
223 }
224 }
225
226 private async handleSession (sessionId: string, streamPath: string, streamKey: string) {
227 const videoLive = await VideoLiveModel.loadByStreamKey(streamKey)
228 if (!videoLive) {
229 logger.warn('Unknown live video with stream key %s.', streamKey)
230 return this.abortSession(sessionId)
231 }
232
233 const video = videoLive.Video
234 if (video.isBlacklisted()) {
235 logger.warn('Video is blacklisted. Refusing stream %s.', streamKey)
236 return this.abortSession(sessionId)
237 }
238
239 // Cleanup old potential live files (could happen with a permanent live)
240 this.cleanupShaSegments(video.uuid)
241
242 const oldStreamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id)
243 if (oldStreamingPlaylist) {
244 await cleanupLive(video, oldStreamingPlaylist)
245 }
246
247 this.videoSessions.set(video.id, sessionId)
248
249 const playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid)
250
251 const session = this.getContext().sessions.get(sessionId)
252 const rtmpUrl = 'rtmp://127.0.0.1:' + config.rtmp.port + streamPath
253
254 const [ resolutionResult, fps ] = await Promise.all([
255 getVideoFileResolution(rtmpUrl),
256 getVideoFileFPS(rtmpUrl)
257 ])
258
259 const resolutionsEnabled = CONFIG.LIVE.TRANSCODING.ENABLED
260 ? computeResolutionsToTranscode(resolutionResult.videoFileResolution, 'live')
261 : []
262
263 const allResolutions = resolutionsEnabled.concat([ session.videoHeight ])
264
265 logger.info('Will mux/transcode live video of original resolution %d.', session.videoHeight, { allResolutions })
266
267 const [ videoStreamingPlaylist ] = await VideoStreamingPlaylistModel.upsert({
268 videoId: video.id,
269 playlistUrl,
270 segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid, video.isLive),
271 p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, allResolutions),
272 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
273
274 type: VideoStreamingPlaylistType.HLS
275 }, { returning: true }) as [ MStreamingPlaylist, boolean ]
276
277 return this.runMuxing({
278 sessionId,
279 videoLive,
280 playlist: Object.assign(videoStreamingPlaylist, { Video: video }),
281 rtmpUrl,
282 fps,
283 allResolutions
284 })
285 }
286
287 private async runMuxing (options: {
288 sessionId: string
289 videoLive: MVideoLiveVideo
290 playlist: MStreamingPlaylistVideo
291 rtmpUrl: string
292 fps: number
293 allResolutions: number[]
294 }) {
295 const { sessionId, videoLive, playlist, allResolutions, fps, rtmpUrl } = options
296 const startStreamDateTime = new Date().getTime()
297
298 const user = await UserModel.loadByLiveId(videoLive.id)
299 if (!this.livesPerUser.has(user.id)) {
300 this.livesPerUser.set(user.id, [])
301 }
302
303 const currentUserLive = { liveId: videoLive.id, videoId: videoLive.videoId, size: 0 }
304 const livesOfUser = this.livesPerUser.get(user.id)
305 livesOfUser.push(currentUserLive)
306
307 for (let i = 0; i < allResolutions.length; i++) {
308 const resolution = allResolutions[i]
309
310 const file = new VideoFileModel({
311 resolution,
312 size: -1,
313 extname: '.ts',
314 infoHash: null,
315 fps,
316 videoStreamingPlaylistId: playlist.id
317 })
318
319 VideoFileModel.customUpsert(file, 'streaming-playlist', null)
320 .catch(err => logger.error('Cannot create file for live streaming.', { err }))
321 }
322
323 const outPath = getHLSDirectory(videoLive.Video)
324 await ensureDir(outPath)
325
326 const replayDirectory = join(outPath, VIDEO_LIVE.REPLAY_DIRECTORY)
327
328 if (videoLive.saveReplay === true) {
329 await ensureDir(replayDirectory)
330 }
331
332 const videoUUID = videoLive.Video.uuid
333
334 const ffmpegExec = CONFIG.LIVE.TRANSCODING.ENABLED
335 ? await getLiveTranscodingCommand({
336 rtmpUrl,
337 outPath,
338 resolutions: allResolutions,
339 fps,
340 availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
341 profile: CONFIG.LIVE.TRANSCODING.PROFILE
342 })
343 : getLiveMuxingCommand(rtmpUrl, outPath)
344
345 logger.info('Running live muxing/transcoding for %s.', videoUUID)
346 this.transSessions.set(sessionId, ffmpegExec)
347
348 const tsWatcher = chokidar.watch(outPath + '/*.ts')
349
350 const segmentsToProcessPerPlaylist: { [playlistId: string]: string[] } = {}
351 const playlistIdMatcher = /^([\d+])-/
352
353 const addHandler = segmentPath => {
354 logger.debug('Live add handler of %s.', segmentPath)
355
356 const playlistId = basename(segmentPath).match(playlistIdMatcher)[0]
357
358 const segmentsToProcess = segmentsToProcessPerPlaylist[playlistId] || []
359 this.processSegments(outPath, videoUUID, videoLive, segmentsToProcess)
360
361 segmentsToProcessPerPlaylist[playlistId] = [ segmentPath ]
362
363 if (this.hasClientSocketsInBadHealthWithCache(sessionId)) {
364 logger.error(
365 'Too much data in client socket stream (ffmpeg is too slow to transcode the video).' +
366 ' Stopping session of video %s.', videoUUID)
367
368 this.stopSessionOf(videoLive.videoId)
369 return
370 }
371
372 // Duration constraint check
373 if (this.isDurationConstraintValid(startStreamDateTime) !== true) {
374 logger.info('Stopping session of %s: max duration exceeded.', videoUUID)
375
376 this.stopSessionOf(videoLive.videoId)
377 return
378 }
379
380 // Check user quota if the user enabled replay saving
381 if (videoLive.saveReplay === true) {
382 stat(segmentPath)
383 .then(segmentStat => {
384 currentUserLive.size += segmentStat.size
385 })
386 .then(() => this.isQuotaConstraintValid(user, videoLive))
387 .then(quotaValid => {
388 if (quotaValid !== true) {
389 logger.info('Stopping session of %s: user quota exceeded.', videoUUID)
390
391 this.stopSessionOf(videoLive.videoId)
392 }
393 })
394 .catch(err => logger.error('Cannot stat %s or check quota of %d.', segmentPath, user.id, { err }))
395 }
396 }
397
398 const deleteHandler = segmentPath => this.removeSegmentSha(videoUUID, segmentPath)
399
400 tsWatcher.on('add', p => addHandler(p))
401 tsWatcher.on('unlink', p => deleteHandler(p))
402
403 const masterWatcher = chokidar.watch(outPath + '/master.m3u8')
404 masterWatcher.on('add', async () => {
405 try {
406 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoLive.videoId)
407
408 video.state = VideoState.PUBLISHED
409 await video.save()
410 videoLive.Video = video
411
412 setTimeout(() => {
413 federateVideoIfNeeded(video, false)
414 .catch(err => logger.error('Cannot federate live video %s.', video.url, { err }))
415
416 PeerTubeSocket.Instance.sendVideoLiveNewState(video)
417 }, VIDEO_LIVE.SEGMENT_TIME_SECONDS * 1000 * VIDEO_LIVE.EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION)
418
419 } catch (err) {
420 logger.error('Cannot save/federate live video %d.', videoLive.videoId, { err })
421 } finally {
422 masterWatcher.close()
423 .catch(err => logger.error('Cannot close master watcher of %s.', outPath, { err }))
424 }
425 })
426
427 const onFFmpegEnded = () => {
428 logger.info('RTMP transmuxing for video %s ended. Scheduling cleanup', rtmpUrl)
429
430 this.transSessions.delete(sessionId)
431
432 this.watchersPerVideo.delete(videoLive.videoId)
433 this.videoSessions.delete(videoLive.videoId)
434
435 const newLivesPerUser = this.livesPerUser.get(user.id)
436 .filter(o => o.liveId !== videoLive.id)
437 this.livesPerUser.set(user.id, newLivesPerUser)
438
439 setTimeout(() => {
440 // Wait latest segments generation, and close watchers
441
442 Promise.all([ tsWatcher.close(), masterWatcher.close() ])
443 .then(() => {
444 // Process remaining segments hash
445 for (const key of Object.keys(segmentsToProcessPerPlaylist)) {
446 this.processSegments(outPath, videoUUID, videoLive, segmentsToProcessPerPlaylist[key])
447 }
448 })
449 .catch(err => logger.error('Cannot close watchers of %s or process remaining hash segments.', outPath, { err }))
450
451 this.onEndTransmuxing(videoLive.Video.id)
452 .catch(err => logger.error('Error in closed transmuxing.', { err }))
453 }, 1000)
454 }
455
456 ffmpegExec.on('error', (err, stdout, stderr) => {
457 onFFmpegEnded()
458
459 // Don't care that we killed the ffmpeg process
460 if (err?.message?.includes('Exiting normally')) return
461
462 logger.error('Live transcoding error.', { err, stdout, stderr })
463
464 this.abortSession(sessionId)
465 })
466
467 ffmpegExec.on('end', () => onFFmpegEnded())
468
469 ffmpegExec.run()
470 }
471
472 private async onEndTransmuxing (videoId: number, cleanupNow = false) {
473 try {
474 const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
475 if (!fullVideo) return
476
477 const live = await VideoLiveModel.loadByVideoId(videoId)
478
479 if (!live.permanentLive) {
480 JobQueue.Instance.createJob({
481 type: 'video-live-ending',
482 payload: {
483 videoId: fullVideo.id
484 }
485 }, { delay: cleanupNow ? 0 : VIDEO_LIVE.CLEANUP_DELAY })
486
487 fullVideo.state = VideoState.LIVE_ENDED
488 } else {
489 fullVideo.state = VideoState.WAITING_FOR_LIVE
490 }
491
492 await fullVideo.save()
493
494 PeerTubeSocket.Instance.sendVideoLiveNewState(fullVideo)
495
496 await federateVideoIfNeeded(fullVideo, false)
497 } catch (err) {
498 logger.error('Cannot save/federate new video state of live streaming of video id %d.', videoId, { err })
499 }
500 }
501
502 private async addSegmentSha (videoUUID: string, segmentPath: string) {
503 const segmentName = basename(segmentPath)
504 logger.debug('Adding live sha segment %s.', segmentPath)
505
506 const shaResult = await buildSha256Segment(segmentPath)
507
508 if (!this.segmentsSha256.has(videoUUID)) {
509 this.segmentsSha256.set(videoUUID, new Map())
510 }
511
512 const filesMap = this.segmentsSha256.get(videoUUID)
513 filesMap.set(segmentName, shaResult)
514 }
515
516 private removeSegmentSha (videoUUID: string, segmentPath: string) {
517 const segmentName = basename(segmentPath)
518
519 logger.debug('Removing live sha segment %s.', segmentPath)
520
521 const filesMap = this.segmentsSha256.get(videoUUID)
522 if (!filesMap) {
523 logger.warn('Unknown files map to remove sha for %s.', videoUUID)
524 return
525 }
526
527 if (!filesMap.has(segmentName)) {
528 logger.warn('Unknown segment in files map for video %s and segment %s.', videoUUID, segmentPath)
529 return
530 }
531
532 filesMap.delete(segmentName)
533 }
534
535 private isDurationConstraintValid (streamingStartTime: number) {
536 const maxDuration = CONFIG.LIVE.MAX_DURATION
537 // No limit
538 if (maxDuration < 0) return true
539
540 const now = new Date().getTime()
541 const max = streamingStartTime + maxDuration
542
543 return now <= max
544 }
545
546 private hasClientSocketsInBadHealth (sessionId: string) {
547 const rtmpSession = this.getContext().sessions.get(sessionId)
548
549 if (!rtmpSession) {
550 logger.warn('Cannot get session %s to check players socket health.', sessionId)
551 return
552 }
553
554 for (const playerSessionId of rtmpSession.players) {
555 const playerSession = this.getContext().sessions.get(playerSessionId)
556
557 if (!playerSession) {
558 logger.error('Cannot get player session %s to check socket health.', playerSession)
559 continue
560 }
561
562 if (playerSession.socket.writableLength > VIDEO_LIVE.MAX_SOCKET_WAITING_DATA) {
563 return true
564 }
565 }
566
567 return false
568 }
569
570 private async isQuotaConstraintValid (user: MUserId, live: MVideoLive) {
571 if (live.saveReplay !== true) return true
572
573 return this.isAbleToUploadVideoWithCache(user.id)
574 }
575
576 private async updateLiveViews () {
577 if (!this.isRunning()) return
578
579 if (!isTestInstance()) logger.info('Updating live video views.')
580
581 for (const videoId of this.watchersPerVideo.keys()) {
582 const notBefore = new Date().getTime() - VIEW_LIFETIME.LIVE
583
584 const watchers = this.watchersPerVideo.get(videoId)
585
586 const numWatchers = watchers.length
587
588 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
589 video.views = numWatchers
590 await video.save()
591
592 await federateVideoIfNeeded(video, false)
593
594 PeerTubeSocket.Instance.sendVideoViewsUpdate(video)
595
596 // Only keep not expired watchers
597 const newWatchers = watchers.filter(w => w > notBefore)
598 this.watchersPerVideo.set(videoId, newWatchers)
599
600 logger.debug('New live video views for %s is %d.', video.url, numWatchers)
601 }
602 }
603
604 private async handleBrokenLives () {
605 const videoIds = await VideoModel.listPublishedLiveIds()
606
607 for (const id of videoIds) {
608 await this.onEndTransmuxing(id, true)
609 }
610 }
611
612 static get Instance () {
613 return this.instance || (this.instance = new this())
614 }
615}
616
617// ---------------------------------------------------------------------------
618
619export {
620 LiveManager
621}
diff --git a/server/lib/live/index.ts b/server/lib/live/index.ts
new file mode 100644
index 000000000..8b46800da
--- /dev/null
+++ b/server/lib/live/index.ts
@@ -0,0 +1,4 @@
1export * from './live-manager'
2export * from './live-quota-store'
3export * from './live-segment-sha-store'
4export * from './live-utils'
diff --git a/server/lib/live/live-manager.ts b/server/lib/live/live-manager.ts
new file mode 100644
index 000000000..014cd3fcf
--- /dev/null
+++ b/server/lib/live/live-manager.ts
@@ -0,0 +1,419 @@
1
2import { createServer, Server } from 'net'
3import { isTestInstance } from '@server/helpers/core-utils'
4import { computeResolutionsToTranscode, getVideoFileFPS, getVideoFileResolution } from '@server/helpers/ffprobe-utils'
5import { logger, loggerTagsFactory } from '@server/helpers/logger'
6import { CONFIG, registerConfigChangedHandler } from '@server/initializers/config'
7import { P2P_MEDIA_LOADER_PEER_VERSION, VIDEO_LIVE, VIEW_LIFETIME, WEBSERVER } from '@server/initializers/constants'
8import { UserModel } from '@server/models/user/user'
9import { VideoModel } from '@server/models/video/video'
10import { VideoLiveModel } from '@server/models/video/video-live'
11import { VideoStreamingPlaylistModel } from '@server/models/video/video-streaming-playlist'
12import { MStreamingPlaylist, MStreamingPlaylistVideo, MVideo, MVideoLiveVideo } from '@server/types/models'
13import { VideoState, VideoStreamingPlaylistType } from '@shared/models'
14import { federateVideoIfNeeded } from '../activitypub/videos'
15import { JobQueue } from '../job-queue'
16import { PeerTubeSocket } from '../peertube-socket'
17import { LiveQuotaStore } from './live-quota-store'
18import { LiveSegmentShaStore } from './live-segment-sha-store'
19import { cleanupLive } from './live-utils'
20import { MuxingSession } from './shared'
21
22const NodeRtmpSession = require('node-media-server/node_rtmp_session')
23const context = require('node-media-server/node_core_ctx')
24const nodeMediaServerLogger = require('node-media-server/node_core_logger')
25
26// Disable node media server logs
27nodeMediaServerLogger.setLogType(0)
28
29const config = {
30 rtmp: {
31 port: CONFIG.LIVE.RTMP.PORT,
32 chunk_size: VIDEO_LIVE.RTMP.CHUNK_SIZE,
33 gop_cache: VIDEO_LIVE.RTMP.GOP_CACHE,
34 ping: VIDEO_LIVE.RTMP.PING,
35 ping_timeout: VIDEO_LIVE.RTMP.PING_TIMEOUT
36 },
37 transcoding: {
38 ffmpeg: 'ffmpeg'
39 }
40}
41
42const lTags = loggerTagsFactory('live')
43
44class LiveManager {
45
46 private static instance: LiveManager
47
48 private readonly muxingSessions = new Map<string, MuxingSession>()
49 private readonly videoSessions = new Map<number, string>()
50 // Values are Date().getTime()
51 private readonly watchersPerVideo = new Map<number, number[]>()
52
53 private rtmpServer: Server
54
55 private constructor () {
56 }
57
58 init () {
59 const events = this.getContext().nodeEvent
60 events.on('postPublish', (sessionId: string, streamPath: string) => {
61 logger.debug('RTMP received stream', { id: sessionId, streamPath, ...lTags(sessionId) })
62
63 const splittedPath = streamPath.split('/')
64 if (splittedPath.length !== 3 || splittedPath[1] !== VIDEO_LIVE.RTMP.BASE_PATH) {
65 logger.warn('Live path is incorrect.', { streamPath, ...lTags(sessionId) })
66 return this.abortSession(sessionId)
67 }
68
69 this.handleSession(sessionId, streamPath, splittedPath[2])
70 .catch(err => logger.error('Cannot handle sessions.', { err, ...lTags(sessionId) }))
71 })
72
73 events.on('donePublish', sessionId => {
74 logger.info('Live session ended.', { sessionId, ...lTags(sessionId) })
75 })
76
77 registerConfigChangedHandler(() => {
78 if (!this.rtmpServer && CONFIG.LIVE.ENABLED === true) {
79 this.run()
80 return
81 }
82
83 if (this.rtmpServer && CONFIG.LIVE.ENABLED === false) {
84 this.stop()
85 }
86 })
87
88 // Cleanup broken lives, that were terminated by a server restart for example
89 this.handleBrokenLives()
90 .catch(err => logger.error('Cannot handle broken lives.', { err, ...lTags() }))
91
92 setInterval(() => this.updateLiveViews(), VIEW_LIFETIME.LIVE)
93 }
94
95 run () {
96 logger.info('Running RTMP server on port %d', config.rtmp.port, lTags())
97
98 this.rtmpServer = createServer(socket => {
99 const session = new NodeRtmpSession(config, socket)
100
101 session.run()
102 })
103
104 this.rtmpServer.on('error', err => {
105 logger.error('Cannot run RTMP server.', { err, ...lTags() })
106 })
107
108 this.rtmpServer.listen(CONFIG.LIVE.RTMP.PORT)
109 }
110
111 stop () {
112 logger.info('Stopping RTMP server.', lTags())
113
114 this.rtmpServer.close()
115 this.rtmpServer = undefined
116
117 // Sessions is an object
118 this.getContext().sessions.forEach((session: any) => {
119 if (session instanceof NodeRtmpSession) {
120 session.stop()
121 }
122 })
123 }
124
125 isRunning () {
126 return !!this.rtmpServer
127 }
128
129 stopSessionOf (videoId: number) {
130 const sessionId = this.videoSessions.get(videoId)
131 if (!sessionId) return
132
133 this.videoSessions.delete(videoId)
134 this.abortSession(sessionId)
135 }
136
137 addViewTo (videoId: number) {
138 if (this.videoSessions.has(videoId) === false) return
139
140 let watchers = this.watchersPerVideo.get(videoId)
141
142 if (!watchers) {
143 watchers = []
144 this.watchersPerVideo.set(videoId, watchers)
145 }
146
147 watchers.push(new Date().getTime())
148 }
149
150 private getContext () {
151 return context
152 }
153
154 private abortSession (sessionId: string) {
155 const session = this.getContext().sessions.get(sessionId)
156 if (session) {
157 session.stop()
158 this.getContext().sessions.delete(sessionId)
159 }
160
161 const muxingSession = this.muxingSessions.get(sessionId)
162 if (muxingSession) {
163 // Muxing session will fire and event so we correctly cleanup the session
164 muxingSession.abort()
165
166 this.muxingSessions.delete(sessionId)
167 }
168 }
169
170 private async handleSession (sessionId: string, streamPath: string, streamKey: string) {
171 const videoLive = await VideoLiveModel.loadByStreamKey(streamKey)
172 if (!videoLive) {
173 logger.warn('Unknown live video with stream key %s.', streamKey, lTags(sessionId))
174 return this.abortSession(sessionId)
175 }
176
177 const video = videoLive.Video
178 if (video.isBlacklisted()) {
179 logger.warn('Video is blacklisted. Refusing stream %s.', streamKey, lTags(sessionId, video.uuid))
180 return this.abortSession(sessionId)
181 }
182
183 // Cleanup old potential live files (could happen with a permanent live)
184 LiveSegmentShaStore.Instance.cleanupShaSegments(video.uuid)
185
186 const oldStreamingPlaylist = await VideoStreamingPlaylistModel.loadHLSPlaylistByVideo(video.id)
187 if (oldStreamingPlaylist) {
188 await cleanupLive(video, oldStreamingPlaylist)
189 }
190
191 this.videoSessions.set(video.id, sessionId)
192
193 const rtmpUrl = 'rtmp://127.0.0.1:' + config.rtmp.port + streamPath
194
195 const [ { videoFileResolution }, fps ] = await Promise.all([
196 getVideoFileResolution(rtmpUrl),
197 getVideoFileFPS(rtmpUrl)
198 ])
199
200 const allResolutions = this.buildAllResolutionsToTranscode(videoFileResolution)
201
202 logger.info(
203 'Will mux/transcode live video of original resolution %d.', videoFileResolution,
204 { allResolutions, ...lTags(sessionId, video.uuid) }
205 )
206
207 const streamingPlaylist = await this.createLivePlaylist(video, allResolutions)
208
209 return this.runMuxingSession({
210 sessionId,
211 videoLive,
212 streamingPlaylist,
213 rtmpUrl,
214 fps,
215 allResolutions
216 })
217 }
218
219 private async runMuxingSession (options: {
220 sessionId: string
221 videoLive: MVideoLiveVideo
222 streamingPlaylist: MStreamingPlaylistVideo
223 rtmpUrl: string
224 fps: number
225 allResolutions: number[]
226 }) {
227 const { sessionId, videoLive, streamingPlaylist, allResolutions, fps, rtmpUrl } = options
228 const videoUUID = videoLive.Video.uuid
229 const localLTags = lTags(sessionId, videoUUID)
230
231 const user = await UserModel.loadByLiveId(videoLive.id)
232 LiveQuotaStore.Instance.addNewLive(user.id, videoLive.id)
233
234 const muxingSession = new MuxingSession({
235 context: this.getContext(),
236 user,
237 sessionId,
238 videoLive,
239 streamingPlaylist,
240 rtmpUrl,
241 fps,
242 allResolutions
243 })
244
245 muxingSession.on('master-playlist-created', () => this.publishAndFederateLive(videoLive, localLTags))
246
247 muxingSession.on('bad-socket-health', ({ videoId }) => {
248 logger.error(
249 'Too much data in client socket stream (ffmpeg is too slow to transcode the video).' +
250 ' Stopping session of video %s.', videoUUID,
251 localLTags
252 )
253
254 this.stopSessionOf(videoId)
255 })
256
257 muxingSession.on('duration-exceeded', ({ videoId }) => {
258 logger.info('Stopping session of %s: max duration exceeded.', videoUUID, localLTags)
259
260 this.stopSessionOf(videoId)
261 })
262
263 muxingSession.on('quota-exceeded', ({ videoId }) => {
264 logger.info('Stopping session of %s: user quota exceeded.', videoUUID, localLTags)
265
266 this.stopSessionOf(videoId)
267 })
268
269 muxingSession.on('ffmpeg-error', ({ sessionId }) => this.abortSession(sessionId))
270 muxingSession.on('ffmpeg-end', ({ videoId }) => {
271 this.onMuxingFFmpegEnd(videoId)
272 })
273
274 muxingSession.on('after-cleanup', ({ videoId }) => {
275 this.muxingSessions.delete(sessionId)
276
277 muxingSession.destroy()
278
279 return this.onAfterMuxingCleanup(videoId)
280 .catch(err => logger.error('Error in end transmuxing.', { err, ...localLTags }))
281 })
282
283 this.muxingSessions.set(sessionId, muxingSession)
284
285 muxingSession.runMuxing()
286 .catch(err => {
287 logger.error('Cannot run muxing.', { err, ...localLTags })
288 this.abortSession(sessionId)
289 })
290 }
291
292 private async publishAndFederateLive (live: MVideoLiveVideo, localLTags: { tags: string[] }) {
293 const videoId = live.videoId
294
295 try {
296 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
297
298 logger.info('Will publish and federate live %s.', video.url, localLTags)
299
300 video.state = VideoState.PUBLISHED
301 await video.save()
302
303 live.Video = video
304
305 setTimeout(() => {
306 federateVideoIfNeeded(video, false)
307 .catch(err => logger.error('Cannot federate live video %s.', video.url, { err, ...localLTags }))
308
309 PeerTubeSocket.Instance.sendVideoLiveNewState(video)
310 }, VIDEO_LIVE.SEGMENT_TIME_SECONDS * 1000 * VIDEO_LIVE.EDGE_LIVE_DELAY_SEGMENTS_NOTIFICATION)
311 } catch (err) {
312 logger.error('Cannot save/federate live video %d.', videoId, { err, ...localLTags })
313 }
314 }
315
316 private onMuxingFFmpegEnd (videoId: number) {
317 this.watchersPerVideo.delete(videoId)
318 this.videoSessions.delete(videoId)
319 }
320
321 private async onAfterMuxingCleanup (videoUUID: string, cleanupNow = false) {
322 try {
323 const fullVideo = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoUUID)
324 if (!fullVideo) return
325
326 const live = await VideoLiveModel.loadByVideoId(fullVideo.id)
327
328 if (!live.permanentLive) {
329 JobQueue.Instance.createJob({
330 type: 'video-live-ending',
331 payload: {
332 videoId: fullVideo.id
333 }
334 }, { delay: cleanupNow ? 0 : VIDEO_LIVE.CLEANUP_DELAY })
335
336 fullVideo.state = VideoState.LIVE_ENDED
337 } else {
338 fullVideo.state = VideoState.WAITING_FOR_LIVE
339 }
340
341 await fullVideo.save()
342
343 PeerTubeSocket.Instance.sendVideoLiveNewState(fullVideo)
344
345 await federateVideoIfNeeded(fullVideo, false)
346 } catch (err) {
347 logger.error('Cannot save/federate new video state of live streaming of video %d.', videoUUID, { err, ...lTags(videoUUID) })
348 }
349 }
350
351 private async updateLiveViews () {
352 if (!this.isRunning()) return
353
354 if (!isTestInstance()) logger.info('Updating live video views.', lTags())
355
356 for (const videoId of this.watchersPerVideo.keys()) {
357 const notBefore = new Date().getTime() - VIEW_LIFETIME.LIVE
358
359 const watchers = this.watchersPerVideo.get(videoId)
360
361 const numWatchers = watchers.length
362
363 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(videoId)
364 video.views = numWatchers
365 await video.save()
366
367 await federateVideoIfNeeded(video, false)
368
369 PeerTubeSocket.Instance.sendVideoViewsUpdate(video)
370
371 // Only keep not expired watchers
372 const newWatchers = watchers.filter(w => w > notBefore)
373 this.watchersPerVideo.set(videoId, newWatchers)
374
375 logger.debug('New live video views for %s is %d.', video.url, numWatchers, lTags())
376 }
377 }
378
379 private async handleBrokenLives () {
380 const videoUUIDs = await VideoModel.listPublishedLiveUUIDs()
381
382 for (const uuid of videoUUIDs) {
383 await this.onAfterMuxingCleanup(uuid, true)
384 }
385 }
386
387 private buildAllResolutionsToTranscode (originResolution: number) {
388 const resolutionsEnabled = CONFIG.LIVE.TRANSCODING.ENABLED
389 ? computeResolutionsToTranscode(originResolution, 'live')
390 : []
391
392 return resolutionsEnabled.concat([ originResolution ])
393 }
394
395 private async createLivePlaylist (video: MVideo, allResolutions: number[]) {
396 const playlistUrl = WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsMasterPlaylistStaticPath(video.uuid)
397 const [ videoStreamingPlaylist ] = await VideoStreamingPlaylistModel.upsert({
398 videoId: video.id,
399 playlistUrl,
400 segmentsSha256Url: WEBSERVER.URL + VideoStreamingPlaylistModel.getHlsSha256SegmentsStaticPath(video.uuid, video.isLive),
401 p2pMediaLoaderInfohashes: VideoStreamingPlaylistModel.buildP2PMediaLoaderInfoHashes(playlistUrl, allResolutions),
402 p2pMediaLoaderPeerVersion: P2P_MEDIA_LOADER_PEER_VERSION,
403
404 type: VideoStreamingPlaylistType.HLS
405 }, { returning: true }) as [ MStreamingPlaylist, boolean ]
406
407 return Object.assign(videoStreamingPlaylist, { Video: video })
408 }
409
410 static get Instance () {
411 return this.instance || (this.instance = new this())
412 }
413}
414
415// ---------------------------------------------------------------------------
416
417export {
418 LiveManager
419}
diff --git a/server/lib/live/live-quota-store.ts b/server/lib/live/live-quota-store.ts
new file mode 100644
index 000000000..8ceccde98
--- /dev/null
+++ b/server/lib/live/live-quota-store.ts
@@ -0,0 +1,48 @@
1class LiveQuotaStore {
2
3 private static instance: LiveQuotaStore
4
5 private readonly livesPerUser = new Map<number, { liveId: number, size: number }[]>()
6
7 private constructor () {
8 }
9
10 addNewLive (userId: number, liveId: number) {
11 if (!this.livesPerUser.has(userId)) {
12 this.livesPerUser.set(userId, [])
13 }
14
15 const currentUserLive = { liveId, size: 0 }
16 const livesOfUser = this.livesPerUser.get(userId)
17 livesOfUser.push(currentUserLive)
18 }
19
20 removeLive (userId: number, liveId: number) {
21 const newLivesPerUser = this.livesPerUser.get(userId)
22 .filter(o => o.liveId !== liveId)
23
24 this.livesPerUser.set(userId, newLivesPerUser)
25 }
26
27 addQuotaTo (userId: number, liveId: number, size: number) {
28 const lives = this.livesPerUser.get(userId)
29 const live = lives.find(l => l.liveId === liveId)
30
31 live.size += size
32 }
33
34 getLiveQuotaOf (userId: number) {
35 const currentLives = this.livesPerUser.get(userId)
36 if (!currentLives) return 0
37
38 return currentLives.reduce((sum, obj) => sum + obj.size, 0)
39 }
40
41 static get Instance () {
42 return this.instance || (this.instance = new this())
43 }
44}
45
46export {
47 LiveQuotaStore
48}
diff --git a/server/lib/live/live-segment-sha-store.ts b/server/lib/live/live-segment-sha-store.ts
new file mode 100644
index 000000000..4af6f3ebf
--- /dev/null
+++ b/server/lib/live/live-segment-sha-store.ts
@@ -0,0 +1,64 @@
1import { basename } from 'path'
2import { logger, loggerTagsFactory } from '@server/helpers/logger'
3import { buildSha256Segment } from '../hls'
4
5const lTags = loggerTagsFactory('live')
6
7class LiveSegmentShaStore {
8
9 private static instance: LiveSegmentShaStore
10
11 private readonly segmentsSha256 = new Map<string, Map<string, string>>()
12
13 private constructor () {
14 }
15
16 getSegmentsSha256 (videoUUID: string) {
17 return this.segmentsSha256.get(videoUUID)
18 }
19
20 async addSegmentSha (videoUUID: string, segmentPath: string) {
21 const segmentName = basename(segmentPath)
22 logger.debug('Adding live sha segment %s.', segmentPath, lTags(videoUUID))
23
24 const shaResult = await buildSha256Segment(segmentPath)
25
26 if (!this.segmentsSha256.has(videoUUID)) {
27 this.segmentsSha256.set(videoUUID, new Map())
28 }
29
30 const filesMap = this.segmentsSha256.get(videoUUID)
31 filesMap.set(segmentName, shaResult)
32 }
33
34 removeSegmentSha (videoUUID: string, segmentPath: string) {
35 const segmentName = basename(segmentPath)
36
37 logger.debug('Removing live sha segment %s.', segmentPath, lTags(videoUUID))
38
39 const filesMap = this.segmentsSha256.get(videoUUID)
40 if (!filesMap) {
41 logger.warn('Unknown files map to remove sha for %s.', videoUUID, lTags(videoUUID))
42 return
43 }
44
45 if (!filesMap.has(segmentName)) {
46 logger.warn('Unknown segment in files map for video %s and segment %s.', videoUUID, segmentPath, lTags(videoUUID))
47 return
48 }
49
50 filesMap.delete(segmentName)
51 }
52
53 cleanupShaSegments (videoUUID: string) {
54 this.segmentsSha256.delete(videoUUID)
55 }
56
57 static get Instance () {
58 return this.instance || (this.instance = new this())
59 }
60}
61
62export {
63 LiveSegmentShaStore
64}
diff --git a/server/lib/live/live-utils.ts b/server/lib/live/live-utils.ts
new file mode 100644
index 000000000..e4526c7a5
--- /dev/null
+++ b/server/lib/live/live-utils.ts
@@ -0,0 +1,23 @@
1import { remove } from 'fs-extra'
2import { basename } from 'path'
3import { MStreamingPlaylist, MVideo } from '@server/types/models'
4import { getHLSDirectory } from '../video-paths'
5
6function buildConcatenatedName (segmentOrPlaylistPath: string) {
7 const num = basename(segmentOrPlaylistPath).match(/^(\d+)(-|\.)/)
8
9 return 'concat-' + num[1] + '.ts'
10}
11
12async function cleanupLive (video: MVideo, streamingPlaylist: MStreamingPlaylist) {
13 const hlsDirectory = getHLSDirectory(video)
14
15 await remove(hlsDirectory)
16
17 await streamingPlaylist.destroy()
18}
19
20export {
21 cleanupLive,
22 buildConcatenatedName
23}
diff --git a/server/lib/live/shared/index.ts b/server/lib/live/shared/index.ts
new file mode 100644
index 000000000..c4d1b59ec
--- /dev/null
+++ b/server/lib/live/shared/index.ts
@@ -0,0 +1 @@
export * from './muxing-session'
diff --git a/server/lib/live/shared/muxing-session.ts b/server/lib/live/shared/muxing-session.ts
new file mode 100644
index 000000000..26467f060
--- /dev/null
+++ b/server/lib/live/shared/muxing-session.ts
@@ -0,0 +1,346 @@
1
2import * as Bluebird from 'bluebird'
3import * as chokidar from 'chokidar'
4import { FfmpegCommand } from 'fluent-ffmpeg'
5import { appendFile, ensureDir, readFile, stat } from 'fs-extra'
6import { basename, join } from 'path'
7import { EventEmitter } from 'stream'
8import { getLiveMuxingCommand, getLiveTranscodingCommand } from '@server/helpers/ffmpeg-utils'
9import { logger, loggerTagsFactory, LoggerTagsFn } from '@server/helpers/logger'
10import { CONFIG } from '@server/initializers/config'
11import { MEMOIZE_TTL, VIDEO_LIVE } from '@server/initializers/constants'
12import { VideoFileModel } from '@server/models/video/video-file'
13import { MStreamingPlaylistVideo, MUserId, MVideoLiveVideo } from '@server/types/models'
14import { VideoTranscodingProfilesManager } from '../../transcoding/video-transcoding-profiles'
15import { isAbleToUploadVideo } from '../../user'
16import { getHLSDirectory } from '../../video-paths'
17import { LiveQuotaStore } from '../live-quota-store'
18import { LiveSegmentShaStore } from '../live-segment-sha-store'
19import { buildConcatenatedName } from '../live-utils'
20
21import memoizee = require('memoizee')
22
23interface MuxingSessionEvents {
24 'master-playlist-created': ({ videoId: number }) => void
25
26 'bad-socket-health': ({ videoId: number }) => void
27 'duration-exceeded': ({ videoId: number }) => void
28 'quota-exceeded': ({ videoId: number }) => void
29
30 'ffmpeg-end': ({ videoId: number }) => void
31 'ffmpeg-error': ({ sessionId: string }) => void
32
33 'after-cleanup': ({ videoId: number }) => void
34}
35
36declare interface MuxingSession {
37 on<U extends keyof MuxingSessionEvents>(
38 event: U, listener: MuxingSessionEvents[U]
39 ): this
40
41 emit<U extends keyof MuxingSessionEvents>(
42 event: U, ...args: Parameters<MuxingSessionEvents[U]>
43 ): boolean
44}
45
46class MuxingSession extends EventEmitter {
47
48 private ffmpegCommand: FfmpegCommand
49
50 private readonly context: any
51 private readonly user: MUserId
52 private readonly sessionId: string
53 private readonly videoLive: MVideoLiveVideo
54 private readonly streamingPlaylist: MStreamingPlaylistVideo
55 private readonly rtmpUrl: string
56 private readonly fps: number
57 private readonly allResolutions: number[]
58
59 private readonly videoId: number
60 private readonly videoUUID: string
61 private readonly saveReplay: boolean
62
63 private readonly lTags: LoggerTagsFn
64
65 private segmentsToProcessPerPlaylist: { [playlistId: string]: string[] } = {}
66
67 private tsWatcher: chokidar.FSWatcher
68 private masterWatcher: chokidar.FSWatcher
69
70 private readonly isAbleToUploadVideoWithCache = memoizee((userId: number) => {
71 return isAbleToUploadVideo(userId, 1000)
72 }, { maxAge: MEMOIZE_TTL.LIVE_ABLE_TO_UPLOAD })
73
74 private readonly hasClientSocketInBadHealthWithCache = memoizee((sessionId: string) => {
75 return this.hasClientSocketInBadHealth(sessionId)
76 }, { maxAge: MEMOIZE_TTL.LIVE_CHECK_SOCKET_HEALTH })
77
78 constructor (options: {
79 context: any
80 user: MUserId
81 sessionId: string
82 videoLive: MVideoLiveVideo
83 streamingPlaylist: MStreamingPlaylistVideo
84 rtmpUrl: string
85 fps: number
86 allResolutions: number[]
87 }) {
88 super()
89
90 this.context = options.context
91 this.user = options.user
92 this.sessionId = options.sessionId
93 this.videoLive = options.videoLive
94 this.streamingPlaylist = options.streamingPlaylist
95 this.rtmpUrl = options.rtmpUrl
96 this.fps = options.fps
97 this.allResolutions = options.allResolutions
98
99 this.videoId = this.videoLive.Video.id
100 this.videoUUID = this.videoLive.Video.uuid
101
102 this.saveReplay = this.videoLive.saveReplay
103
104 this.lTags = loggerTagsFactory('live', this.sessionId, this.videoUUID)
105 }
106
107 async runMuxing () {
108 this.createFiles()
109
110 const outPath = await this.prepareDirectories()
111
112 this.ffmpegCommand = CONFIG.LIVE.TRANSCODING.ENABLED
113 ? await getLiveTranscodingCommand({
114 rtmpUrl: this.rtmpUrl,
115 outPath,
116 resolutions: this.allResolutions,
117 fps: this.fps,
118 availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
119 profile: CONFIG.LIVE.TRANSCODING.PROFILE
120 })
121 : getLiveMuxingCommand(this.rtmpUrl, outPath)
122
123 logger.info('Running live muxing/transcoding for %s.', this.videoUUID, this.lTags)
124
125 this.watchTSFiles(outPath)
126 this.watchMasterFile(outPath)
127
128 this.ffmpegCommand.on('error', (err, stdout, stderr) => {
129 this.onFFmpegError(err, stdout, stderr, outPath)
130 })
131
132 this.ffmpegCommand.on('end', () => this.onFFmpegEnded(outPath))
133
134 this.ffmpegCommand.run()
135 }
136
137 abort () {
138 if (!this.ffmpegCommand) return
139
140 this.ffmpegCommand.kill('SIGINT')
141 }
142
143 destroy () {
144 this.removeAllListeners()
145 this.isAbleToUploadVideoWithCache.clear()
146 this.hasClientSocketInBadHealthWithCache.clear()
147 }
148
149 private onFFmpegError (err: any, stdout: string, stderr: string, outPath: string) {
150 this.onFFmpegEnded(outPath)
151
152 // Don't care that we killed the ffmpeg process
153 if (err?.message?.includes('Exiting normally')) return
154
155 logger.error('Live transcoding error.', { err, stdout, stderr, ...this.lTags })
156
157 this.emit('ffmpeg-error', ({ sessionId: this.sessionId }))
158 }
159
160 private onFFmpegEnded (outPath: string) {
161 logger.info('RTMP transmuxing for video %s ended. Scheduling cleanup', this.rtmpUrl, this.lTags)
162
163 setTimeout(() => {
164 // Wait latest segments generation, and close watchers
165
166 Promise.all([ this.tsWatcher.close(), this.masterWatcher.close() ])
167 .then(() => {
168 // Process remaining segments hash
169 for (const key of Object.keys(this.segmentsToProcessPerPlaylist)) {
170 this.processSegments(outPath, this.segmentsToProcessPerPlaylist[key])
171 }
172 })
173 .catch(err => {
174 logger.error(
175 'Cannot close watchers of %s or process remaining hash segments.', outPath,
176 { err, ...this.lTags }
177 )
178 })
179
180 this.emit('after-cleanup', { videoId: this.videoId })
181 }, 1000)
182 }
183
184 private watchMasterFile (outPath: string) {
185 this.masterWatcher = chokidar.watch(outPath + '/master.m3u8')
186
187 this.masterWatcher.on('add', async () => {
188 this.emit('master-playlist-created', { videoId: this.videoId })
189
190 this.masterWatcher.close()
191 .catch(err => logger.error('Cannot close master watcher of %s.', outPath, { err, ...this.lTags }))
192 })
193 }
194
195 private watchTSFiles (outPath: string) {
196 const startStreamDateTime = new Date().getTime()
197
198 this.tsWatcher = chokidar.watch(outPath + '/*.ts')
199
200 const playlistIdMatcher = /^([\d+])-/
201
202 const addHandler = async segmentPath => {
203 logger.debug('Live add handler of %s.', segmentPath, this.lTags)
204
205 const playlistId = basename(segmentPath).match(playlistIdMatcher)[0]
206
207 const segmentsToProcess = this.segmentsToProcessPerPlaylist[playlistId] || []
208 this.processSegments(outPath, segmentsToProcess)
209
210 this.segmentsToProcessPerPlaylist[playlistId] = [ segmentPath ]
211
212 if (this.hasClientSocketInBadHealthWithCache(this.sessionId)) {
213 this.emit('bad-socket-health', { videoId: this.videoId })
214 return
215 }
216
217 // Duration constraint check
218 if (this.isDurationConstraintValid(startStreamDateTime) !== true) {
219 this.emit('duration-exceeded', { videoId: this.videoId })
220 return
221 }
222
223 // Check user quota if the user enabled replay saving
224 if (await this.isQuotaExceeded(segmentPath) === true) {
225 this.emit('quota-exceeded', { videoId: this.videoId })
226 }
227 }
228
229 const deleteHandler = segmentPath => LiveSegmentShaStore.Instance.removeSegmentSha(this.videoUUID, segmentPath)
230
231 this.tsWatcher.on('add', p => addHandler(p))
232 this.tsWatcher.on('unlink', p => deleteHandler(p))
233 }
234
235 private async isQuotaExceeded (segmentPath: string) {
236 if (this.saveReplay !== true) return false
237
238 try {
239 const segmentStat = await stat(segmentPath)
240
241 LiveQuotaStore.Instance.addQuotaTo(this.user.id, this.videoLive.id, segmentStat.size)
242
243 const canUpload = await this.isAbleToUploadVideoWithCache(this.user.id)
244
245 return canUpload !== true
246 } catch (err) {
247 logger.error('Cannot stat %s or check quota of %d.', segmentPath, this.user.id, { err, ...this.lTags })
248 }
249 }
250
251 private createFiles () {
252 for (let i = 0; i < this.allResolutions.length; i++) {
253 const resolution = this.allResolutions[i]
254
255 const file = new VideoFileModel({
256 resolution,
257 size: -1,
258 extname: '.ts',
259 infoHash: null,
260 fps: this.fps,
261 videoStreamingPlaylistId: this.streamingPlaylist.id
262 })
263
264 VideoFileModel.customUpsert(file, 'streaming-playlist', null)
265 .catch(err => logger.error('Cannot create file for live streaming.', { err, ...this.lTags }))
266 }
267 }
268
269 private async prepareDirectories () {
270 const outPath = getHLSDirectory(this.videoLive.Video)
271 await ensureDir(outPath)
272
273 const replayDirectory = join(outPath, VIDEO_LIVE.REPLAY_DIRECTORY)
274
275 if (this.videoLive.saveReplay === true) {
276 await ensureDir(replayDirectory)
277 }
278
279 return outPath
280 }
281
282 private isDurationConstraintValid (streamingStartTime: number) {
283 const maxDuration = CONFIG.LIVE.MAX_DURATION
284 // No limit
285 if (maxDuration < 0) return true
286
287 const now = new Date().getTime()
288 const max = streamingStartTime + maxDuration
289
290 return now <= max
291 }
292
293 private processSegments (hlsVideoPath: string, segmentPaths: string[]) {
294 Bluebird.mapSeries(segmentPaths, async previousSegment => {
295 // Add sha hash of previous segments, because ffmpeg should have finished generating them
296 await LiveSegmentShaStore.Instance.addSegmentSha(this.videoUUID, previousSegment)
297
298 if (this.saveReplay) {
299 await this.addSegmentToReplay(hlsVideoPath, previousSegment)
300 }
301 }).catch(err => logger.error('Cannot process segments in %s', hlsVideoPath, { err, ...this.lTags }))
302 }
303
304 private hasClientSocketInBadHealth (sessionId: string) {
305 const rtmpSession = this.context.sessions.get(sessionId)
306
307 if (!rtmpSession) {
308 logger.warn('Cannot get session %s to check players socket health.', sessionId, this.lTags)
309 return
310 }
311
312 for (const playerSessionId of rtmpSession.players) {
313 const playerSession = this.context.sessions.get(playerSessionId)
314
315 if (!playerSession) {
316 logger.error('Cannot get player session %s to check socket health.', playerSession, this.lTags)
317 continue
318 }
319
320 if (playerSession.socket.writableLength > VIDEO_LIVE.MAX_SOCKET_WAITING_DATA) {
321 return true
322 }
323 }
324
325 return false
326 }
327
328 private async addSegmentToReplay (hlsVideoPath: string, segmentPath: string) {
329 const segmentName = basename(segmentPath)
330 const dest = join(hlsVideoPath, VIDEO_LIVE.REPLAY_DIRECTORY, buildConcatenatedName(segmentName))
331
332 try {
333 const data = await readFile(segmentPath)
334
335 await appendFile(dest, data)
336 } catch (err) {
337 logger.error('Cannot copy segment %s to replay directory.', segmentPath, { err, ...this.lTags })
338 }
339 }
340}
341
342// ---------------------------------------------------------------------------
343
344export {
345 MuxingSession
346}
diff --git a/server/lib/actor-image.ts b/server/lib/local-actor.ts
index f271f0b5b..77667f6b0 100644
--- a/server/lib/actor-image.ts
+++ b/server/lib/local-actor.ts
@@ -1,19 +1,38 @@
1import 'multer' 1import 'multer'
2import { queue } from 'async' 2import { queue } from 'async'
3import * as LRUCache from 'lru-cache' 3import * as LRUCache from 'lru-cache'
4import { extname, join } from 'path' 4import { join } from 'path'
5import { v4 as uuidv4 } from 'uuid' 5import { getLowercaseExtension } from '@server/helpers/core-utils'
6import { ActorImageType } from '@shared/models' 6import { buildUUID } from '@server/helpers/uuid'
7import { ActorModel } from '@server/models/actor/actor'
8import { ActivityPubActorType, ActorImageType } from '@shared/models'
7import { retryTransactionWrapper } from '../helpers/database-utils' 9import { retryTransactionWrapper } from '../helpers/database-utils'
8import { processImage } from '../helpers/image-utils' 10import { processImage } from '../helpers/image-utils'
9import { downloadImage } from '../helpers/requests' 11import { downloadImage } from '../helpers/requests'
10import { CONFIG } from '../initializers/config' 12import { CONFIG } from '../initializers/config'
11import { ACTOR_IMAGES_SIZE, LRU_CACHE, QUEUE_CONCURRENCY } from '../initializers/constants' 13import { ACTOR_IMAGES_SIZE, LRU_CACHE, QUEUE_CONCURRENCY, WEBSERVER } from '../initializers/constants'
12import { sequelizeTypescript } from '../initializers/database' 14import { sequelizeTypescript } from '../initializers/database'
13import { MAccountDefault, MChannelDefault } from '../types/models' 15import { MAccountDefault, MActor, MChannelDefault } from '../types/models'
14import { deleteActorImageInstance, updateActorImageInstance } from './activitypub/actor' 16import { deleteActorImageInstance, updateActorImageInstance } from './activitypub/actors'
15import { sendUpdateActor } from './activitypub/send' 17import { sendUpdateActor } from './activitypub/send'
16 18
19function buildActorInstance (type: ActivityPubActorType, url: string, preferredUsername: string) {
20 return new ActorModel({
21 type,
22 url,
23 preferredUsername,
24 publicKey: null,
25 privateKey: null,
26 followersCount: 0,
27 followingCount: 0,
28 inboxUrl: url + '/inbox',
29 outboxUrl: url + '/outbox',
30 sharedInboxUrl: WEBSERVER.URL + '/inbox',
31 followersUrl: url + '/followers',
32 followingUrl: url + '/following'
33 }) as MActor
34}
35
17async function updateLocalActorImageFile ( 36async function updateLocalActorImageFile (
18 accountOrChannel: MAccountDefault | MChannelDefault, 37 accountOrChannel: MAccountDefault | MChannelDefault,
19 imagePhysicalFile: Express.Multer.File, 38 imagePhysicalFile: Express.Multer.File,
@@ -23,9 +42,9 @@ async function updateLocalActorImageFile (
23 ? ACTOR_IMAGES_SIZE.AVATARS 42 ? ACTOR_IMAGES_SIZE.AVATARS
24 : ACTOR_IMAGES_SIZE.BANNERS 43 : ACTOR_IMAGES_SIZE.BANNERS
25 44
26 const extension = extname(imagePhysicalFile.filename) 45 const extension = getLowercaseExtension(imagePhysicalFile.filename)
27 46
28 const imageName = uuidv4() + extension 47 const imageName = buildUUID() + extension
29 const destination = join(CONFIG.STORAGE.ACTOR_IMAGES, imageName) 48 const destination = join(CONFIG.STORAGE.ACTOR_IMAGES, imageName)
30 await processImage(imagePhysicalFile.path, destination, imageSize) 49 await processImage(imagePhysicalFile.path, destination, imageSize)
31 50
@@ -93,5 +112,6 @@ export {
93 actorImagePathUnsafeCache, 112 actorImagePathUnsafeCache,
94 updateLocalActorImageFile, 113 updateLocalActorImageFile,
95 deleteLocalActorImageFile, 114 deleteLocalActorImageFile,
96 pushActorImageProcessInQueue 115 pushActorImageProcessInQueue,
116 buildActorInstance
97} 117}
diff --git a/server/lib/model-loaders/actor.ts b/server/lib/model-loaders/actor.ts
new file mode 100644
index 000000000..1355d8ee2
--- /dev/null
+++ b/server/lib/model-loaders/actor.ts
@@ -0,0 +1,17 @@
1
2import { ActorModel } from '../../models/actor/actor'
3import { MActorAccountChannelId, MActorFull } from '../../types/models'
4
5type ActorLoadByUrlType = 'all' | 'association-ids'
6
7function loadActorByUrl (url: string, fetchType: ActorLoadByUrlType): Promise<MActorFull | MActorAccountChannelId> {
8 if (fetchType === 'all') return ActorModel.loadByUrlAndPopulateAccountAndChannel(url)
9
10 if (fetchType === 'association-ids') return ActorModel.loadByUrl(url)
11}
12
13export {
14 ActorLoadByUrlType,
15
16 loadActorByUrl
17}
diff --git a/server/lib/model-loaders/index.ts b/server/lib/model-loaders/index.ts
new file mode 100644
index 000000000..9e5152cb2
--- /dev/null
+++ b/server/lib/model-loaders/index.ts
@@ -0,0 +1,2 @@
1export * from './actor'
2export * from './video'
diff --git a/server/lib/model-loaders/video.ts b/server/lib/model-loaders/video.ts
new file mode 100644
index 000000000..0a3c15ad8
--- /dev/null
+++ b/server/lib/model-loaders/video.ts
@@ -0,0 +1,73 @@
1import { VideoModel } from '@server/models/video/video'
2import {
3 MVideoAccountLightBlacklistAllFiles,
4 MVideoFormattableDetails,
5 MVideoFullLight,
6 MVideoId,
7 MVideoImmutable,
8 MVideoThumbnail
9} from '@server/types/models'
10import { Hooks } from '../plugins/hooks'
11
12type VideoLoadType = 'for-api' | 'all' | 'only-video' | 'id' | 'none' | 'only-immutable-attributes'
13
14function loadVideo (id: number | string, fetchType: 'for-api', userId?: number): Promise<MVideoFormattableDetails>
15function loadVideo (id: number | string, fetchType: 'all', userId?: number): Promise<MVideoFullLight>
16function loadVideo (id: number | string, fetchType: 'only-immutable-attributes'): Promise<MVideoImmutable>
17function loadVideo (id: number | string, fetchType: 'only-video', userId?: number): Promise<MVideoThumbnail>
18function loadVideo (id: number | string, fetchType: 'id' | 'none', userId?: number): Promise<MVideoId>
19function loadVideo (
20 id: number | string,
21 fetchType: VideoLoadType,
22 userId?: number
23): Promise<MVideoFullLight | MVideoThumbnail | MVideoId | MVideoImmutable>
24function loadVideo (
25 id: number | string,
26 fetchType: VideoLoadType,
27 userId?: number
28): Promise<MVideoFullLight | MVideoThumbnail | MVideoId | MVideoImmutable> {
29
30 if (fetchType === 'for-api') {
31 return Hooks.wrapPromiseFun(
32 VideoModel.loadForGetAPI,
33 { id, userId },
34 'filter:api.video.get.result'
35 )
36 }
37
38 if (fetchType === 'all') return VideoModel.loadAndPopulateAccountAndServerAndTags(id, undefined, userId)
39
40 if (fetchType === 'only-immutable-attributes') return VideoModel.loadImmutableAttributes(id)
41
42 if (fetchType === 'only-video') return VideoModel.load(id)
43
44 if (fetchType === 'id' || fetchType === 'none') return VideoModel.loadOnlyId(id)
45}
46
47type VideoLoadByUrlType = 'all' | 'only-video' | 'only-immutable-attributes'
48
49function loadVideoByUrl (url: string, fetchType: 'all'): Promise<MVideoAccountLightBlacklistAllFiles>
50function loadVideoByUrl (url: string, fetchType: 'only-immutable-attributes'): Promise<MVideoImmutable>
51function loadVideoByUrl (url: string, fetchType: 'only-video'): Promise<MVideoThumbnail>
52function loadVideoByUrl (
53 url: string,
54 fetchType: VideoLoadByUrlType
55): Promise<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail | MVideoImmutable>
56function loadVideoByUrl (
57 url: string,
58 fetchType: VideoLoadByUrlType
59): Promise<MVideoAccountLightBlacklistAllFiles | MVideoThumbnail | MVideoImmutable> {
60 if (fetchType === 'all') return VideoModel.loadByUrlAndPopulateAccount(url)
61
62 if (fetchType === 'only-immutable-attributes') return VideoModel.loadByUrlImmutableAttributes(url)
63
64 if (fetchType === 'only-video') return VideoModel.loadByUrl(url)
65}
66
67export {
68 VideoLoadType,
69 VideoLoadByUrlType,
70
71 loadVideo,
72 loadVideoByUrl
73}
diff --git a/server/lib/moderation.ts b/server/lib/moderation.ts
index 925d64902..14e00518e 100644
--- a/server/lib/moderation.ts
+++ b/server/lib/moderation.ts
@@ -23,9 +23,9 @@ import { ActivityCreate } from '../../shared/models/activitypub'
23import { VideoObject } from '../../shared/models/activitypub/objects' 23import { VideoObject } from '../../shared/models/activitypub/objects'
24import { VideoCommentObject } from '../../shared/models/activitypub/objects/video-comment-object' 24import { VideoCommentObject } from '../../shared/models/activitypub/objects/video-comment-object'
25import { LiveVideoCreate, VideoCreate, VideoImportCreate } from '../../shared/models/videos' 25import { LiveVideoCreate, VideoCreate, VideoImportCreate } from '../../shared/models/videos'
26import { VideoCommentCreate } from '../../shared/models/videos/video-comment.model' 26import { VideoCommentCreate } from '../../shared/models/videos/comment/video-comment.model'
27import { UserModel } from '../models/account/user' 27import { ActorModel } from '../models/actor/actor'
28import { ActorModel } from '../models/activitypub/actor' 28import { UserModel } from '../models/user/user'
29import { VideoModel } from '../models/video/video' 29import { VideoModel } from '../models/video/video'
30import { VideoCommentModel } from '../models/video/video-comment' 30import { VideoCommentModel } from '../models/video/video-comment'
31import { sendAbuse } from './activitypub/send/send-flag' 31import { sendAbuse } from './activitypub/send/send-flag'
@@ -221,7 +221,7 @@ async function createAbuse (options: {
221 const { isOwned } = await associateFun(abuseInstance) 221 const { isOwned } = await associateFun(abuseInstance)
222 222
223 if (isOwned === false) { 223 if (isOwned === false) {
224 await sendAbuse(reporterAccount.Actor, abuseInstance, abuseInstance.FlaggedAccount, transaction) 224 sendAbuse(reporterAccount.Actor, abuseInstance, abuseInstance.FlaggedAccount, transaction)
225 } 225 }
226 226
227 const abuseJSON = abuseInstance.toFormattedAdminJSON() 227 const abuseJSON = abuseInstance.toFormattedAdminJSON()
diff --git a/server/lib/notifier.ts b/server/lib/notifier.ts
index da7f7cc05..1f9ff16df 100644
--- a/server/lib/notifier.ts
+++ b/server/lib/notifier.ts
@@ -17,8 +17,8 @@ import { VideoPrivacy, VideoState } from '../../shared/models/videos'
17import { logger } from '../helpers/logger' 17import { logger } from '../helpers/logger'
18import { CONFIG } from '../initializers/config' 18import { CONFIG } from '../initializers/config'
19import { AccountBlocklistModel } from '../models/account/account-blocklist' 19import { AccountBlocklistModel } from '../models/account/account-blocklist'
20import { UserModel } from '../models/account/user' 20import { UserModel } from '../models/user/user'
21import { UserNotificationModel } from '../models/account/user-notification' 21import { UserNotificationModel } from '../models/user/user-notification'
22import { MAbuseFull, MAbuseMessage, MAccountServer, MActorFollowFull, MApplication, MPlugin } from '../types/models' 22import { MAbuseFull, MAbuseMessage, MAccountServer, MActorFollowFull, MApplication, MPlugin } from '../types/models'
23import { MCommentOwnerVideo, MVideoAccountLight, MVideoFullLight } from '../types/models/video' 23import { MCommentOwnerVideo, MVideoAccountLight, MVideoFullLight } from '../types/models/video'
24import { isBlockedByServerOrAccount } from './blocklist' 24import { isBlockedByServerOrAccount } from './blocklist'
diff --git a/server/lib/plugins/hooks.ts b/server/lib/plugins/hooks.ts
index aa92f03cc..5e97b52a0 100644
--- a/server/lib/plugins/hooks.ts
+++ b/server/lib/plugins/hooks.ts
@@ -1,7 +1,7 @@
1import { ServerActionHookName, ServerFilterHookName } from '../../../shared/models/plugins/server-hook.model'
2import { PluginManager } from './plugin-manager'
3import { logger } from '../../helpers/logger'
4import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { ServerActionHookName, ServerFilterHookName } from '../../../shared/models'
3import { logger } from '../../helpers/logger'
4import { PluginManager } from './plugin-manager'
5 5
6type PromiseFunction <U, T> = (params: U) => Promise<T> | Bluebird<T> 6type PromiseFunction <U, T> = (params: U) => Promise<T> | Bluebird<T>
7type RawFunction <U, T> = (params: U) => T 7type RawFunction <U, T> = (params: U) => T
diff --git a/server/lib/plugins/plugin-helpers-builder.ts b/server/lib/plugins/plugin-helpers-builder.ts
index f1bc24d8b..8487672ba 100644
--- a/server/lib/plugins/plugin-helpers-builder.ts
+++ b/server/lib/plugins/plugin-helpers-builder.ts
@@ -15,9 +15,9 @@ import { MPlugin } from '@server/types/models'
15import { PeerTubeHelpers } from '@server/types/plugins' 15import { PeerTubeHelpers } from '@server/types/plugins'
16import { VideoBlacklistCreate } from '@shared/models' 16import { VideoBlacklistCreate } from '@shared/models'
17import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../blocklist' 17import { addAccountInBlocklist, addServerInBlocklist, removeAccountFromBlocklist, removeServerFromBlocklist } from '../blocklist'
18import { getServerConfig } from '../config' 18import { ServerConfigManager } from '../server-config-manager'
19import { blacklistVideo, unblacklistVideo } from '../video-blacklist' 19import { blacklistVideo, unblacklistVideo } from '../video-blacklist'
20import { UserModel } from '@server/models/account/user' 20import { UserModel } from '@server/models/user/user'
21 21
22function buildPluginHelpers (pluginModel: MPlugin, npmName: string): PeerTubeHelpers { 22function buildPluginHelpers (pluginModel: MPlugin, npmName: string): PeerTubeHelpers {
23 const logger = buildPluginLogger(npmName) 23 const logger = buildPluginLogger(npmName)
@@ -147,7 +147,7 @@ function buildConfigHelpers () {
147 }, 147 },
148 148
149 getServerConfig () { 149 getServerConfig () {
150 return getServerConfig() 150 return ServerConfigManager.Instance.getServerConfig()
151 } 151 }
152 } 152 }
153} 153}
diff --git a/server/lib/plugins/plugin-index.ts b/server/lib/plugins/plugin-index.ts
index 165bc91b3..119cee8e0 100644
--- a/server/lib/plugins/plugin-index.ts
+++ b/server/lib/plugins/plugin-index.ts
@@ -1,16 +1,16 @@
1import { sanitizeUrl } from '@server/helpers/core-utils' 1import { sanitizeUrl } from '@server/helpers/core-utils'
2import { ResultList } from '../../../shared/models' 2import { logger } from '@server/helpers/logger'
3import { PeertubePluginIndexList } from '../../../shared/models/plugins/peertube-plugin-index-list.model' 3import { doJSONRequest } from '@server/helpers/requests'
4import { PeerTubePluginIndex } from '../../../shared/models/plugins/peertube-plugin-index.model' 4import { CONFIG } from '@server/initializers/config'
5import { PEERTUBE_VERSION } from '@server/initializers/constants'
6import { PluginModel } from '@server/models/server/plugin'
5import { 7import {
8 PeerTubePluginIndex,
9 PeertubePluginIndexList,
6 PeertubePluginLatestVersionRequest, 10 PeertubePluginLatestVersionRequest,
7 PeertubePluginLatestVersionResponse 11 PeertubePluginLatestVersionResponse,
8} from '../../../shared/models/plugins/peertube-plugin-latest-version.model' 12 ResultList
9import { logger } from '../../helpers/logger' 13} from '@shared/models'
10import { doJSONRequest } from '../../helpers/requests'
11import { CONFIG } from '../../initializers/config'
12import { PEERTUBE_VERSION } from '../../initializers/constants'
13import { PluginModel } from '../../models/server/plugin'
14import { PluginManager } from './plugin-manager' 14import { PluginManager } from './plugin-manager'
15 15
16async function listAvailablePluginsFromIndex (options: PeertubePluginIndexList) { 16async function listAvailablePluginsFromIndex (options: PeertubePluginIndexList) {
diff --git a/server/lib/plugins/plugin-manager.ts b/server/lib/plugins/plugin-manager.ts
index ba9814383..6599bccca 100644
--- a/server/lib/plugins/plugin-manager.ts
+++ b/server/lib/plugins/plugin-manager.ts
@@ -4,16 +4,11 @@ import { createReadStream, createWriteStream } from 'fs'
4import { ensureDir, outputFile, readJSON } from 'fs-extra' 4import { ensureDir, outputFile, readJSON } from 'fs-extra'
5import { basename, join } from 'path' 5import { basename, join } from 'path'
6import { MOAuthTokenUser, MUser } from '@server/types/models' 6import { MOAuthTokenUser, MUser } from '@server/types/models'
7import { RegisterServerHookOptions } from '@shared/models/plugins/register-server-hook.model' 7import { getCompleteLocale } from '@shared/core-utils'
8import { ClientScript, PluginPackageJson, PluginTranslation, PluginTranslationPaths, RegisterServerHookOptions } from '@shared/models'
8import { getHookType, internalRunHook } from '../../../shared/core-utils/plugins/hooks' 9import { getHookType, internalRunHook } from '../../../shared/core-utils/plugins/hooks'
9import {
10 ClientScript,
11 PluginPackageJson,
12 PluginTranslationPaths as PackagePluginTranslations
13} from '../../../shared/models/plugins/plugin-package-json.model'
14import { PluginTranslation } from '../../../shared/models/plugins/plugin-translation.model'
15import { PluginType } from '../../../shared/models/plugins/plugin.type' 10import { PluginType } from '../../../shared/models/plugins/plugin.type'
16import { ServerHook, ServerHookName } from '../../../shared/models/plugins/server-hook.model' 11import { ServerHook, ServerHookName } from '../../../shared/models/plugins/server/server-hook.model'
17import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins' 12import { isLibraryCodeValid, isPackageJSONValid } from '../../helpers/custom-validators/plugins'
18import { logger } from '../../helpers/logger' 13import { logger } from '../../helpers/logger'
19import { CONFIG } from '../../initializers/config' 14import { CONFIG } from '../../initializers/config'
@@ -23,7 +18,6 @@ import { PluginLibrary, RegisterServerAuthExternalOptions, RegisterServerAuthPas
23import { ClientHtml } from '../client-html' 18import { ClientHtml } from '../client-html'
24import { RegisterHelpers } from './register-helpers' 19import { RegisterHelpers } from './register-helpers'
25import { installNpmPlugin, installNpmPluginFromDisk, removeNpmPlugin } from './yarn' 20import { installNpmPlugin, installNpmPluginFromDisk, removeNpmPlugin } from './yarn'
26import { getCompleteLocale } from '@shared/core-utils'
27 21
28export interface RegisteredPlugin { 22export interface RegisteredPlugin {
29 npmName: string 23 npmName: string
@@ -310,22 +304,28 @@ export class PluginManager implements ServerHook {
310 uninstalled: false, 304 uninstalled: false,
311 peertubeEngine: packageJSON.engine.peertube 305 peertubeEngine: packageJSON.engine.peertube
312 }, { returning: true }) 306 }, { returning: true })
313 } catch (err) { 307
314 logger.error('Cannot install plugin %s, removing it...', toInstall, { err }) 308 logger.info('Successful installation of plugin %s.', toInstall)
309
310 await this.registerPluginOrTheme(plugin)
311 } catch (rootErr) {
312 logger.error('Cannot install plugin %s, removing it...', toInstall, { err: rootErr })
315 313
316 try { 314 try {
317 await removeNpmPlugin(npmName) 315 await this.uninstall(npmName)
318 } catch (err) { 316 } catch (err) {
319 logger.error('Cannot remove plugin %s after failed installation.', toInstall, { err }) 317 logger.error('Cannot uninstall plugin %s after failed installation.', toInstall, { err })
318
319 try {
320 await removeNpmPlugin(npmName)
321 } catch (err) {
322 logger.error('Cannot remove plugin %s after failed installation.', toInstall, { err })
323 }
320 } 324 }
321 325
322 throw err 326 throw rootErr
323 } 327 }
324 328
325 logger.info('Successful installation of plugin %s.', toInstall)
326
327 await this.registerPluginOrTheme(plugin)
328
329 return plugin 329 return plugin
330 } 330 }
331 331
@@ -431,8 +431,7 @@ export class PluginManager implements ServerHook {
431 431
432 await ensureDir(registerOptions.peertubeHelpers.plugin.getDataDirectoryPath()) 432 await ensureDir(registerOptions.peertubeHelpers.plugin.getDataDirectoryPath())
433 433
434 library.register(registerOptions) 434 await library.register(registerOptions)
435 .catch(err => logger.error('Cannot register plugin %s.', npmName, { err }))
436 435
437 logger.info('Add plugin %s CSS to global file.', npmName) 436 logger.info('Add plugin %s CSS to global file.', npmName)
438 437
@@ -443,7 +442,7 @@ export class PluginManager implements ServerHook {
443 442
444 // ###################### Translations ###################### 443 // ###################### Translations ######################
445 444
446 private async addTranslations (plugin: PluginModel, npmName: string, translationPaths: PackagePluginTranslations) { 445 private async addTranslations (plugin: PluginModel, npmName: string, translationPaths: PluginTranslationPaths) {
447 for (const locale of Object.keys(translationPaths)) { 446 for (const locale of Object.keys(translationPaths)) {
448 const path = translationPaths[locale] 447 const path = translationPaths[locale]
449 const json = await readJSON(join(this.getPluginPath(plugin.name, plugin.type), path)) 448 const json = await readJSON(join(this.getPluginPath(plugin.name, plugin.type), path))
diff --git a/server/lib/plugins/register-helpers.ts b/server/lib/plugins/register-helpers.ts
index aa69ca2a2..09275f9ba 100644
--- a/server/lib/plugins/register-helpers.ts
+++ b/server/lib/plugins/register-helpers.ts
@@ -26,10 +26,10 @@ import {
26 PluginVideoLicenceManager, 26 PluginVideoLicenceManager,
27 PluginVideoPrivacyManager, 27 PluginVideoPrivacyManager,
28 RegisterServerHookOptions, 28 RegisterServerHookOptions,
29 RegisterServerSettingOptions 29 RegisterServerSettingOptions,
30 serverHookObject
30} from '@shared/models' 31} from '@shared/models'
31import { serverHookObject } from '@shared/models/plugins/server-hook.model' 32import { VideoTranscodingProfilesManager } from '../transcoding/video-transcoding-profiles'
32import { VideoTranscodingProfilesManager } from '../video-transcoding-profiles'
33import { buildPluginHelpers } from './plugin-helpers-builder' 33import { buildPluginHelpers } from './plugin-helpers-builder'
34 34
35type AlterableVideoConstant = 'language' | 'licence' | 'category' | 'privacy' | 'playlistPrivacy' 35type AlterableVideoConstant = 'language' | 'licence' | 'category' | 'privacy' | 'playlistPrivacy'
@@ -37,18 +37,20 @@ type VideoConstant = { [key in number | string]: string }
37 37
38type UpdatedVideoConstant = { 38type UpdatedVideoConstant = {
39 [name in AlterableVideoConstant]: { 39 [name in AlterableVideoConstant]: {
40 added: { key: number | string, label: string }[] 40 [ npmName: string]: {
41 deleted: { key: number | string, label: string }[] 41 added: { key: number | string, label: string }[]
42 deleted: { key: number | string, label: string }[]
43 }
42 } 44 }
43} 45}
44 46
45export class RegisterHelpers { 47export class RegisterHelpers {
46 private readonly updatedVideoConstants: UpdatedVideoConstant = { 48 private readonly updatedVideoConstants: UpdatedVideoConstant = {
47 playlistPrivacy: { added: [], deleted: [] }, 49 playlistPrivacy: { },
48 privacy: { added: [], deleted: [] }, 50 privacy: { },
49 language: { added: [], deleted: [] }, 51 language: { },
50 licence: { added: [], deleted: [] }, 52 licence: { },
51 category: { added: [], deleted: [] } 53 category: { }
52 } 54 }
53 55
54 private readonly transcodingProfiles: { 56 private readonly transcodingProfiles: {
@@ -377,7 +379,7 @@ export class RegisterHelpers {
377 const { npmName, type, obj, key } = parameters 379 const { npmName, type, obj, key } = parameters
378 380
379 if (!obj[key]) { 381 if (!obj[key]) {
380 logger.warn('Cannot delete %s %s by plugin %s: key does not exist.', type, npmName, key) 382 logger.warn('Cannot delete %s by plugin %s: key %s does not exist.', type, npmName, key)
381 return false 383 return false
382 } 384 }
383 385
@@ -388,7 +390,15 @@ export class RegisterHelpers {
388 } 390 }
389 } 391 }
390 392
391 this.updatedVideoConstants[type][npmName].deleted.push({ key, label: obj[key] }) 393 const updatedConstants = this.updatedVideoConstants[type][npmName]
394
395 const alreadyAdded = updatedConstants.added.find(a => a.key === key)
396 if (alreadyAdded) {
397 updatedConstants.added.filter(a => a.key !== key)
398 } else if (obj[key]) {
399 updatedConstants.deleted.push({ key, label: obj[key] })
400 }
401
392 delete obj[key] 402 delete obj[key]
393 403
394 return true 404 return true
diff --git a/server/lib/redundancy.ts b/server/lib/redundancy.ts
index da620b607..2a9241249 100644
--- a/server/lib/redundancy.ts
+++ b/server/lib/redundancy.ts
@@ -1,12 +1,12 @@
1import { VideoRedundancyModel } from '../models/redundancy/video-redundancy'
2import { sendUndoCacheFile } from './activitypub/send'
3import { Transaction } from 'sequelize' 1import { Transaction } from 'sequelize'
4import { MActorSignature, MVideoRedundancyVideo } from '@server/types/models'
5import { CONFIG } from '@server/initializers/config'
6import { logger } from '@server/helpers/logger' 2import { logger } from '@server/helpers/logger'
7import { ActorFollowModel } from '@server/models/activitypub/actor-follow' 3import { CONFIG } from '@server/initializers/config'
8import { Activity } from '@shared/models' 4import { ActorFollowModel } from '@server/models/actor/actor-follow'
9import { getServerActor } from '@server/models/application/application' 5import { getServerActor } from '@server/models/application/application'
6import { MActorSignature, MVideoRedundancyVideo } from '@server/types/models'
7import { Activity } from '@shared/models'
8import { VideoRedundancyModel } from '../models/redundancy/video-redundancy'
9import { sendUndoCacheFile } from './activitypub/send'
10 10
11async function removeVideoRedundancy (videoRedundancy: MVideoRedundancyVideo, t?: Transaction) { 11async function removeVideoRedundancy (videoRedundancy: MVideoRedundancyVideo, t?: Transaction) {
12 const serverActor = await getServerActor() 12 const serverActor = await getServerActor()
diff --git a/server/lib/schedulers/actor-follow-scheduler.ts b/server/lib/schedulers/actor-follow-scheduler.ts
index 598c0211f..1b80316e9 100644
--- a/server/lib/schedulers/actor-follow-scheduler.ts
+++ b/server/lib/schedulers/actor-follow-scheduler.ts
@@ -1,9 +1,9 @@
1import { isTestInstance } from '../../helpers/core-utils' 1import { isTestInstance } from '../../helpers/core-utils'
2import { logger } from '../../helpers/logger' 2import { logger } from '../../helpers/logger'
3import { ActorFollowModel } from '../../models/activitypub/actor-follow'
4import { AbstractScheduler } from './abstract-scheduler'
5import { ACTOR_FOLLOW_SCORE, SCHEDULER_INTERVALS_MS } from '../../initializers/constants' 3import { ACTOR_FOLLOW_SCORE, SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
4import { ActorFollowModel } from '../../models/actor/actor-follow'
6import { ActorFollowScoreCache } from '../files-cache' 5import { ActorFollowScoreCache } from '../files-cache'
6import { AbstractScheduler } from './abstract-scheduler'
7 7
8export class ActorFollowScheduler extends AbstractScheduler { 8export class ActorFollowScheduler extends AbstractScheduler {
9 9
diff --git a/server/lib/schedulers/auto-follow-index-instances.ts b/server/lib/schedulers/auto-follow-index-instances.ts
index 0b8cd1389..aaa5feed5 100644
--- a/server/lib/schedulers/auto-follow-index-instances.ts
+++ b/server/lib/schedulers/auto-follow-index-instances.ts
@@ -1,7 +1,7 @@
1import { chunk } from 'lodash' 1import { chunk } from 'lodash'
2import { doJSONRequest } from '@server/helpers/requests' 2import { doJSONRequest } from '@server/helpers/requests'
3import { JobQueue } from '@server/lib/job-queue' 3import { JobQueue } from '@server/lib/job-queue'
4import { ActorFollowModel } from '@server/models/activitypub/actor-follow' 4import { ActorFollowModel } from '@server/models/actor/actor-follow'
5import { getServerActor } from '@server/models/application/application' 5import { getServerActor } from '@server/models/application/application'
6import { logger } from '../../helpers/logger' 6import { logger } from '../../helpers/logger'
7import { CONFIG } from '../../initializers/config' 7import { CONFIG } from '../../initializers/config'
diff --git a/server/lib/schedulers/remove-old-history-scheduler.ts b/server/lib/schedulers/remove-old-history-scheduler.ts
index 17a42b2c4..225669ea2 100644
--- a/server/lib/schedulers/remove-old-history-scheduler.ts
+++ b/server/lib/schedulers/remove-old-history-scheduler.ts
@@ -1,7 +1,7 @@
1import { logger } from '../../helpers/logger' 1import { logger } from '../../helpers/logger'
2import { AbstractScheduler } from './abstract-scheduler' 2import { AbstractScheduler } from './abstract-scheduler'
3import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants' 3import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
4import { UserVideoHistoryModel } from '../../models/account/user-video-history' 4import { UserVideoHistoryModel } from '../../models/user/user-video-history'
5import { CONFIG } from '../../initializers/config' 5import { CONFIG } from '../../initializers/config'
6 6
7export class RemoveOldHistoryScheduler extends AbstractScheduler { 7export class RemoveOldHistoryScheduler extends AbstractScheduler {
diff --git a/server/lib/schedulers/update-videos-scheduler.ts b/server/lib/schedulers/update-videos-scheduler.ts
index 3e75babcb..af69bda89 100644
--- a/server/lib/schedulers/update-videos-scheduler.ts
+++ b/server/lib/schedulers/update-videos-scheduler.ts
@@ -1,12 +1,12 @@
1import { VideoModel } from '@server/models/video/video'
2import { MVideoFullLight } from '@server/types/models'
1import { logger } from '../../helpers/logger' 3import { logger } from '../../helpers/logger'
2import { AbstractScheduler } from './abstract-scheduler' 4import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
5import { sequelizeTypescript } from '../../initializers/database'
3import { ScheduleVideoUpdateModel } from '../../models/video/schedule-video-update' 6import { ScheduleVideoUpdateModel } from '../../models/video/schedule-video-update'
4import { retryTransactionWrapper } from '../../helpers/database-utils'
5import { federateVideoIfNeeded } from '../activitypub/videos' 7import { federateVideoIfNeeded } from '../activitypub/videos'
6import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
7import { Notifier } from '../notifier' 8import { Notifier } from '../notifier'
8import { sequelizeTypescript } from '../../initializers/database' 9import { AbstractScheduler } from './abstract-scheduler'
9import { MVideoFullLight } from '@server/types/models'
10 10
11export class UpdateVideosScheduler extends AbstractScheduler { 11export class UpdateVideosScheduler extends AbstractScheduler {
12 12
@@ -19,18 +19,19 @@ export class UpdateVideosScheduler extends AbstractScheduler {
19 } 19 }
20 20
21 protected async internalExecute () { 21 protected async internalExecute () {
22 return retryTransactionWrapper(this.updateVideos.bind(this)) 22 return this.updateVideos()
23 } 23 }
24 24
25 private async updateVideos () { 25 private async updateVideos () {
26 if (!await ScheduleVideoUpdateModel.areVideosToUpdate()) return undefined 26 if (!await ScheduleVideoUpdateModel.areVideosToUpdate()) return undefined
27 27
28 const publishedVideos = await sequelizeTypescript.transaction(async t => { 28 const schedules = await ScheduleVideoUpdateModel.listVideosToUpdate()
29 const schedules = await ScheduleVideoUpdateModel.listVideosToUpdate(t) 29 const publishedVideos: MVideoFullLight[] = []
30 const publishedVideos: MVideoFullLight[] = [] 30
31 for (const schedule of schedules) {
32 await sequelizeTypescript.transaction(async t => {
33 const video = await VideoModel.loadAndPopulateAccountAndServerAndTags(schedule.videoId, t)
31 34
32 for (const schedule of schedules) {
33 const video = schedule.Video
34 logger.info('Executing scheduled video update on %s.', video.uuid) 35 logger.info('Executing scheduled video update on %s.', video.uuid)
35 36
36 if (schedule.privacy) { 37 if (schedule.privacy) {
@@ -42,16 +43,13 @@ export class UpdateVideosScheduler extends AbstractScheduler {
42 await federateVideoIfNeeded(video, isNewVideo, t) 43 await federateVideoIfNeeded(video, isNewVideo, t)
43 44
44 if (wasConfidentialVideo) { 45 if (wasConfidentialVideo) {
45 const videoToPublish: MVideoFullLight = Object.assign(video, { ScheduleVideoUpdate: schedule, UserVideoHistories: [] }) 46 publishedVideos.push(video)
46 publishedVideos.push(videoToPublish)
47 } 47 }
48 } 48 }
49 49
50 await schedule.destroy({ transaction: t }) 50 await schedule.destroy({ transaction: t })
51 } 51 })
52 52 }
53 return publishedVideos
54 })
55 53
56 for (const v of publishedVideos) { 54 for (const v of publishedVideos) {
57 Notifier.Instance.notifyOnNewVideoIfNeeded(v) 55 Notifier.Instance.notifyOnNewVideoIfNeeded(v)
diff --git a/server/lib/schedulers/videos-redundancy-scheduler.ts b/server/lib/schedulers/videos-redundancy-scheduler.ts
index 59b55cccc..b5a5eb697 100644
--- a/server/lib/schedulers/videos-redundancy-scheduler.ts
+++ b/server/lib/schedulers/videos-redundancy-scheduler.ts
@@ -23,7 +23,7 @@ import { HLS_REDUNDANCY_DIRECTORY, REDUNDANCY, VIDEO_IMPORT_TIMEOUT } from '../.
23import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' 23import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
24import { sendCreateCacheFile, sendUpdateCacheFile } from '../activitypub/send' 24import { sendCreateCacheFile, sendUpdateCacheFile } from '../activitypub/send'
25import { getLocalVideoCacheFileActivityPubUrl, getLocalVideoCacheStreamingPlaylistActivityPubUrl } from '../activitypub/url' 25import { getLocalVideoCacheFileActivityPubUrl, getLocalVideoCacheStreamingPlaylistActivityPubUrl } from '../activitypub/url'
26import { getOrCreateVideoAndAccountAndChannel } from '../activitypub/videos' 26import { getOrCreateAPVideo } from '../activitypub/videos'
27import { downloadPlaylistSegments } from '../hls' 27import { downloadPlaylistSegments } from '../hls'
28import { removeVideoRedundancy } from '../redundancy' 28import { removeVideoRedundancy } from '../redundancy'
29import { generateHLSRedundancyUrl, generateWebTorrentRedundancyUrl } from '../video-paths' 29import { generateHLSRedundancyUrl, generateWebTorrentRedundancyUrl } from '../video-paths'
@@ -351,7 +351,7 @@ export class VideosRedundancyScheduler extends AbstractScheduler {
351 syncParam: { likes: false, dislikes: false, shares: false, comments: false, thumbnail: false, refreshVideo: true }, 351 syncParam: { likes: false, dislikes: false, shares: false, comments: false, thumbnail: false, refreshVideo: true },
352 fetchType: 'all' as 'all' 352 fetchType: 'all' as 'all'
353 } 353 }
354 const { video } = await getOrCreateVideoAndAccountAndChannel(getVideoOptions) 354 const { video } = await getOrCreateAPVideo(getVideoOptions)
355 355
356 return video 356 return video
357 } 357 }
diff --git a/server/lib/schedulers/youtube-dl-update-scheduler.ts b/server/lib/schedulers/youtube-dl-update-scheduler.ts
index aefe6aba4..898691c13 100644
--- a/server/lib/schedulers/youtube-dl-update-scheduler.ts
+++ b/server/lib/schedulers/youtube-dl-update-scheduler.ts
@@ -1,6 +1,6 @@
1import { AbstractScheduler } from './abstract-scheduler' 1import { YoutubeDL } from '@server/helpers/youtube-dl'
2import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants' 2import { SCHEDULER_INTERVALS_MS } from '../../initializers/constants'
3import { updateYoutubeDLBinary } from '../../helpers/youtube-dl' 3import { AbstractScheduler } from './abstract-scheduler'
4 4
5export class YoutubeDlUpdateScheduler extends AbstractScheduler { 5export class YoutubeDlUpdateScheduler extends AbstractScheduler {
6 6
@@ -13,7 +13,7 @@ export class YoutubeDlUpdateScheduler extends AbstractScheduler {
13 } 13 }
14 14
15 protected internalExecute () { 15 protected internalExecute () {
16 return updateYoutubeDLBinary() 16 return YoutubeDL.updateYoutubeDLBinary()
17 } 17 }
18 18
19 static get Instance () { 19 static get Instance () {
diff --git a/server/lib/search.ts b/server/lib/search.ts
new file mode 100644
index 000000000..b643a4055
--- /dev/null
+++ b/server/lib/search.ts
@@ -0,0 +1,50 @@
1import * as express from 'express'
2import { CONFIG } from '@server/initializers/config'
3import { AccountBlocklistModel } from '@server/models/account/account-blocklist'
4import { getServerActor } from '@server/models/application/application'
5import { ServerBlocklistModel } from '@server/models/server/server-blocklist'
6import { SearchTargetQuery } from '@shared/models'
7
8function isSearchIndexSearch (query: SearchTargetQuery) {
9 if (query.searchTarget === 'search-index') return true
10
11 const searchIndexConfig = CONFIG.SEARCH.SEARCH_INDEX
12
13 if (searchIndexConfig.ENABLED !== true) return false
14
15 if (searchIndexConfig.DISABLE_LOCAL_SEARCH) return true
16 if (searchIndexConfig.IS_DEFAULT_SEARCH && !query.searchTarget) return true
17
18 return false
19}
20
21async function buildMutedForSearchIndex (res: express.Response) {
22 const serverActor = await getServerActor()
23 const accountIds = [ serverActor.Account.id ]
24
25 if (res.locals.oauth) {
26 accountIds.push(res.locals.oauth.token.User.Account.id)
27 }
28
29 const [ blockedHosts, blockedAccounts ] = await Promise.all([
30 ServerBlocklistModel.listHostsBlockedBy(accountIds),
31 AccountBlocklistModel.listHandlesBlockedBy(accountIds)
32 ])
33
34 return {
35 blockedHosts,
36 blockedAccounts
37 }
38}
39
40function isURISearch (search: string) {
41 if (!search) return false
42
43 return search.startsWith('http://') || search.startsWith('https://')
44}
45
46export {
47 isSearchIndexSearch,
48 buildMutedForSearchIndex,
49 isURISearch
50}
diff --git a/server/lib/server-config-manager.ts b/server/lib/server-config-manager.ts
new file mode 100644
index 000000000..80d87a9d3
--- /dev/null
+++ b/server/lib/server-config-manager.ts
@@ -0,0 +1,304 @@
1import { getServerCommit } from '@server/helpers/utils'
2import { CONFIG, isEmailEnabled } from '@server/initializers/config'
3import { CONSTRAINTS_FIELDS, DEFAULT_THEME_NAME, PEERTUBE_VERSION } from '@server/initializers/constants'
4import { isSignupAllowed, isSignupAllowedForCurrentIP } from '@server/lib/signup'
5import { ActorCustomPageModel } from '@server/models/account/actor-custom-page'
6import { HTMLServerConfig, RegisteredExternalAuthConfig, RegisteredIdAndPassAuthConfig, ServerConfig } from '@shared/models'
7import { Hooks } from './plugins/hooks'
8import { PluginManager } from './plugins/plugin-manager'
9import { getThemeOrDefault } from './plugins/theme-utils'
10import { VideoTranscodingProfilesManager } from './transcoding/video-transcoding-profiles'
11
12/**
13 *
14 * Used to send the server config to clients (using REST/API or plugins API)
15 * We need a singleton class to manage config state depending on external events (to build menu entries etc)
16 *
17 */
18
19class ServerConfigManager {
20
21 private static instance: ServerConfigManager
22
23 private serverCommit: string
24
25 private homepageEnabled = false
26
27 private constructor () {}
28
29 async init () {
30 const instanceHomepage = await ActorCustomPageModel.loadInstanceHomepage()
31
32 this.updateHomepageState(instanceHomepage?.content)
33 }
34
35 updateHomepageState (content: string) {
36 this.homepageEnabled = !!content
37 }
38
39 async getHTMLServerConfig (): Promise<HTMLServerConfig> {
40 if (this.serverCommit === undefined) this.serverCommit = await getServerCommit()
41
42 const defaultTheme = getThemeOrDefault(CONFIG.THEME.DEFAULT, DEFAULT_THEME_NAME)
43
44 return {
45 instance: {
46 name: CONFIG.INSTANCE.NAME,
47 shortDescription: CONFIG.INSTANCE.SHORT_DESCRIPTION,
48 isNSFW: CONFIG.INSTANCE.IS_NSFW,
49 defaultNSFWPolicy: CONFIG.INSTANCE.DEFAULT_NSFW_POLICY,
50 defaultClientRoute: CONFIG.INSTANCE.DEFAULT_CLIENT_ROUTE,
51 customizations: {
52 javascript: CONFIG.INSTANCE.CUSTOMIZATIONS.JAVASCRIPT,
53 css: CONFIG.INSTANCE.CUSTOMIZATIONS.CSS
54 }
55 },
56 search: {
57 remoteUri: {
58 users: CONFIG.SEARCH.REMOTE_URI.USERS,
59 anonymous: CONFIG.SEARCH.REMOTE_URI.ANONYMOUS
60 },
61 searchIndex: {
62 enabled: CONFIG.SEARCH.SEARCH_INDEX.ENABLED,
63 url: CONFIG.SEARCH.SEARCH_INDEX.URL,
64 disableLocalSearch: CONFIG.SEARCH.SEARCH_INDEX.DISABLE_LOCAL_SEARCH,
65 isDefaultSearch: CONFIG.SEARCH.SEARCH_INDEX.IS_DEFAULT_SEARCH
66 }
67 },
68 plugin: {
69 registered: this.getRegisteredPlugins(),
70 registeredExternalAuths: this.getExternalAuthsPlugins(),
71 registeredIdAndPassAuths: this.getIdAndPassAuthPlugins()
72 },
73 theme: {
74 registered: this.getRegisteredThemes(),
75 default: defaultTheme
76 },
77 email: {
78 enabled: isEmailEnabled()
79 },
80 contactForm: {
81 enabled: CONFIG.CONTACT_FORM.ENABLED
82 },
83 serverVersion: PEERTUBE_VERSION,
84 serverCommit: this.serverCommit,
85 transcoding: {
86 hls: {
87 enabled: CONFIG.TRANSCODING.HLS.ENABLED
88 },
89 webtorrent: {
90 enabled: CONFIG.TRANSCODING.WEBTORRENT.ENABLED
91 },
92 enabledResolutions: this.getEnabledResolutions('vod'),
93 profile: CONFIG.TRANSCODING.PROFILE,
94 availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('vod')
95 },
96 live: {
97 enabled: CONFIG.LIVE.ENABLED,
98
99 allowReplay: CONFIG.LIVE.ALLOW_REPLAY,
100 maxDuration: CONFIG.LIVE.MAX_DURATION,
101 maxInstanceLives: CONFIG.LIVE.MAX_INSTANCE_LIVES,
102 maxUserLives: CONFIG.LIVE.MAX_USER_LIVES,
103
104 transcoding: {
105 enabled: CONFIG.LIVE.TRANSCODING.ENABLED,
106 enabledResolutions: this.getEnabledResolutions('live'),
107 profile: CONFIG.LIVE.TRANSCODING.PROFILE,
108 availableProfiles: VideoTranscodingProfilesManager.Instance.getAvailableProfiles('live')
109 },
110
111 rtmp: {
112 port: CONFIG.LIVE.RTMP.PORT
113 }
114 },
115 import: {
116 videos: {
117 http: {
118 enabled: CONFIG.IMPORT.VIDEOS.HTTP.ENABLED
119 },
120 torrent: {
121 enabled: CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED
122 }
123 }
124 },
125 autoBlacklist: {
126 videos: {
127 ofUsers: {
128 enabled: CONFIG.AUTO_BLACKLIST.VIDEOS.OF_USERS.ENABLED
129 }
130 }
131 },
132 avatar: {
133 file: {
134 size: {
135 max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
136 },
137 extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
138 }
139 },
140 banner: {
141 file: {
142 size: {
143 max: CONSTRAINTS_FIELDS.ACTORS.IMAGE.FILE_SIZE.max
144 },
145 extensions: CONSTRAINTS_FIELDS.ACTORS.IMAGE.EXTNAME
146 }
147 },
148 video: {
149 image: {
150 extensions: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.EXTNAME,
151 size: {
152 max: CONSTRAINTS_FIELDS.VIDEOS.IMAGE.FILE_SIZE.max
153 }
154 },
155 file: {
156 extensions: CONSTRAINTS_FIELDS.VIDEOS.EXTNAME
157 }
158 },
159 videoCaption: {
160 file: {
161 size: {
162 max: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.FILE_SIZE.max
163 },
164 extensions: CONSTRAINTS_FIELDS.VIDEO_CAPTIONS.CAPTION_FILE.EXTNAME
165 }
166 },
167 user: {
168 videoQuota: CONFIG.USER.VIDEO_QUOTA,
169 videoQuotaDaily: CONFIG.USER.VIDEO_QUOTA_DAILY
170 },
171 trending: {
172 videos: {
173 intervalDays: CONFIG.TRENDING.VIDEOS.INTERVAL_DAYS,
174 algorithms: {
175 enabled: CONFIG.TRENDING.VIDEOS.ALGORITHMS.ENABLED,
176 default: CONFIG.TRENDING.VIDEOS.ALGORITHMS.DEFAULT
177 }
178 }
179 },
180 tracker: {
181 enabled: CONFIG.TRACKER.ENABLED
182 },
183
184 followings: {
185 instance: {
186 autoFollowIndex: {
187 indexUrl: CONFIG.FOLLOWINGS.INSTANCE.AUTO_FOLLOW_INDEX.INDEX_URL
188 }
189 }
190 },
191
192 broadcastMessage: {
193 enabled: CONFIG.BROADCAST_MESSAGE.ENABLED,
194 message: CONFIG.BROADCAST_MESSAGE.MESSAGE,
195 level: CONFIG.BROADCAST_MESSAGE.LEVEL,
196 dismissable: CONFIG.BROADCAST_MESSAGE.DISMISSABLE
197 },
198
199 homepage: {
200 enabled: this.homepageEnabled
201 }
202 }
203 }
204
205 async getServerConfig (ip?: string): Promise<ServerConfig> {
206 const { allowed } = await Hooks.wrapPromiseFun(
207 isSignupAllowed,
208 {
209 ip
210 },
211 'filter:api.user.signup.allowed.result'
212 )
213
214 const allowedForCurrentIP = isSignupAllowedForCurrentIP(ip)
215
216 const signup = {
217 allowed,
218 allowedForCurrentIP,
219 minimumAge: CONFIG.SIGNUP.MINIMUM_AGE,
220 requiresEmailVerification: CONFIG.SIGNUP.REQUIRES_EMAIL_VERIFICATION
221 }
222
223 const htmlConfig = await this.getHTMLServerConfig()
224
225 return { ...htmlConfig, signup }
226 }
227
228 getRegisteredThemes () {
229 return PluginManager.Instance.getRegisteredThemes()
230 .map(t => ({
231 name: t.name,
232 version: t.version,
233 description: t.description,
234 css: t.css,
235 clientScripts: t.clientScripts
236 }))
237 }
238
239 getRegisteredPlugins () {
240 return PluginManager.Instance.getRegisteredPlugins()
241 .map(p => ({
242 name: p.name,
243 version: p.version,
244 description: p.description,
245 clientScripts: p.clientScripts
246 }))
247 }
248
249 getEnabledResolutions (type: 'vod' | 'live') {
250 const transcoding = type === 'vod'
251 ? CONFIG.TRANSCODING
252 : CONFIG.LIVE.TRANSCODING
253
254 return Object.keys(transcoding.RESOLUTIONS)
255 .filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true)
256 .map(r => parseInt(r, 10))
257 }
258
259 private getIdAndPassAuthPlugins () {
260 const result: RegisteredIdAndPassAuthConfig[] = []
261
262 for (const p of PluginManager.Instance.getIdAndPassAuths()) {
263 for (const auth of p.idAndPassAuths) {
264 result.push({
265 npmName: p.npmName,
266 name: p.name,
267 version: p.version,
268 authName: auth.authName,
269 weight: auth.getWeight()
270 })
271 }
272 }
273
274 return result
275 }
276
277 private getExternalAuthsPlugins () {
278 const result: RegisteredExternalAuthConfig[] = []
279
280 for (const p of PluginManager.Instance.getExternalAuths()) {
281 for (const auth of p.externalAuths) {
282 result.push({
283 npmName: p.npmName,
284 name: p.name,
285 version: p.version,
286 authName: auth.authName,
287 authDisplayName: auth.authDisplayName()
288 })
289 }
290 }
291
292 return result
293 }
294
295 static get Instance () {
296 return this.instance || (this.instance = new this())
297 }
298}
299
300// ---------------------------------------------------------------------------
301
302export {
303 ServerConfigManager
304}
diff --git a/server/helpers/signup.ts b/server/lib/signup.ts
index ed872539b..8fa81e601 100644
--- a/server/helpers/signup.ts
+++ b/server/lib/signup.ts
@@ -1,4 +1,4 @@
1import { UserModel } from '../models/account/user' 1import { UserModel } from '../models/user/user'
2import * as ipaddr from 'ipaddr.js' 2import * as ipaddr from 'ipaddr.js'
3import { CONFIG } from '../initializers/config' 3import { CONFIG } from '../initializers/config'
4 4
diff --git a/server/lib/stat-manager.ts b/server/lib/stat-manager.ts
index 5d703f610..3c5e0a93e 100644
--- a/server/lib/stat-manager.ts
+++ b/server/lib/stat-manager.ts
@@ -1,6 +1,6 @@
1import { CONFIG } from '@server/initializers/config' 1import { CONFIG } from '@server/initializers/config'
2import { UserModel } from '@server/models/account/user' 2import { UserModel } from '@server/models/user/user'
3import { ActorFollowModel } from '@server/models/activitypub/actor-follow' 3import { ActorFollowModel } from '@server/models/actor/actor-follow'
4import { VideoRedundancyModel } from '@server/models/redundancy/video-redundancy' 4import { VideoRedundancyModel } from '@server/models/redundancy/video-redundancy'
5import { VideoModel } from '@server/models/video/video' 5import { VideoModel } from '@server/models/video/video'
6import { VideoChannelModel } from '@server/models/video/video-channel' 6import { VideoChannelModel } from '@server/models/video/video-channel'
diff --git a/server/lib/thumbnail.ts b/server/lib/thumbnail.ts
index cfee69cfc..c08523988 100644
--- a/server/lib/thumbnail.ts
+++ b/server/lib/thumbnail.ts
@@ -14,7 +14,7 @@ import { getVideoFilePath } from './video-paths'
14 14
15type ImageSize = { height?: number, width?: number } 15type ImageSize = { height?: number, width?: number }
16 16
17function createPlaylistMiniatureFromExisting (options: { 17function updatePlaylistMiniatureFromExisting (options: {
18 inputPath: string 18 inputPath: string
19 playlist: MVideoPlaylistThumbnail 19 playlist: MVideoPlaylistThumbnail
20 automaticallyGenerated: boolean 20 automaticallyGenerated: boolean
@@ -26,7 +26,7 @@ function createPlaylistMiniatureFromExisting (options: {
26 const type = ThumbnailType.MINIATURE 26 const type = ThumbnailType.MINIATURE
27 27
28 const thumbnailCreator = () => processImage(inputPath, outputPath, { width, height }, keepOriginal) 28 const thumbnailCreator = () => processImage(inputPath, outputPath, { width, height }, keepOriginal)
29 return createThumbnailFromFunction({ 29 return updateThumbnailFromFunction({
30 thumbnailCreator, 30 thumbnailCreator,
31 filename, 31 filename,
32 height, 32 height,
@@ -37,7 +37,7 @@ function createPlaylistMiniatureFromExisting (options: {
37 }) 37 })
38} 38}
39 39
40function createPlaylistMiniatureFromUrl (options: { 40function updatePlaylistMiniatureFromUrl (options: {
41 downloadUrl: string 41 downloadUrl: string
42 playlist: MVideoPlaylistThumbnail 42 playlist: MVideoPlaylistThumbnail
43 size?: ImageSize 43 size?: ImageSize
@@ -52,10 +52,10 @@ function createPlaylistMiniatureFromUrl (options: {
52 : downloadUrl 52 : downloadUrl
53 53
54 const thumbnailCreator = () => downloadImage(downloadUrl, basePath, filename, { width, height }) 54 const thumbnailCreator = () => downloadImage(downloadUrl, basePath, filename, { width, height })
55 return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl }) 55 return updateThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl })
56} 56}
57 57
58function createVideoMiniatureFromUrl (options: { 58function updateVideoMiniatureFromUrl (options: {
59 downloadUrl: string 59 downloadUrl: string
60 video: MVideoThumbnail 60 video: MVideoThumbnail
61 type: ThumbnailType 61 type: ThumbnailType
@@ -82,10 +82,10 @@ function createVideoMiniatureFromUrl (options: {
82 return Promise.resolve() 82 return Promise.resolve()
83 } 83 }
84 84
85 return createThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl }) 85 return updateThumbnailFromFunction({ thumbnailCreator, filename, height, width, type, existingThumbnail, fileUrl })
86} 86}
87 87
88function createVideoMiniatureFromExisting (options: { 88function updateVideoMiniatureFromExisting (options: {
89 inputPath: string 89 inputPath: string
90 video: MVideoThumbnail 90 video: MVideoThumbnail
91 type: ThumbnailType 91 type: ThumbnailType
@@ -98,7 +98,7 @@ function createVideoMiniatureFromExisting (options: {
98 const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size) 98 const { filename, outputPath, height, width, existingThumbnail } = buildMetadataFromVideo(video, type, size)
99 const thumbnailCreator = () => processImage(inputPath, outputPath, { width, height }, keepOriginal) 99 const thumbnailCreator = () => processImage(inputPath, outputPath, { width, height }, keepOriginal)
100 100
101 return createThumbnailFromFunction({ 101 return updateThumbnailFromFunction({
102 thumbnailCreator, 102 thumbnailCreator,
103 filename, 103 filename,
104 height, 104 height,
@@ -123,7 +123,7 @@ function generateVideoMiniature (options: {
123 ? () => processImage(ASSETS_PATH.DEFAULT_AUDIO_BACKGROUND, outputPath, { width, height }, true) 123 ? () => processImage(ASSETS_PATH.DEFAULT_AUDIO_BACKGROUND, outputPath, { width, height }, true)
124 : () => generateImageFromVideoFile(input, basePath, filename, { height, width }) 124 : () => generateImageFromVideoFile(input, basePath, filename, { height, width })
125 125
126 return createThumbnailFromFunction({ 126 return updateThumbnailFromFunction({
127 thumbnailCreator, 127 thumbnailCreator,
128 filename, 128 filename,
129 height, 129 height,
@@ -134,7 +134,7 @@ function generateVideoMiniature (options: {
134 }) 134 })
135} 135}
136 136
137function createPlaceholderThumbnail (options: { 137function updatePlaceholderThumbnail (options: {
138 fileUrl: string 138 fileUrl: string
139 video: MVideoThumbnail 139 video: MVideoThumbnail
140 type: ThumbnailType 140 type: ThumbnailType
@@ -165,11 +165,11 @@ function createPlaceholderThumbnail (options: {
165 165
166export { 166export {
167 generateVideoMiniature, 167 generateVideoMiniature,
168 createVideoMiniatureFromUrl, 168 updateVideoMiniatureFromUrl,
169 createVideoMiniatureFromExisting, 169 updateVideoMiniatureFromExisting,
170 createPlaceholderThumbnail, 170 updatePlaceholderThumbnail,
171 createPlaylistMiniatureFromUrl, 171 updatePlaylistMiniatureFromUrl,
172 createPlaylistMiniatureFromExisting 172 updatePlaylistMiniatureFromExisting
173} 173}
174 174
175function hasThumbnailUrlChanged (existingThumbnail: MThumbnail, downloadUrl: string, video: MVideoUUID) { 175function hasThumbnailUrlChanged (existingThumbnail: MThumbnail, downloadUrl: string, video: MVideoUUID) {
@@ -231,7 +231,7 @@ function buildMetadataFromVideo (video: MVideoThumbnail, type: ThumbnailType, si
231 return undefined 231 return undefined
232} 232}
233 233
234async function createThumbnailFromFunction (parameters: { 234async function updateThumbnailFromFunction (parameters: {
235 thumbnailCreator: () => Promise<any> 235 thumbnailCreator: () => Promise<any>
236 filename: string 236 filename: string
237 height: number 237 height: number
diff --git a/server/lib/video-transcoding-profiles.ts b/server/lib/transcoding/video-transcoding-profiles.ts
index 81f5e1962..c5ea72a5f 100644
--- a/server/lib/video-transcoding-profiles.ts
+++ b/server/lib/transcoding/video-transcoding-profiles.ts
@@ -1,6 +1,6 @@
1import { logger } from '@server/helpers/logger' 1import { logger } from '@server/helpers/logger'
2import { AvailableEncoders, EncoderOptionsBuilder, getTargetBitrate, VideoResolution } from '../../shared/models/videos' 2import { AvailableEncoders, EncoderOptionsBuilder, getTargetBitrate, VideoResolution } from '../../../shared/models/videos'
3import { buildStreamSuffix, resetSupportedEncoders } from '../helpers/ffmpeg-utils' 3import { buildStreamSuffix, resetSupportedEncoders } from '../../helpers/ffmpeg-utils'
4import { 4import {
5 canDoQuickAudioTranscode, 5 canDoQuickAudioTranscode,
6 ffprobePromise, 6 ffprobePromise,
@@ -8,8 +8,8 @@ import {
8 getMaxAudioBitrate, 8 getMaxAudioBitrate,
9 getVideoFileBitrate, 9 getVideoFileBitrate,
10 getVideoStreamFromFile 10 getVideoStreamFromFile
11} from '../helpers/ffprobe-utils' 11} from '../../helpers/ffprobe-utils'
12import { VIDEO_TRANSCODING_FPS } from '../initializers/constants' 12import { VIDEO_TRANSCODING_FPS } from '../../initializers/constants'
13 13
14/** 14/**
15 * 15 *
diff --git a/server/lib/video-transcoding.ts b/server/lib/transcoding/video-transcoding.ts
index c949dca2e..1ad63baf3 100644
--- a/server/lib/video-transcoding.ts
+++ b/server/lib/transcoding/video-transcoding.ts
@@ -1,19 +1,20 @@
1import { Job } from 'bull' 1import { Job } from 'bull'
2import { copyFile, ensureDir, move, remove, stat } from 'fs-extra' 2import { copyFile, ensureDir, move, remove, stat } from 'fs-extra'
3import { basename, extname as extnameUtil, join } from 'path' 3import { basename, extname as extnameUtil, join } from 'path'
4import { toEven } from '@server/helpers/core-utils'
4import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent' 5import { createTorrentAndSetInfoHash } from '@server/helpers/webtorrent'
5import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoFullLight } from '@server/types/models' 6import { MStreamingPlaylistFilesVideo, MVideoFile, MVideoFullLight } from '@server/types/models'
6import { VideoResolution } from '../../shared/models/videos' 7import { VideoResolution } from '../../../shared/models/videos'
7import { VideoStreamingPlaylistType } from '../../shared/models/videos/video-streaming-playlist.type' 8import { VideoStreamingPlaylistType } from '../../../shared/models/videos/video-streaming-playlist.type'
8import { transcode, TranscodeOptions, TranscodeOptionsType } from '../helpers/ffmpeg-utils' 9import { transcode, TranscodeOptions, TranscodeOptionsType } from '../../helpers/ffmpeg-utils'
9import { canDoQuickTranscode, getDurationFromVideoFile, getMetadataFromFile, getVideoFileFPS } from '../helpers/ffprobe-utils' 10import { canDoQuickTranscode, getDurationFromVideoFile, getMetadataFromFile, getVideoFileFPS } from '../../helpers/ffprobe-utils'
10import { logger } from '../helpers/logger' 11import { logger } from '../../helpers/logger'
11import { CONFIG } from '../initializers/config' 12import { CONFIG } from '../../initializers/config'
12import { HLS_STREAMING_PLAYLIST_DIRECTORY, P2P_MEDIA_LOADER_PEER_VERSION, WEBSERVER } from '../initializers/constants' 13import { HLS_STREAMING_PLAYLIST_DIRECTORY, P2P_MEDIA_LOADER_PEER_VERSION, WEBSERVER } from '../../initializers/constants'
13import { VideoFileModel } from '../models/video/video-file' 14import { VideoFileModel } from '../../models/video/video-file'
14import { VideoStreamingPlaylistModel } from '../models/video/video-streaming-playlist' 15import { VideoStreamingPlaylistModel } from '../../models/video/video-streaming-playlist'
15import { updateMasterHLSPlaylist, updateSha256VODSegments } from './hls' 16import { updateMasterHLSPlaylist, updateSha256VODSegments } from '../hls'
16import { generateVideoFilename, generateVideoStreamingPlaylistName, getVideoFilePath } from './video-paths' 17import { generateVideoFilename, generateVideoStreamingPlaylistName, getVideoFilePath } from '../video-paths'
17import { VideoTranscodingProfilesManager } from './video-transcoding-profiles' 18import { VideoTranscodingProfilesManager } from './video-transcoding-profiles'
18 19
19/** 20/**
@@ -35,6 +36,8 @@ async function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile
35 ? 'quick-transcode' 36 ? 'quick-transcode'
36 : 'video' 37 : 'video'
37 38
39 const resolution = toEven(inputVideoFile.resolution)
40
38 const transcodeOptions: TranscodeOptions = { 41 const transcodeOptions: TranscodeOptions = {
39 type: transcodeType, 42 type: transcodeType,
40 43
@@ -44,7 +47,7 @@ async function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile
44 availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(), 47 availableEncoders: VideoTranscodingProfilesManager.Instance.getAvailableEncoders(),
45 profile: CONFIG.TRANSCODING.PROFILE, 48 profile: CONFIG.TRANSCODING.PROFILE,
46 49
47 resolution: inputVideoFile.resolution, 50 resolution,
48 51
49 job 52 job
50 } 53 }
@@ -57,7 +60,7 @@ async function optimizeOriginalVideofile (video: MVideoFullLight, inputVideoFile
57 60
58 // Important to do this before getVideoFilename() to take in account the new filename 61 // Important to do this before getVideoFilename() to take in account the new filename
59 inputVideoFile.extname = newExtname 62 inputVideoFile.extname = newExtname
60 inputVideoFile.filename = generateVideoFilename(video, false, inputVideoFile.resolution, newExtname) 63 inputVideoFile.filename = generateVideoFilename(video, false, resolution, newExtname)
61 64
62 const videoOutputPath = getVideoFilePath(video, inputVideoFile) 65 const videoOutputPath = getVideoFilePath(video, inputVideoFile)
63 66
@@ -215,16 +218,6 @@ function generateHlsPlaylistResolution (options: {
215 }) 218 })
216} 219}
217 220
218function getEnabledResolutions (type: 'vod' | 'live') {
219 const transcoding = type === 'vod'
220 ? CONFIG.TRANSCODING
221 : CONFIG.LIVE.TRANSCODING
222
223 return Object.keys(transcoding.RESOLUTIONS)
224 .filter(key => transcoding.ENABLED && transcoding.RESOLUTIONS[key] === true)
225 .map(r => parseInt(r, 10))
226}
227
228// --------------------------------------------------------------------------- 221// ---------------------------------------------------------------------------
229 222
230export { 223export {
@@ -232,8 +225,7 @@ export {
232 generateHlsPlaylistResolutionFromTS, 225 generateHlsPlaylistResolutionFromTS,
233 optimizeOriginalVideofile, 226 optimizeOriginalVideofile,
234 transcodeNewWebTorrentResolution, 227 transcodeNewWebTorrentResolution,
235 mergeAudioVideofile, 228 mergeAudioVideofile
236 getEnabledResolutions
237} 229}
238 230
239// --------------------------------------------------------------------------- 231// ---------------------------------------------------------------------------
diff --git a/server/lib/user.ts b/server/lib/user.ts
index 9b0a0a2f1..936403692 100644
--- a/server/lib/user.ts
+++ b/server/lib/user.ts
@@ -1,19 +1,21 @@
1import { Transaction } from 'sequelize/types' 1import { Transaction } from 'sequelize/types'
2import { v4 as uuidv4 } from 'uuid' 2import { buildUUID } from '@server/helpers/uuid'
3import { UserModel } from '@server/models/account/user' 3import { UserModel } from '@server/models/user/user'
4import { MActorDefault } from '@server/types/models/actor'
4import { ActivityPubActorType } from '../../shared/models/activitypub' 5import { ActivityPubActorType } from '../../shared/models/activitypub'
5import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users' 6import { UserNotificationSetting, UserNotificationSettingValue } from '../../shared/models/users'
6import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants' 7import { SERVER_ACTOR_NAME, WEBSERVER } from '../initializers/constants'
7import { sequelizeTypescript } from '../initializers/database' 8import { sequelizeTypescript } from '../initializers/database'
8import { AccountModel } from '../models/account/account' 9import { AccountModel } from '../models/account/account'
9import { UserNotificationSettingModel } from '../models/account/user-notification-setting' 10import { ActorModel } from '../models/actor/actor'
10import { ActorModel } from '../models/activitypub/actor' 11import { UserNotificationSettingModel } from '../models/user/user-notification-setting'
11import { MAccountDefault, MActorDefault, MChannelActor } from '../types/models' 12import { MAccountDefault, MChannelActor } from '../types/models'
12import { MUser, MUserDefault, MUserId } from '../types/models/user' 13import { MUser, MUserDefault, MUserId } from '../types/models/user'
13import { buildActorInstance, generateAndSaveActorKeys } from './activitypub/actor' 14import { generateAndSaveActorKeys } from './activitypub/actors'
14import { getLocalAccountActivityPubUrl } from './activitypub/url' 15import { getLocalAccountActivityPubUrl } from './activitypub/url'
15import { Emailer } from './emailer' 16import { Emailer } from './emailer'
16import { LiveManager } from './live-manager' 17import { LiveQuotaStore } from './live/live-quota-store'
18import { buildActorInstance } from './local-actor'
17import { Redis } from './redis' 19import { Redis } from './redis'
18import { createLocalVideoChannel } from './video-channel' 20import { createLocalVideoChannel } from './video-channel'
19import { createWatchLaterPlaylist } from './video-playlist' 21import { createWatchLaterPlaylist } from './video-playlist'
@@ -42,11 +44,11 @@ async function createUserAccountAndChannelAndPlaylist (parameters: {
42 displayName: userDisplayName, 44 displayName: userDisplayName,
43 userId: userCreated.id, 45 userId: userCreated.id,
44 applicationId: null, 46 applicationId: null,
45 t: t 47 t
46 }) 48 })
47 userCreated.Account = accountCreated 49 userCreated.Account = accountCreated
48 50
49 const channelAttributes = await buildChannelAttributes(userCreated, channelNames) 51 const channelAttributes = await buildChannelAttributes(userCreated, t, channelNames)
50 const videoChannel = await createLocalVideoChannel(channelAttributes, accountCreated, t) 52 const videoChannel = await createLocalVideoChannel(channelAttributes, accountCreated, t)
51 53
52 const videoPlaylist = await createWatchLaterPlaylist(accountCreated, t) 54 const videoPlaylist = await createWatchLaterPlaylist(accountCreated, t)
@@ -127,7 +129,7 @@ async function getOriginalVideoFileTotalFromUser (user: MUserId) {
127 129
128 const base = await UserModel.getTotalRawQuery(query, user.id) 130 const base = await UserModel.getTotalRawQuery(query, user.id)
129 131
130 return base + LiveManager.Instance.getLiveQuotaUsedByUser(user.id) 132 return base + LiveQuotaStore.Instance.getLiveQuotaOf(user.id)
131} 133}
132 134
133// Returns cumulative size of all video files uploaded in the last 24 hours. 135// Returns cumulative size of all video files uploaded in the last 24 hours.
@@ -141,10 +143,10 @@ async function getOriginalVideoFileTotalDailyFromUser (user: MUserId) {
141 143
142 const base = await UserModel.getTotalRawQuery(query, user.id) 144 const base = await UserModel.getTotalRawQuery(query, user.id)
143 145
144 return base + LiveManager.Instance.getLiveQuotaUsedByUser(user.id) 146 return base + LiveQuotaStore.Instance.getLiveQuotaOf(user.id)
145} 147}
146 148
147async function isAbleToUploadVideo (userId: number, size: number) { 149async function isAbleToUploadVideo (userId: number, newVideoSize: number) {
148 const user = await UserModel.loadById(userId) 150 const user = await UserModel.loadById(userId)
149 151
150 if (user.videoQuota === -1 && user.videoQuotaDaily === -1) return Promise.resolve(true) 152 if (user.videoQuota === -1 && user.videoQuotaDaily === -1) return Promise.resolve(true)
@@ -154,8 +156,8 @@ async function isAbleToUploadVideo (userId: number, size: number) {
154 getOriginalVideoFileTotalDailyFromUser(user) 156 getOriginalVideoFileTotalDailyFromUser(user)
155 ]) 157 ])
156 158
157 const uploadedTotal = size + totalBytes 159 const uploadedTotal = newVideoSize + totalBytes
158 const uploadedDaily = size + totalBytesDaily 160 const uploadedDaily = newVideoSize + totalBytesDaily
159 161
160 if (user.videoQuotaDaily === -1) return uploadedTotal < user.videoQuota 162 if (user.videoQuotaDaily === -1) return uploadedTotal < user.videoQuota
161 if (user.videoQuota === -1) return uploadedDaily < user.videoQuotaDaily 163 if (user.videoQuota === -1) return uploadedDaily < user.videoQuotaDaily
@@ -201,14 +203,14 @@ function createDefaultUserNotificationSettings (user: MUserId, t: Transaction |
201 return UserNotificationSettingModel.create(values, { transaction: t }) 203 return UserNotificationSettingModel.create(values, { transaction: t })
202} 204}
203 205
204async function buildChannelAttributes (user: MUser, channelNames?: ChannelNames) { 206async function buildChannelAttributes (user: MUser, transaction?: Transaction, channelNames?: ChannelNames) {
205 if (channelNames) return channelNames 207 if (channelNames) return channelNames
206 208
207 let channelName = user.username + '_channel' 209 let channelName = user.username + '_channel'
208 210
209 // Conflict, generate uuid instead 211 // Conflict, generate uuid instead
210 const actor = await ActorModel.loadLocalByName(channelName) 212 const actor = await ActorModel.loadLocalByName(channelName, transaction)
211 if (actor) channelName = uuidv4() 213 if (actor) channelName = buildUUID()
212 214
213 const videoChannelDisplayName = `Main ${user.username} channel` 215 const videoChannelDisplayName = `Main ${user.username} channel`
214 216
diff --git a/server/lib/video-blacklist.ts b/server/lib/video-blacklist.ts
index 37c43c3b0..0984c0d7a 100644
--- a/server/lib/video-blacklist.ts
+++ b/server/lib/video-blacklist.ts
@@ -16,7 +16,7 @@ import { CONFIG } from '../initializers/config'
16import { VideoBlacklistModel } from '../models/video/video-blacklist' 16import { VideoBlacklistModel } from '../models/video/video-blacklist'
17import { sendDeleteVideo } from './activitypub/send' 17import { sendDeleteVideo } from './activitypub/send'
18import { federateVideoIfNeeded } from './activitypub/videos' 18import { federateVideoIfNeeded } from './activitypub/videos'
19import { LiveManager } from './live-manager' 19import { LiveManager } from './live/live-manager'
20import { Notifier } from './notifier' 20import { Notifier } from './notifier'
21import { Hooks } from './plugins/hooks' 21import { Hooks } from './plugins/hooks'
22 22
diff --git a/server/lib/video-channel.ts b/server/lib/video-channel.ts
index 0476cb2d5..2fd63a8c4 100644
--- a/server/lib/video-channel.ts
+++ b/server/lib/video-channel.ts
@@ -1,17 +1,15 @@
1import * as Sequelize from 'sequelize' 1import * as Sequelize from 'sequelize'
2import { v4 as uuidv4 } from 'uuid'
3import { VideoChannelCreate } from '../../shared/models' 2import { VideoChannelCreate } from '../../shared/models'
4import { VideoModel } from '../models/video/video' 3import { VideoModel } from '../models/video/video'
5import { VideoChannelModel } from '../models/video/video-channel' 4import { VideoChannelModel } from '../models/video/video-channel'
6import { MAccountId, MChannelId } from '../types/models' 5import { MAccountId, MChannelId } from '../types/models'
7import { buildActorInstance } from './activitypub/actor'
8import { getLocalVideoChannelActivityPubUrl } from './activitypub/url' 6import { getLocalVideoChannelActivityPubUrl } from './activitypub/url'
9import { federateVideoIfNeeded } from './activitypub/videos' 7import { federateVideoIfNeeded } from './activitypub/videos'
8import { buildActorInstance } from './local-actor'
10 9
11async function createLocalVideoChannel (videoChannelInfo: VideoChannelCreate, account: MAccountId, t: Sequelize.Transaction) { 10async function createLocalVideoChannel (videoChannelInfo: VideoChannelCreate, account: MAccountId, t: Sequelize.Transaction) {
12 const uuid = uuidv4()
13 const url = getLocalVideoChannelActivityPubUrl(videoChannelInfo.name) 11 const url = getLocalVideoChannelActivityPubUrl(videoChannelInfo.name)
14 const actorInstance = buildActorInstance('Group', url, videoChannelInfo.name, uuid) 12 const actorInstance = buildActorInstance('Group', url, videoChannelInfo.name)
15 13
16 const actorInstanceCreated = await actorInstance.save({ transaction: t }) 14 const actorInstanceCreated = await actorInstance.save({ transaction: t })
17 15
diff --git a/server/lib/video-comment.ts b/server/lib/video-comment.ts
index 736ebb2f8..c76570a5d 100644
--- a/server/lib/video-comment.ts
+++ b/server/lib/video-comment.ts
@@ -3,7 +3,7 @@ import * as Sequelize from 'sequelize'
3import { logger } from '@server/helpers/logger' 3import { logger } from '@server/helpers/logger'
4import { sequelizeTypescript } from '@server/initializers/database' 4import { sequelizeTypescript } from '@server/initializers/database'
5import { ResultList } from '../../shared/models' 5import { ResultList } from '../../shared/models'
6import { VideoCommentThreadTree } from '../../shared/models/videos/video-comment.model' 6import { VideoCommentThreadTree } from '../../shared/models/videos/comment/video-comment.model'
7import { VideoCommentModel } from '../models/video/video-comment' 7import { VideoCommentModel } from '../models/video/video-comment'
8import { MAccountDefault, MComment, MCommentOwnerVideo, MCommentOwnerVideoReply, MVideoFullLight } from '../types/models' 8import { MAccountDefault, MComment, MCommentOwnerVideo, MCommentOwnerVideoReply, MVideoFullLight } from '../types/models'
9import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send' 9import { sendCreateVideoComment, sendDeleteVideoComment } from './activitypub/send'
@@ -18,9 +18,9 @@ async function removeComment (videoCommentInstance: MCommentOwnerVideo) {
18 await sendDeleteVideoComment(videoCommentInstance, t) 18 await sendDeleteVideoComment(videoCommentInstance, t)
19 } 19 }
20 20
21 markCommentAsDeleted(videoCommentInstance) 21 videoCommentInstance.markAsDeleted()
22 22
23 await videoCommentInstance.save() 23 await videoCommentInstance.save({ transaction: t })
24 }) 24 })
25 25
26 logger.info('Video comment %d deleted.', videoCommentInstance.id) 26 logger.info('Video comment %d deleted.', videoCommentInstance.id)
@@ -95,17 +95,10 @@ function buildFormattedCommentTree (resultList: ResultList<VideoCommentModel>):
95 return thread 95 return thread
96} 96}
97 97
98function markCommentAsDeleted (comment: MComment): void {
99 comment.text = ''
100 comment.deletedAt = new Date()
101 comment.accountId = null
102}
103
104// --------------------------------------------------------------------------- 98// ---------------------------------------------------------------------------
105 99
106export { 100export {
107 removeComment, 101 removeComment,
108 createVideoComment, 102 createVideoComment,
109 buildFormattedCommentTree, 103 buildFormattedCommentTree
110 markCommentAsDeleted
111} 104}
diff --git a/server/lib/video.ts b/server/lib/video.ts
index 21e4b7ff2..daf998704 100644
--- a/server/lib/video.ts
+++ b/server/lib/video.ts
@@ -10,7 +10,7 @@ import { ThumbnailType, VideoCreate, VideoPrivacy, VideoTranscodingPayload } fro
10import { federateVideoIfNeeded } from './activitypub/videos' 10import { federateVideoIfNeeded } from './activitypub/videos'
11import { JobQueue } from './job-queue/job-queue' 11import { JobQueue } from './job-queue/job-queue'
12import { Notifier } from './notifier' 12import { Notifier } from './notifier'
13import { createVideoMiniatureFromExisting } from './thumbnail' 13import { updateVideoMiniatureFromExisting } from './thumbnail'
14 14
15function buildLocalVideoFromReq (videoInfo: VideoCreate, channelId: number): FilteredModelAttributes<VideoModel> { 15function buildLocalVideoFromReq (videoInfo: VideoCreate, channelId: number): FilteredModelAttributes<VideoModel> {
16 return { 16 return {
@@ -28,6 +28,8 @@ function buildLocalVideoFromReq (videoInfo: VideoCreate, channelId: number): Fil
28 privacy: videoInfo.privacy || VideoPrivacy.PRIVATE, 28 privacy: videoInfo.privacy || VideoPrivacy.PRIVATE,
29 channelId: channelId, 29 channelId: channelId,
30 originallyPublishedAt: videoInfo.originallyPublishedAt 30 originallyPublishedAt: videoInfo.originallyPublishedAt
31 ? new Date(videoInfo.originallyPublishedAt)
32 : null
31 } 33 }
32} 34}
33 35
@@ -52,7 +54,7 @@ async function buildVideoThumbnailsFromReq (options: {
52 const fields = files?.[p.fieldName] 54 const fields = files?.[p.fieldName]
53 55
54 if (fields) { 56 if (fields) {
55 return createVideoMiniatureFromExisting({ 57 return updateVideoMiniatureFromExisting({
56 inputPath: fields[0].path, 58 inputPath: fields[0].path,
57 video, 59 video,
58 type: p.type, 60 type: p.type,
diff --git a/server/middlewares/activitypub.ts b/server/middlewares/activitypub.ts
index ce94a2129..6b43b7764 100644
--- a/server/middlewares/activitypub.ts
+++ b/server/middlewares/activitypub.ts
@@ -1,13 +1,12 @@
1import { NextFunction, Request, Response } from 'express' 1import { NextFunction, Request, Response } from 'express'
2import { getAPId } from '@server/helpers/activitypub'
3import { isActorDeleteActivityValid } from '@server/helpers/custom-validators/activitypub/actor'
2import { ActivityDelete, ActivityPubSignature } from '../../shared' 4import { ActivityDelete, ActivityPubSignature } from '../../shared'
5import { HttpStatusCode } from '../../shared/core-utils/miscs/http-error-codes'
3import { logger } from '../helpers/logger' 6import { logger } from '../helpers/logger'
4import { isHTTPSignatureVerified, isJsonLDSignatureVerified, parseHTTPSignature } from '../helpers/peertube-crypto' 7import { isHTTPSignatureVerified, isJsonLDSignatureVerified, parseHTTPSignature } from '../helpers/peertube-crypto'
5import { ACCEPT_HEADERS, ACTIVITY_PUB, HTTP_SIGNATURE } from '../initializers/constants' 8import { ACCEPT_HEADERS, ACTIVITY_PUB, HTTP_SIGNATURE } from '../initializers/constants'
6import { getOrCreateActorAndServerAndModel } from '../lib/activitypub/actor' 9import { getOrCreateAPActor, loadActorUrlOrGetFromWebfinger } from '../lib/activitypub/actors'
7import { loadActorUrlOrGetFromWebfinger } from '../helpers/webfinger'
8import { isActorDeleteActivityValid } from '@server/helpers/custom-validators/activitypub/actor'
9import { getAPId } from '@server/helpers/activitypub'
10import { HttpStatusCode } from '../../shared/core-utils/miscs/http-error-codes'
11 10
12async function checkSignature (req: Request, res: Response, next: NextFunction) { 11async function checkSignature (req: Request, res: Response, next: NextFunction) {
13 try { 12 try {
@@ -29,11 +28,14 @@ async function checkSignature (req: Request, res: Response, next: NextFunction)
29 const activity: ActivityDelete = req.body 28 const activity: ActivityDelete = req.body
30 if (isActorDeleteActivityValid(activity) && activity.object === activity.actor) { 29 if (isActorDeleteActivityValid(activity) && activity.object === activity.actor) {
31 logger.debug('Handling signature error on actor delete activity', { err }) 30 logger.debug('Handling signature error on actor delete activity', { err })
32 return res.sendStatus(HttpStatusCode.NO_CONTENT_204) 31 return res.status(HttpStatusCode.NO_CONTENT_204).end()
33 } 32 }
34 33
35 logger.warn('Error in ActivityPub signature checker.', { err }) 34 logger.warn('Error in ActivityPub signature checker.', { err })
36 return res.sendStatus(HttpStatusCode.FORBIDDEN_403) 35 return res.fail({
36 status: HttpStatusCode.FORBIDDEN_403,
37 message: 'ActivityPub signature could not be checked'
38 })
37 } 39 }
38} 40}
39 41
@@ -71,13 +73,22 @@ async function checkHttpSignature (req: Request, res: Response) {
71 } catch (err) { 73 } catch (err) {
72 logger.warn('Invalid signature because of exception in signature parser', { reqBody: req.body, err }) 74 logger.warn('Invalid signature because of exception in signature parser', { reqBody: req.body, err })
73 75
74 res.status(HttpStatusCode.FORBIDDEN_403).json({ error: err.message }) 76 res.fail({
77 status: HttpStatusCode.FORBIDDEN_403,
78 message: err.message
79 })
75 return false 80 return false
76 } 81 }
77 82
78 const keyId = parsed.keyId 83 const keyId = parsed.keyId
79 if (!keyId) { 84 if (!keyId) {
80 res.sendStatus(HttpStatusCode.FORBIDDEN_403) 85 res.fail({
86 status: HttpStatusCode.FORBIDDEN_403,
87 message: 'Invalid key ID',
88 data: {
89 keyId
90 }
91 })
81 return false 92 return false
82 } 93 }
83 94
@@ -88,18 +99,23 @@ async function checkHttpSignature (req: Request, res: Response) {
88 actorUrl = await loadActorUrlOrGetFromWebfinger(actorUrl.replace(/^acct:/, '')) 99 actorUrl = await loadActorUrlOrGetFromWebfinger(actorUrl.replace(/^acct:/, ''))
89 } 100 }
90 101
91 const actor = await getOrCreateActorAndServerAndModel(actorUrl) 102 const actor = await getOrCreateAPActor(actorUrl)
92 103
93 const verified = isHTTPSignatureVerified(parsed, actor) 104 const verified = isHTTPSignatureVerified(parsed, actor)
94 if (verified !== true) { 105 if (verified !== true) {
95 logger.warn('Signature from %s is invalid', actorUrl, { parsed }) 106 logger.warn('Signature from %s is invalid', actorUrl, { parsed })
96 107
97 res.sendStatus(HttpStatusCode.FORBIDDEN_403) 108 res.fail({
109 status: HttpStatusCode.FORBIDDEN_403,
110 message: 'Invalid signature',
111 data: {
112 actorUrl
113 }
114 })
98 return false 115 return false
99 } 116 }
100 117
101 res.locals.signature = { actor } 118 res.locals.signature = { actor }
102
103 return true 119 return true
104} 120}
105 121
@@ -107,7 +123,10 @@ async function checkJsonLDSignature (req: Request, res: Response) {
107 const signatureObject: ActivityPubSignature = req.body.signature 123 const signatureObject: ActivityPubSignature = req.body.signature
108 124
109 if (!signatureObject || !signatureObject.creator) { 125 if (!signatureObject || !signatureObject.creator) {
110 res.sendStatus(HttpStatusCode.FORBIDDEN_403) 126 res.fail({
127 status: HttpStatusCode.FORBIDDEN_403,
128 message: 'Object and creator signature do not match'
129 })
111 return false 130 return false
112 } 131 }
113 132
@@ -115,17 +134,19 @@ async function checkJsonLDSignature (req: Request, res: Response) {
115 134
116 logger.debug('Checking JsonLD signature of actor %s...', creator) 135 logger.debug('Checking JsonLD signature of actor %s...', creator)
117 136
118 const actor = await getOrCreateActorAndServerAndModel(creator) 137 const actor = await getOrCreateAPActor(creator)
119 const verified = await isJsonLDSignatureVerified(actor, req.body) 138 const verified = await isJsonLDSignatureVerified(actor, req.body)
120 139
121 if (verified !== true) { 140 if (verified !== true) {
122 logger.warn('Signature not verified.', req.body) 141 logger.warn('Signature not verified.', req.body)
123 142
124 res.sendStatus(HttpStatusCode.FORBIDDEN_403) 143 res.fail({
144 status: HttpStatusCode.FORBIDDEN_403,
145 message: 'Signature could not be verified'
146 })
125 return false 147 return false
126 } 148 }
127 149
128 res.locals.signature = { actor } 150 res.locals.signature = { actor }
129
130 return true 151 return true
131} 152}
diff --git a/server/middlewares/auth.ts b/server/middlewares/auth.ts
index f38373624..176461cc2 100644
--- a/server/middlewares/auth.ts
+++ b/server/middlewares/auth.ts
@@ -16,11 +16,11 @@ function authenticate (req: express.Request, res: express.Response, next: expres
16 .catch(err => { 16 .catch(err => {
17 logger.warn('Cannot authenticate.', { err }) 17 logger.warn('Cannot authenticate.', { err })
18 18
19 return res.status(err.status) 19 return res.fail({
20 .json({ 20 status: err.status,
21 error: 'Token is invalid.', 21 message: 'Token is invalid',
22 code: err.name 22 type: err.name
23 }) 23 })
24 }) 24 })
25} 25}
26 26
@@ -52,7 +52,12 @@ function authenticatePromiseIfNeeded (req: express.Request, res: express.Respons
52 // Already authenticated? (or tried to) 52 // Already authenticated? (or tried to)
53 if (res.locals.oauth?.token.User) return resolve() 53 if (res.locals.oauth?.token.User) return resolve()
54 54
55 if (res.locals.authenticated === false) return res.sendStatus(HttpStatusCode.UNAUTHORIZED_401) 55 if (res.locals.authenticated === false) {
56 return res.fail({
57 status: HttpStatusCode.UNAUTHORIZED_401,
58 message: 'Not authenticated'
59 })
60 }
56 61
57 authenticate(req, res, () => resolve(), authenticateInQuery) 62 authenticate(req, res, () => resolve(), authenticateInQuery)
58 }) 63 })
diff --git a/server/middlewares/doc.ts b/server/middlewares/doc.ts
new file mode 100644
index 000000000..3db85c68d
--- /dev/null
+++ b/server/middlewares/doc.ts
@@ -0,0 +1,16 @@
1import * as express from 'express'
2
3function openapiOperationDoc (options: {
4 url?: string
5 operationId?: string
6}) {
7 return (req: express.Request, res: express.Response, next: express.NextFunction) => {
8 res.locals.docUrl = options.url || 'https://docs.joinpeertube.org/api-rest-reference.html#operation/' + options.operationId
9
10 if (next) return next()
11 }
12}
13
14export {
15 openapiOperationDoc
16}
diff --git a/server/middlewares/error.ts b/server/middlewares/error.ts
new file mode 100644
index 000000000..e3eb1c8f5
--- /dev/null
+++ b/server/middlewares/error.ts
@@ -0,0 +1,39 @@
1import * as express from 'express'
2import { ProblemDocument, ProblemDocumentExtension } from 'http-problem-details'
3import { HttpStatusCode } from '@shared/core-utils'
4
5function apiFailMiddleware (req: express.Request, res: express.Response, next: express.NextFunction) {
6 res.fail = options => {
7 const { status = HttpStatusCode.BAD_REQUEST_400, message, title, type, data, instance } = options
8
9 const extension = new ProblemDocumentExtension({
10 ...data,
11
12 docs: res.locals.docUrl,
13 code: type,
14
15 // For <= 3.2 compatibility
16 error: message
17 })
18
19 res.status(status)
20 res.setHeader('Content-Type', 'application/problem+json')
21 res.json(new ProblemDocument({
22 status,
23 title,
24 instance,
25
26 detail: message,
27
28 type: type
29 ? `https://docs.joinpeertube.org/api-rest-reference.html#section/Errors/${type}`
30 : undefined
31 }, extension))
32 }
33
34 if (next) next()
35}
36
37export {
38 apiFailMiddleware
39}
diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts
index 3e280e16f..413653dac 100644
--- a/server/middlewares/index.ts
+++ b/server/middlewares/index.ts
@@ -7,4 +7,6 @@ export * from './servers'
7export * from './sort' 7export * from './sort'
8export * from './user-right' 8export * from './user-right'
9export * from './dnt' 9export * from './dnt'
10export * from './error'
11export * from './doc'
10export * from './csp' 12export * from './csp'
diff --git a/server/middlewares/servers.ts b/server/middlewares/servers.ts
index 5e1c165f0..9aa56bc93 100644
--- a/server/middlewares/servers.ts
+++ b/server/middlewares/servers.ts
@@ -10,7 +10,10 @@ function setBodyHostsPort (req: express.Request, res: express.Response, next: ex
10 10
11 // Problem with the url parsing? 11 // Problem with the url parsing?
12 if (hostWithPort === null) { 12 if (hostWithPort === null) {
13 return res.sendStatus(HttpStatusCode.INTERNAL_SERVER_ERROR_500) 13 return res.fail({
14 status: HttpStatusCode.INTERNAL_SERVER_ERROR_500,
15 message: 'Could not parse hosts'
16 })
14 } 17 }
15 18
16 req.body.hosts[i] = hostWithPort 19 req.body.hosts[i] = hostWithPort
diff --git a/server/middlewares/user-right.ts b/server/middlewares/user-right.ts
index 45dda4781..d1888c2d3 100644
--- a/server/middlewares/user-right.ts
+++ b/server/middlewares/user-right.ts
@@ -10,8 +10,10 @@ function ensureUserHasRight (userRight: UserRight) {
10 const message = `User ${user.username} does not have right ${userRight} to access to ${req.path}.` 10 const message = `User ${user.username} does not have right ${userRight} to access to ${req.path}.`
11 logger.info(message) 11 logger.info(message)
12 12
13 return res.status(HttpStatusCode.FORBIDDEN_403) 13 return res.fail({
14 .json({ error: message }) 14 status: HttpStatusCode.FORBIDDEN_403,
15 message
16 })
15 } 17 }
16 18
17 return next() 19 return next()
diff --git a/server/middlewares/validators/abuse.ts b/server/middlewares/validators/abuse.ts
index 3b897fdef..c048bc6af 100644
--- a/server/middlewares/validators/abuse.ts
+++ b/server/middlewares/validators/abuse.ts
@@ -12,14 +12,12 @@ import {
12 isAbuseTimestampValid, 12 isAbuseTimestampValid,
13 isAbuseVideoIsValid 13 isAbuseVideoIsValid
14} from '@server/helpers/custom-validators/abuses' 14} from '@server/helpers/custom-validators/abuses'
15import { exists, isIdOrUUIDValid, isIdValid, toIntOrNull } from '@server/helpers/custom-validators/misc' 15import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID, toIntOrNull } from '@server/helpers/custom-validators/misc'
16import { doesCommentIdExist } from '@server/helpers/custom-validators/video-comments'
17import { logger } from '@server/helpers/logger' 16import { logger } from '@server/helpers/logger'
18import { doesAbuseExist, doesAccountIdExist, doesVideoExist } from '@server/helpers/middlewares'
19import { AbuseMessageModel } from '@server/models/abuse/abuse-message' 17import { AbuseMessageModel } from '@server/models/abuse/abuse-message'
20import { AbuseCreate, UserRight } from '@shared/models' 18import { AbuseCreate, UserRight } from '@shared/models'
21import { areValidationErrors } from './utils'
22import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' 19import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
20import { areValidationErrors, doesAbuseExist, doesAccountIdExist, doesCommentIdExist, doesVideoExist } from './shared'
23 21
24const abuseReportValidator = [ 22const abuseReportValidator = [
25 body('account.id') 23 body('account.id')
@@ -29,6 +27,7 @@ const abuseReportValidator = [
29 27
30 body('video.id') 28 body('video.id')
31 .optional() 29 .optional()
30 .customSanitizer(toCompleteUUID)
32 .custom(isIdOrUUIDValid) 31 .custom(isIdOrUUIDValid)
33 .withMessage('Should have a valid videoId'), 32 .withMessage('Should have a valid videoId'),
34 body('video.startAt') 33 body('video.startAt')
@@ -71,9 +70,7 @@ const abuseReportValidator = [
71 if (body.comment?.id && !await doesCommentIdExist(body.comment.id, res)) return 70 if (body.comment?.id && !await doesCommentIdExist(body.comment.id, res)) return
72 71
73 if (!body.video?.id && !body.account?.id && !body.comment?.id) { 72 if (!body.video?.id && !body.account?.id && !body.comment?.id) {
74 res.status(HttpStatusCode.BAD_REQUEST_400) 73 res.fail({ message: 'video id or account id or comment id is required.' })
75 .json({ error: 'video id or account id or comment id is required.' })
76
77 return 74 return
78 } 75 }
79 76
@@ -195,8 +192,10 @@ const getAbuseValidator = [
195 const message = `User ${user.username} does not have right to get abuse ${abuse.id}` 192 const message = `User ${user.username} does not have right to get abuse ${abuse.id}`
196 logger.warn(message) 193 logger.warn(message)
197 194
198 return res.status(HttpStatusCode.FORBIDDEN_403) 195 return res.fail({
199 .json({ error: message }) 196 status: HttpStatusCode.FORBIDDEN_403,
197 message
198 })
200 } 199 }
201 200
202 return next() 201 return next()
@@ -209,10 +208,7 @@ const checkAbuseValidForMessagesValidator = [
209 208
210 const abuse = res.locals.abuse 209 const abuse = res.locals.abuse
211 if (abuse.ReporterAccount.isOwned() === false) { 210 if (abuse.ReporterAccount.isOwned() === false) {
212 return res.status(HttpStatusCode.BAD_REQUEST_400) 211 return res.fail({ message: 'This abuse was created by a user of your instance.' })
213 .json({
214 error: 'This abuse was created by a user of your instance.'
215 })
216 } 212 }
217 213
218 return next() 214 return next()
@@ -246,13 +242,17 @@ const deleteAbuseMessageValidator = [
246 const abuseMessage = await AbuseMessageModel.loadByIdAndAbuseId(messageId, abuse.id) 242 const abuseMessage = await AbuseMessageModel.loadByIdAndAbuseId(messageId, abuse.id)
247 243
248 if (!abuseMessage) { 244 if (!abuseMessage) {
249 return res.status(HttpStatusCode.NOT_FOUND_404) 245 return res.fail({
250 .json({ error: 'Abuse message not found' }) 246 status: HttpStatusCode.NOT_FOUND_404,
247 message: 'Abuse message not found'
248 })
251 } 249 }
252 250
253 if (user.hasRight(UserRight.MANAGE_ABUSES) !== true && abuseMessage.accountId !== user.Account.id) { 251 if (user.hasRight(UserRight.MANAGE_ABUSES) !== true && abuseMessage.accountId !== user.Account.id) {
254 return res.status(HttpStatusCode.FORBIDDEN_403) 252 return res.fail({
255 .json({ error: 'Cannot delete this abuse message' }) 253 status: HttpStatusCode.FORBIDDEN_403,
254 message: 'Cannot delete this abuse message'
255 })
256 } 256 }
257 257
258 res.locals.abuseMessage = abuseMessage 258 res.locals.abuseMessage = abuseMessage
diff --git a/server/middlewares/validators/account.ts b/server/middlewares/validators/account.ts
index cbdcef2fd..599eb10bb 100644
--- a/server/middlewares/validators/account.ts
+++ b/server/middlewares/validators/account.ts
@@ -2,8 +2,7 @@ import * as express from 'express'
2import { param } from 'express-validator' 2import { param } from 'express-validator'
3import { isAccountNameValid } from '../../helpers/custom-validators/accounts' 3import { isAccountNameValid } from '../../helpers/custom-validators/accounts'
4import { logger } from '../../helpers/logger' 4import { logger } from '../../helpers/logger'
5import { areValidationErrors } from './utils' 5import { areValidationErrors, doesAccountNameWithHostExist, doesLocalAccountNameExist } from './shared'
6import { doesAccountNameWithHostExist, doesLocalAccountNameExist } from '../../helpers/middlewares'
7 6
8const localAccountValidator = [ 7const localAccountValidator = [
9 param('name').custom(isAccountNameValid).withMessage('Should have a valid account name'), 8 param('name').custom(isAccountNameValid).withMessage('Should have a valid account name'),
diff --git a/server/middlewares/validators/activitypub/activity.ts b/server/middlewares/validators/activitypub/activity.ts
index e78ef07ef..cc6acd4b1 100644
--- a/server/middlewares/validators/activitypub/activity.ts
+++ b/server/middlewares/validators/activitypub/activity.ts
@@ -9,16 +9,14 @@ async function activityPubValidator (req: express.Request, res: express.Response
9 9
10 if (!isRootActivityValid(req.body)) { 10 if (!isRootActivityValid(req.body)) {
11 logger.warn('Incorrect activity parameters.', { activity: req.body }) 11 logger.warn('Incorrect activity parameters.', { activity: req.body })
12 return res.status(HttpStatusCode.BAD_REQUEST_400) 12 return res.fail({ message: 'Incorrect activity' })
13 .json({ error: 'Incorrect activity.' })
14 } 13 }
15 14
16 const serverActor = await getServerActor() 15 const serverActor = await getServerActor()
17 const remoteActor = res.locals.signature.actor 16 const remoteActor = res.locals.signature.actor
18 if (serverActor.id === remoteActor.id || remoteActor.serverId === null) { 17 if (serverActor.id === remoteActor.id || remoteActor.serverId === null) {
19 logger.error('Receiving request in INBOX by ourselves!', req.body) 18 logger.error('Receiving request in INBOX by ourselves!', req.body)
20 return res.status(HttpStatusCode.CONFLICT_409) 19 return res.status(HttpStatusCode.CONFLICT_409).end()
21 .end()
22 } 20 }
23 21
24 return next() 22 return next()
diff --git a/server/middlewares/validators/activitypub/pagination.ts b/server/middlewares/validators/activitypub/pagination.ts
index fa21f063d..c8ec34eb6 100644
--- a/server/middlewares/validators/activitypub/pagination.ts
+++ b/server/middlewares/validators/activitypub/pagination.ts
@@ -1,8 +1,8 @@
1import * as express from 'express' 1import * as express from 'express'
2import { query } from 'express-validator' 2import { query } from 'express-validator'
3import { logger } from '../../../helpers/logger'
4import { areValidationErrors } from '../utils'
5import { PAGINATION } from '@server/initializers/constants' 3import { PAGINATION } from '@server/initializers/constants'
4import { logger } from '../../../helpers/logger'
5import { areValidationErrors } from '../shared'
6 6
7const apPaginationValidator = [ 7const apPaginationValidator = [
8 query('page') 8 query('page')
diff --git a/server/middlewares/validators/activitypub/signature.ts b/server/middlewares/validators/activitypub/signature.ts
index 7c4e49463..f2f7d5848 100644
--- a/server/middlewares/validators/activitypub/signature.ts
+++ b/server/middlewares/validators/activitypub/signature.ts
@@ -1,12 +1,13 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body } from 'express-validator' 2import { body } from 'express-validator'
3import { 3import {
4 isSignatureCreatorValid, isSignatureTypeValid, 4 isSignatureCreatorValid,
5 isSignatureTypeValid,
5 isSignatureValueValid 6 isSignatureValueValid
6} from '../../../helpers/custom-validators/activitypub/signature' 7} from '../../../helpers/custom-validators/activitypub/signature'
7import { isDateValid } from '../../../helpers/custom-validators/misc' 8import { isDateValid } from '../../../helpers/custom-validators/misc'
8import { logger } from '../../../helpers/logger' 9import { logger } from '../../../helpers/logger'
9import { areValidationErrors } from '../utils' 10import { areValidationErrors } from '../shared'
10 11
11const signatureValidator = [ 12const signatureValidator = [
12 body('signature.type') 13 body('signature.type')
@@ -14,7 +15,7 @@ const signatureValidator = [
14 .custom(isSignatureTypeValid).withMessage('Should have a valid signature type'), 15 .custom(isSignatureTypeValid).withMessage('Should have a valid signature type'),
15 body('signature.created') 16 body('signature.created')
16 .optional() 17 .optional()
17 .custom(isDateValid).withMessage('Should have a valid signature created date'), 18 .custom(isDateValid).withMessage('Should have a signature created date that conforms to ISO 8601'),
18 body('signature.creator') 19 body('signature.creator')
19 .optional() 20 .optional()
20 .custom(isSignatureCreatorValid).withMessage('Should have a valid signature creator'), 21 .custom(isSignatureCreatorValid).withMessage('Should have a valid signature creator'),
diff --git a/server/middlewares/validators/actor-image.ts b/server/middlewares/validators/actor-image.ts
index 961d7a7e5..49daadd61 100644
--- a/server/middlewares/validators/actor-image.ts
+++ b/server/middlewares/validators/actor-image.ts
@@ -4,7 +4,7 @@ import { isActorImageFile } from '@server/helpers/custom-validators/actor-images
4import { cleanUpReqFiles } from '../../helpers/express-utils' 4import { cleanUpReqFiles } from '../../helpers/express-utils'
5import { logger } from '../../helpers/logger' 5import { logger } from '../../helpers/logger'
6import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 6import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
7import { areValidationErrors } from './utils' 7import { areValidationErrors } from './shared'
8 8
9const updateActorImageValidatorFactory = (fieldname: string) => ([ 9const updateActorImageValidatorFactory = (fieldname: string) => ([
10 body(fieldname).custom((value, { req }) => isActorImageFile(req.files, fieldname)).withMessage( 10 body(fieldname).custom((value, { req }) => isActorImageFile(req.files, fieldname)).withMessage(
diff --git a/server/middlewares/validators/blocklist.ts b/server/middlewares/validators/blocklist.ts
index f61811a1a..826b16fc8 100644
--- a/server/middlewares/validators/blocklist.ts
+++ b/server/middlewares/validators/blocklist.ts
@@ -1,15 +1,14 @@
1import { body, param } from 'express-validator'
2import * as express from 'express' 1import * as express from 'express'
2import { body, param } from 'express-validator'
3import { getServerActor } from '@server/models/application/application'
4import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
5import { isHostValid } from '../../helpers/custom-validators/servers'
3import { logger } from '../../helpers/logger' 6import { logger } from '../../helpers/logger'
4import { areValidationErrors } from './utils' 7import { WEBSERVER } from '../../initializers/constants'
5import { AccountBlocklistModel } from '../../models/account/account-blocklist' 8import { AccountBlocklistModel } from '../../models/account/account-blocklist'
6import { isHostValid } from '../../helpers/custom-validators/servers'
7import { ServerBlocklistModel } from '../../models/server/server-blocklist'
8import { ServerModel } from '../../models/server/server' 9import { ServerModel } from '../../models/server/server'
9import { WEBSERVER } from '../../initializers/constants' 10import { ServerBlocklistModel } from '../../models/server/server-blocklist'
10import { doesAccountNameWithHostExist } from '../../helpers/middlewares' 11import { areValidationErrors, doesAccountNameWithHostExist } from './shared'
11import { getServerActor } from '@server/models/application/application'
12import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
13 12
14const blockAccountValidator = [ 13const blockAccountValidator = [
15 body('accountName').exists().withMessage('Should have an account name with host'), 14 body('accountName').exists().withMessage('Should have an account name with host'),
@@ -24,9 +23,10 @@ const blockAccountValidator = [
24 const accountToBlock = res.locals.account 23 const accountToBlock = res.locals.account
25 24
26 if (user.Account.id === accountToBlock.id) { 25 if (user.Account.id === accountToBlock.id) {
27 res.status(HttpStatusCode.CONFLICT_409) 26 res.fail({
28 .json({ error: 'You cannot block yourself.' }) 27 status: HttpStatusCode.CONFLICT_409,
29 28 message: 'You cannot block yourself.'
29 })
30 return 30 return
31 } 31 }
32 32
@@ -79,8 +79,10 @@ const blockServerValidator = [
79 const host: string = req.body.host 79 const host: string = req.body.host
80 80
81 if (host === WEBSERVER.HOST) { 81 if (host === WEBSERVER.HOST) {
82 return res.status(HttpStatusCode.CONFLICT_409) 82 return res.fail({
83 .json({ error: 'You cannot block your own server.' }) 83 status: HttpStatusCode.CONFLICT_409,
84 message: 'You cannot block your own server.'
85 })
84 } 86 }
85 87
86 const server = await ServerModel.loadOrCreateByHost(host) 88 const server = await ServerModel.loadOrCreateByHost(host)
@@ -137,27 +139,27 @@ export {
137async function doesUnblockAccountExist (accountId: number, targetAccountId: number, res: express.Response) { 139async function doesUnblockAccountExist (accountId: number, targetAccountId: number, res: express.Response) {
138 const accountBlock = await AccountBlocklistModel.loadByAccountAndTarget(accountId, targetAccountId) 140 const accountBlock = await AccountBlocklistModel.loadByAccountAndTarget(accountId, targetAccountId)
139 if (!accountBlock) { 141 if (!accountBlock) {
140 res.status(HttpStatusCode.NOT_FOUND_404) 142 res.fail({
141 .json({ error: 'Account block entry not found.' }) 143 status: HttpStatusCode.NOT_FOUND_404,
142 144 message: 'Account block entry not found.'
145 })
143 return false 146 return false
144 } 147 }
145 148
146 res.locals.accountBlock = accountBlock 149 res.locals.accountBlock = accountBlock
147
148 return true 150 return true
149} 151}
150 152
151async function doesUnblockServerExist (accountId: number, host: string, res: express.Response) { 153async function doesUnblockServerExist (accountId: number, host: string, res: express.Response) {
152 const serverBlock = await ServerBlocklistModel.loadByAccountAndHost(accountId, host) 154 const serverBlock = await ServerBlocklistModel.loadByAccountAndHost(accountId, host)
153 if (!serverBlock) { 155 if (!serverBlock) {
154 res.status(HttpStatusCode.NOT_FOUND_404) 156 res.fail({
155 .json({ error: 'Server block entry not found.' }) 157 status: HttpStatusCode.NOT_FOUND_404,
156 158 message: 'Server block entry not found.'
159 })
157 return false 160 return false
158 } 161 }
159 162
160 res.locals.serverBlock = serverBlock 163 res.locals.serverBlock = serverBlock
161
162 return true 164 return true
163} 165}
diff --git a/server/middlewares/validators/bulk.ts b/server/middlewares/validators/bulk.ts
index cfb16d352..9bb95f5b7 100644
--- a/server/middlewares/validators/bulk.ts
+++ b/server/middlewares/validators/bulk.ts
@@ -1,12 +1,11 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body } from 'express-validator' 2import { body } from 'express-validator'
3import { isBulkRemoveCommentsOfScopeValid } from '@server/helpers/custom-validators/bulk' 3import { isBulkRemoveCommentsOfScopeValid } from '@server/helpers/custom-validators/bulk'
4import { doesAccountNameWithHostExist } from '@server/helpers/middlewares' 4import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
5import { UserRight } from '@shared/models' 5import { UserRight } from '@shared/models'
6import { BulkRemoveCommentsOfBody } from '@shared/models/bulk/bulk-remove-comments-of-body.model' 6import { BulkRemoveCommentsOfBody } from '@shared/models/bulk/bulk-remove-comments-of-body.model'
7import { logger } from '../../helpers/logger' 7import { logger } from '../../helpers/logger'
8import { areValidationErrors } from './utils' 8import { areValidationErrors, doesAccountNameWithHostExist } from './shared'
9import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
10 9
11const bulkRemoveCommentsOfValidator = [ 10const bulkRemoveCommentsOfValidator = [
12 body('accountName').exists().withMessage('Should have an account name with host'), 11 body('accountName').exists().withMessage('Should have an account name with host'),
@@ -23,9 +22,9 @@ const bulkRemoveCommentsOfValidator = [
23 const body = req.body as BulkRemoveCommentsOfBody 22 const body = req.body as BulkRemoveCommentsOfBody
24 23
25 if (body.scope === 'instance' && user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) !== true) { 24 if (body.scope === 'instance' && user.hasRight(UserRight.REMOVE_ANY_VIDEO_COMMENT) !== true) {
26 return res.status(HttpStatusCode.FORBIDDEN_403) 25 return res.fail({
27 .json({ 26 status: HttpStatusCode.FORBIDDEN_403,
28 error: 'User cannot remove any comments of this instance.' 27 message: 'User cannot remove any comments of this instance.'
29 }) 28 })
30 } 29 }
31 30
diff --git a/server/middlewares/validators/config.ts b/server/middlewares/validators/config.ts
index a85883b19..1aeadbe65 100644
--- a/server/middlewares/validators/config.ts
+++ b/server/middlewares/validators/config.ts
@@ -2,13 +2,12 @@ import * as express from 'express'
2import { body } from 'express-validator' 2import { body } from 'express-validator'
3import { isIntOrNull } from '@server/helpers/custom-validators/misc' 3import { isIntOrNull } from '@server/helpers/custom-validators/misc'
4import { isEmailEnabled } from '@server/initializers/config' 4import { isEmailEnabled } from '@server/initializers/config'
5import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
6import { CustomConfig } from '../../../shared/models/server/custom-config.model' 5import { CustomConfig } from '../../../shared/models/server/custom-config.model'
7import { isThemeNameValid } from '../../helpers/custom-validators/plugins' 6import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
8import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users' 7import { isUserNSFWPolicyValid, isUserVideoQuotaDailyValid, isUserVideoQuotaValid } from '../../helpers/custom-validators/users'
9import { logger } from '../../helpers/logger' 8import { logger } from '../../helpers/logger'
10import { isThemeRegistered } from '../../lib/plugins/theme-utils' 9import { isThemeRegistered } from '../../lib/plugins/theme-utils'
11import { areValidationErrors } from './utils' 10import { areValidationErrors } from './shared'
12 11
13const customConfigUpdateValidator = [ 12const customConfigUpdateValidator = [
14 body('instance.name').exists().withMessage('Should have a valid instance name'), 13 body('instance.name').exists().withMessage('Should have a valid instance name'),
@@ -30,6 +29,7 @@ const customConfigUpdateValidator = [
30 body('signup.enabled').isBoolean().withMessage('Should have a valid signup enabled boolean'), 29 body('signup.enabled').isBoolean().withMessage('Should have a valid signup enabled boolean'),
31 body('signup.limit').isInt().withMessage('Should have a valid signup limit'), 30 body('signup.limit').isInt().withMessage('Should have a valid signup limit'),
32 body('signup.requiresEmailVerification').isBoolean().withMessage('Should have a valid requiresEmailVerification boolean'), 31 body('signup.requiresEmailVerification').isBoolean().withMessage('Should have a valid requiresEmailVerification boolean'),
32 body('signup.minimumAge').isInt().withMessage("Should have a valid minimum age required"),
33 33
34 body('admin.email').isEmail().withMessage('Should have a valid administrator email'), 34 body('admin.email').isEmail().withMessage('Should have a valid administrator email'),
35 body('contactForm.enabled').isBoolean().withMessage('Should have a valid contact form enabled boolean'), 35 body('contactForm.enabled').isBoolean().withMessage('Should have a valid contact form enabled boolean'),
@@ -114,9 +114,7 @@ function checkInvalidConfigIfEmailDisabled (customConfig: CustomConfig, res: exp
114 if (isEmailEnabled()) return true 114 if (isEmailEnabled()) return true
115 115
116 if (customConfig.signup.requiresEmailVerification === true) { 116 if (customConfig.signup.requiresEmailVerification === true) {
117 res.status(HttpStatusCode.BAD_REQUEST_400) 117 res.fail({ message: 'Emailer is disabled but you require signup email verification.' })
118 .send({ error: 'Emailer is disabled but you require signup email verification.' })
119 .end()
120 return false 118 return false
121 } 119 }
122 120
@@ -127,9 +125,7 @@ function checkInvalidTranscodingConfig (customConfig: CustomConfig, res: express
127 if (customConfig.transcoding.enabled === false) return true 125 if (customConfig.transcoding.enabled === false) return true
128 126
129 if (customConfig.transcoding.webtorrent.enabled === false && customConfig.transcoding.hls.enabled === false) { 127 if (customConfig.transcoding.webtorrent.enabled === false && customConfig.transcoding.hls.enabled === false) {
130 res.status(HttpStatusCode.BAD_REQUEST_400) 128 res.fail({ message: 'You need to enable at least webtorrent transcoding or hls transcoding' })
131 .send({ error: 'You need to enable at least webtorrent transcoding or hls transcoding' })
132 .end()
133 return false 129 return false
134 } 130 }
135 131
@@ -140,9 +136,7 @@ function checkInvalidLiveConfig (customConfig: CustomConfig, res: express.Respon
140 if (customConfig.live.enabled === false) return true 136 if (customConfig.live.enabled === false) return true
141 137
142 if (customConfig.live.allowReplay === true && customConfig.transcoding.enabled === false) { 138 if (customConfig.live.allowReplay === true && customConfig.transcoding.enabled === false) {
143 res.status(HttpStatusCode.BAD_REQUEST_400) 139 res.fail({ message: 'You cannot allow live replay if transcoding is not enabled' })
144 .send({ error: 'You cannot allow live replay if transcoding is not enabled' })
145 .end()
146 return false 140 return false
147 } 141 }
148 142
diff --git a/server/middlewares/validators/feeds.ts b/server/middlewares/validators/feeds.ts
index 617661813..51b8fdd19 100644
--- a/server/middlewares/validators/feeds.ts
+++ b/server/middlewares/validators/feeds.ts
@@ -1,18 +1,19 @@
1import * as express from 'express' 1import * as express from 'express'
2import { param, query } from 'express-validator' 2import { param, query } from 'express-validator'
3
4import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
3import { isValidRSSFeed } from '../../helpers/custom-validators/feeds' 5import { isValidRSSFeed } from '../../helpers/custom-validators/feeds'
4import { exists, isIdOrUUIDValid, isIdValid } from '../../helpers/custom-validators/misc' 6import { exists, isIdOrUUIDValid, isIdValid, toCompleteUUID } from '../../helpers/custom-validators/misc'
5import { logger } from '../../helpers/logger' 7import { logger } from '../../helpers/logger'
6import { 8import {
9 areValidationErrors,
7 doesAccountIdExist, 10 doesAccountIdExist,
8 doesAccountNameWithHostExist, 11 doesAccountNameWithHostExist,
9 doesUserFeedTokenCorrespond, 12 doesUserFeedTokenCorrespond,
10 doesVideoChannelIdExist, 13 doesVideoChannelIdExist,
11 doesVideoChannelNameWithHostExist 14 doesVideoChannelNameWithHostExist,
12} from '../../helpers/middlewares' 15 doesVideoExist
13import { doesVideoExist } from '../../helpers/middlewares/videos' 16} from './shared'
14import { areValidationErrors } from './utils'
15import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
16 17
17const feedsFormatValidator = [ 18const feedsFormatValidator = [
18 param('format').optional().custom(isValidRSSFeed).withMessage('Should have a valid format (rss, atom, json)'), 19 param('format').optional().custom(isValidRSSFeed).withMessage('Should have a valid format (rss, atom, json)'),
@@ -36,10 +37,10 @@ function setFeedFormatContentType (req: express.Request, res: express.Response,
36 if (req.accepts(acceptableContentTypes)) { 37 if (req.accepts(acceptableContentTypes)) {
37 res.set('Content-Type', req.accepts(acceptableContentTypes) as string) 38 res.set('Content-Type', req.accepts(acceptableContentTypes) as string)
38 } else { 39 } else {
39 return res.status(HttpStatusCode.NOT_ACCEPTABLE_406) 40 return res.fail({
40 .json({ 41 status: HttpStatusCode.NOT_ACCEPTABLE_406,
41 message: `You should accept at least one of the following content-types: ${acceptableContentTypes.join(', ')}` 42 message: `You should accept at least one of the following content-types: ${acceptableContentTypes.join(', ')}`
42 }) 43 })
43 } 44 }
44 45
45 return next() 46 return next()
@@ -98,7 +99,10 @@ const videoSubscriptionFeedsValidator = [
98] 99]
99 100
100const videoCommentsFeedsValidator = [ 101const videoCommentsFeedsValidator = [
101 query('videoId').optional().custom(isIdOrUUIDValid), 102 query('videoId')
103 .customSanitizer(toCompleteUUID)
104 .optional()
105 .custom(isIdOrUUIDValid),
102 106
103 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 107 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
104 logger.debug('Checking feeds parameters', { parameters: req.query }) 108 logger.debug('Checking feeds parameters', { parameters: req.query })
@@ -106,10 +110,7 @@ const videoCommentsFeedsValidator = [
106 if (areValidationErrors(req, res)) return 110 if (areValidationErrors(req, res)) return
107 111
108 if (req.query.videoId && (req.query.videoChannelId || req.query.videoChannelName)) { 112 if (req.query.videoId && (req.query.videoChannelId || req.query.videoChannelName)) {
109 return res.status(HttpStatusCode.BAD_REQUEST_400) 113 return res.fail({ message: 'videoId cannot be mixed with a channel filter' })
110 .json({
111 message: 'videoId cannot be mixed with a channel filter'
112 })
113 } 114 }
114 115
115 if (req.query.videoId && !await doesVideoExist(req.query.videoId, res)) return 116 if (req.query.videoId && !await doesVideoExist(req.query.videoId, res)) return
diff --git a/server/middlewares/validators/follows.ts b/server/middlewares/validators/follows.ts
index bb849dc72..205baca48 100644
--- a/server/middlewares/validators/follows.ts
+++ b/server/middlewares/validators/follows.ts
@@ -1,18 +1,18 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param, query } from 'express-validator' 2import { body, param, query } from 'express-validator'
3import { isFollowStateValid } from '@server/helpers/custom-validators/follows'
4import { loadActorUrlOrGetFromWebfinger } from '@server/lib/activitypub/actors'
5import { getServerActor } from '@server/models/application/application'
6import { MActorFollowActorsDefault } from '@server/types/models'
7import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
3import { isTestInstance } from '../../helpers/core-utils' 8import { isTestInstance } from '../../helpers/core-utils'
9import { isActorTypeValid, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor'
4import { isEachUniqueHostValid, isHostValid } from '../../helpers/custom-validators/servers' 10import { isEachUniqueHostValid, isHostValid } from '../../helpers/custom-validators/servers'
5import { logger } from '../../helpers/logger' 11import { logger } from '../../helpers/logger'
6import { SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants' 12import { SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants'
7import { ActorFollowModel } from '../../models/activitypub/actor-follow' 13import { ActorModel } from '../../models/actor/actor'
8import { areValidationErrors } from './utils' 14import { ActorFollowModel } from '../../models/actor/actor-follow'
9import { ActorModel } from '../../models/activitypub/actor' 15import { areValidationErrors } from './shared'
10import { loadActorUrlOrGetFromWebfinger } from '../../helpers/webfinger'
11import { isActorTypeValid, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor'
12import { MActorFollowActorsDefault } from '@server/types/models'
13import { isFollowStateValid } from '@server/helpers/custom-validators/follows'
14import { getServerActor } from '@server/models/application/application'
15import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
16 16
17const listFollowsValidator = [ 17const listFollowsValidator = [
18 query('state') 18 query('state')
@@ -63,11 +63,10 @@ const removeFollowingValidator = [
63 const follow = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI(serverActor.id, SERVER_ACTOR_NAME, req.params.host) 63 const follow = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI(serverActor.id, SERVER_ACTOR_NAME, req.params.host)
64 64
65 if (!follow) { 65 if (!follow) {
66 return res 66 return res.fail({
67 .status(HttpStatusCode.NOT_FOUND_404) 67 status: HttpStatusCode.NOT_FOUND_404,
68 .json({ 68 message: `Following ${req.params.host} not found.`
69 error: `Following ${req.params.host} not found.` 69 })
70 })
71 } 70 }
72 71
73 res.locals.follow = follow 72 res.locals.follow = follow
@@ -95,12 +94,10 @@ const getFollowerValidator = [
95 } 94 }
96 95
97 if (!follow) { 96 if (!follow) {
98 return res 97 return res.fail({
99 .status(HttpStatusCode.NOT_FOUND_404) 98 status: HttpStatusCode.NOT_FOUND_404,
100 .json({ 99 message: `Follower ${req.params.nameWithHost} not found.`
101 error: `Follower ${req.params.nameWithHost} not found.` 100 })
102 })
103 .end()
104 } 101 }
105 102
106 res.locals.follow = follow 103 res.locals.follow = follow
@@ -114,12 +111,7 @@ const acceptOrRejectFollowerValidator = [
114 111
115 const follow = res.locals.follow 112 const follow = res.locals.follow
116 if (follow.state !== 'pending') { 113 if (follow.state !== 'pending') {
117 return res 114 return res.fail({ message: 'Follow is not in pending state.' })
118 .status(HttpStatusCode.BAD_REQUEST_400)
119 .json({
120 error: 'Follow is not in pending state.'
121 })
122 .end()
123 } 115 }
124 116
125 return next() 117 return next()
diff --git a/server/middlewares/validators/index.ts b/server/middlewares/validators/index.ts
index 24faeea3e..94a3c2dea 100644
--- a/server/middlewares/validators/index.ts
+++ b/server/middlewares/validators/index.ts
@@ -11,7 +11,7 @@ export * from './sort'
11export * from './users' 11export * from './users'
12export * from './user-subscriptions' 12export * from './user-subscriptions'
13export * from './videos' 13export * from './videos'
14export * from './webfinger'
15export * from './search' 14export * from './search'
16export * from './server' 15export * from './server'
17export * from './user-history' 16export * from './user-history'
17export * from './webfinger'
diff --git a/server/middlewares/validators/jobs.ts b/server/middlewares/validators/jobs.ts
index d87b28c06..5d89d167f 100644
--- a/server/middlewares/validators/jobs.ts
+++ b/server/middlewares/validators/jobs.ts
@@ -2,7 +2,7 @@ import * as express from 'express'
2import { param, query } from 'express-validator' 2import { param, query } from 'express-validator'
3import { isValidJobState, isValidJobType } from '../../helpers/custom-validators/jobs' 3import { isValidJobState, isValidJobType } from '../../helpers/custom-validators/jobs'
4import { logger, loggerTagsFactory } from '../../helpers/logger' 4import { logger, loggerTagsFactory } from '../../helpers/logger'
5import { areValidationErrors } from './utils' 5import { areValidationErrors } from './shared'
6 6
7const lTags = loggerTagsFactory('validators', 'jobs') 7const lTags = loggerTagsFactory('validators', 'jobs')
8 8
diff --git a/server/middlewares/validators/logs.ts b/server/middlewares/validators/logs.ts
index 70e4d0d99..c55baaee3 100644
--- a/server/middlewares/validators/logs.ts
+++ b/server/middlewares/validators/logs.ts
@@ -1,19 +1,19 @@
1import * as express from 'express' 1import * as express from 'express'
2import { logger } from '../../helpers/logger'
3import { areValidationErrors } from './utils'
4import { isDateValid } from '../../helpers/custom-validators/misc'
5import { query } from 'express-validator' 2import { query } from 'express-validator'
6import { isValidLogLevel } from '../../helpers/custom-validators/logs' 3import { isValidLogLevel } from '../../helpers/custom-validators/logs'
4import { isDateValid } from '../../helpers/custom-validators/misc'
5import { logger } from '../../helpers/logger'
6import { areValidationErrors } from './shared'
7 7
8const getLogsValidator = [ 8const getLogsValidator = [
9 query('startDate') 9 query('startDate')
10 .custom(isDateValid).withMessage('Should have a valid start date'), 10 .custom(isDateValid).withMessage('Should have a start date that conforms to ISO 8601'),
11 query('level') 11 query('level')
12 .optional() 12 .optional()
13 .custom(isValidLogLevel).withMessage('Should have a valid level'), 13 .custom(isValidLogLevel).withMessage('Should have a valid level'),
14 query('endDate') 14 query('endDate')
15 .optional() 15 .optional()
16 .custom(isDateValid).withMessage('Should have a valid end date'), 16 .custom(isDateValid).withMessage('Should have an end date that conforms to ISO 8601'),
17 17
18 (req: express.Request, res: express.Response, next: express.NextFunction) => { 18 (req: express.Request, res: express.Response, next: express.NextFunction) => {
19 logger.debug('Checking getLogsValidator parameters.', { parameters: req.query }) 19 logger.debug('Checking getLogsValidator parameters.', { parameters: req.query })
@@ -26,10 +26,10 @@ const getLogsValidator = [
26 26
27const getAuditLogsValidator = [ 27const getAuditLogsValidator = [
28 query('startDate') 28 query('startDate')
29 .custom(isDateValid).withMessage('Should have a valid start date'), 29 .custom(isDateValid).withMessage('Should have a start date that conforms to ISO 8601'),
30 query('endDate') 30 query('endDate')
31 .optional() 31 .optional()
32 .custom(isDateValid).withMessage('Should have a valid end date'), 32 .custom(isDateValid).withMessage('Should have a end date that conforms to ISO 8601'),
33 33
34 (req: express.Request, res: express.Response, next: express.NextFunction) => { 34 (req: express.Request, res: express.Response, next: express.NextFunction) => {
35 logger.debug('Checking getAuditLogsValidator parameters.', { parameters: req.query }) 35 logger.debug('Checking getAuditLogsValidator parameters.', { parameters: req.query })
diff --git a/server/middlewares/validators/oembed.ts b/server/middlewares/validators/oembed.ts
index 2a7dc257b..0a82e6932 100644
--- a/server/middlewares/validators/oembed.ts
+++ b/server/middlewares/validators/oembed.ts
@@ -1,18 +1,32 @@
1import * as express from 'express' 1import * as express from 'express'
2import { query } from 'express-validator' 2import { query } from 'express-validator'
3import { join } from 'path' 3import { join } from 'path'
4import { fetchVideo } from '@server/helpers/video' 4import { loadVideo } from '@server/lib/model-loaders'
5import { VideoPlaylistModel } from '@server/models/video/video-playlist' 5import { VideoPlaylistModel } from '@server/models/video/video-playlist'
6import { VideoPlaylistPrivacy, VideoPrivacy } from '@shared/models' 6import { VideoPlaylistPrivacy, VideoPrivacy } from '@shared/models'
7import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
7import { isTestInstance } from '../../helpers/core-utils' 8import { isTestInstance } from '../../helpers/core-utils'
8import { isIdOrUUIDValid } from '../../helpers/custom-validators/misc' 9import { isIdOrUUIDValid, toCompleteUUID } from '../../helpers/custom-validators/misc'
9import { logger } from '../../helpers/logger' 10import { logger } from '../../helpers/logger'
10import { WEBSERVER } from '../../initializers/constants' 11import { WEBSERVER } from '../../initializers/constants'
11import { areValidationErrors } from './utils' 12import { areValidationErrors } from './shared'
12import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' 13
14const playlistPaths = [
15 join('videos', 'watch', 'playlist'),
16 join('w', 'p')
17]
18
19const videoPaths = [
20 join('videos', 'watch'),
21 'w'
22]
23
24function buildUrls (paths: string[]) {
25 return paths.map(p => WEBSERVER.SCHEME + '://' + join(WEBSERVER.HOST, p) + '/')
26}
13 27
14const startVideoPlaylistsURL = WEBSERVER.SCHEME + '://' + join(WEBSERVER.HOST, 'videos', 'watch', 'playlist') + '/' 28const startPlaylistURLs = buildUrls(playlistPaths)
15const startVideosURL = WEBSERVER.SCHEME + '://' + join(WEBSERVER.HOST, 'videos', 'watch') + '/' 29const startVideoURLs = buildUrls(videoPaths)
16 30
17const watchRegex = /([^/]+)$/ 31const watchRegex = /([^/]+)$/
18const isURLOptions = { 32const isURLOptions = {
@@ -37,41 +51,54 @@ const oembedValidator = [
37 if (areValidationErrors(req, res)) return 51 if (areValidationErrors(req, res)) return
38 52
39 if (req.query.format !== undefined && req.query.format !== 'json') { 53 if (req.query.format !== undefined && req.query.format !== 'json') {
40 return res.status(HttpStatusCode.NOT_IMPLEMENTED_501) 54 return res.fail({
41 .json({ error: 'Requested format is not implemented on server.' }) 55 status: HttpStatusCode.NOT_IMPLEMENTED_501,
56 message: 'Requested format is not implemented on server.',
57 data: {
58 format: req.query.format
59 }
60 })
42 } 61 }
43 62
44 const url = req.query.url as string 63 const url = req.query.url as string
45 64
46 const isPlaylist = url.startsWith(startVideoPlaylistsURL) 65 const isPlaylist = startPlaylistURLs.some(u => url.startsWith(u))
47 const isVideo = isPlaylist ? false : url.startsWith(startVideosURL) 66 const isVideo = isPlaylist ? false : startVideoURLs.some(u => url.startsWith(u))
48 67
49 const startIsOk = isVideo || isPlaylist 68 const startIsOk = isVideo || isPlaylist
50 69
51 const matches = watchRegex.exec(url) 70 const matches = watchRegex.exec(url)
52 71
53 if (startIsOk === false || matches === null) { 72 if (startIsOk === false || matches === null) {
54 return res.status(HttpStatusCode.BAD_REQUEST_400) 73 return res.fail({
55 .json({ error: 'Invalid url.' }) 74 status: HttpStatusCode.BAD_REQUEST_400,
75 message: 'Invalid url.',
76 data: {
77 url
78 }
79 })
56 } 80 }
57 81
58 const elementId = matches[1] 82 const elementId = toCompleteUUID(matches[1])
59 if (isIdOrUUIDValid(elementId) === false) { 83 if (isIdOrUUIDValid(elementId) === false) {
60 return res.status(HttpStatusCode.BAD_REQUEST_400) 84 return res.fail({ message: 'Invalid video or playlist id.' })
61 .json({ error: 'Invalid video or playlist id.' })
62 } 85 }
63 86
64 if (isVideo) { 87 if (isVideo) {
65 const video = await fetchVideo(elementId, 'all') 88 const video = await loadVideo(elementId, 'all')
66 89
67 if (!video) { 90 if (!video) {
68 return res.status(HttpStatusCode.NOT_FOUND_404) 91 return res.fail({
69 .json({ error: 'Video not found' }) 92 status: HttpStatusCode.NOT_FOUND_404,
93 message: 'Video not found'
94 })
70 } 95 }
71 96
72 if (video.privacy !== VideoPrivacy.PUBLIC) { 97 if (video.privacy !== VideoPrivacy.PUBLIC) {
73 return res.status(HttpStatusCode.FORBIDDEN_403) 98 return res.fail({
74 .json({ error: 'Video is not public' }) 99 status: HttpStatusCode.FORBIDDEN_403,
100 message: 'Video is not public'
101 })
75 } 102 }
76 103
77 res.locals.videoAll = video 104 res.locals.videoAll = video
@@ -82,13 +109,17 @@ const oembedValidator = [
82 109
83 const videoPlaylist = await VideoPlaylistModel.loadWithAccountAndChannelSummary(elementId, undefined) 110 const videoPlaylist = await VideoPlaylistModel.loadWithAccountAndChannelSummary(elementId, undefined)
84 if (!videoPlaylist) { 111 if (!videoPlaylist) {
85 return res.status(HttpStatusCode.NOT_FOUND_404) 112 return res.fail({
86 .json({ error: 'Video playlist not found' }) 113 status: HttpStatusCode.NOT_FOUND_404,
114 message: 'Video playlist not found'
115 })
87 } 116 }
88 117
89 if (videoPlaylist.privacy !== VideoPlaylistPrivacy.PUBLIC) { 118 if (videoPlaylist.privacy !== VideoPlaylistPrivacy.PUBLIC) {
90 return res.status(HttpStatusCode.FORBIDDEN_403) 119 return res.fail({
91 .json({ error: 'Playlist is not public' }) 120 status: HttpStatusCode.FORBIDDEN_403,
121 message: 'Playlist is not public'
122 })
92 } 123 }
93 124
94 res.locals.videoPlaylistSummary = videoPlaylist 125 res.locals.videoPlaylistSummary = videoPlaylist
diff --git a/server/middlewares/validators/pagination.ts b/server/middlewares/validators/pagination.ts
index 6b0a83d80..74eae251e 100644
--- a/server/middlewares/validators/pagination.ts
+++ b/server/middlewares/validators/pagination.ts
@@ -1,8 +1,8 @@
1import * as express from 'express' 1import * as express from 'express'
2import { query } from 'express-validator' 2import { query } from 'express-validator'
3import { logger } from '../../helpers/logger'
4import { areValidationErrors } from './utils'
5import { PAGINATION } from '@server/initializers/constants' 3import { PAGINATION } from '@server/initializers/constants'
4import { logger } from '../../helpers/logger'
5import { areValidationErrors } from './shared'
6 6
7const paginationValidator = paginationValidatorBuilder() 7const paginationValidator = paginationValidatorBuilder()
8 8
diff --git a/server/middlewares/validators/plugins.ts b/server/middlewares/validators/plugins.ts
index ab87fe720..8c76d2e36 100644
--- a/server/middlewares/validators/plugins.ts
+++ b/server/middlewares/validators/plugins.ts
@@ -1,15 +1,15 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param, query, ValidationChain } from 'express-validator' 2import { body, param, query, ValidationChain } from 'express-validator'
3import { logger } from '../../helpers/logger' 3import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
4import { areValidationErrors } from './utils' 4import { PluginType } from '../../../shared/models/plugins/plugin.type'
5import { InstallOrUpdatePlugin } from '../../../shared/models/plugins/server/api/install-plugin.model'
6import { exists, isBooleanValid, isSafePath, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
5import { isNpmPluginNameValid, isPluginNameValid, isPluginTypeValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins' 7import { isNpmPluginNameValid, isPluginNameValid, isPluginTypeValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins'
8import { logger } from '../../helpers/logger'
9import { CONFIG } from '../../initializers/config'
6import { PluginManager } from '../../lib/plugins/plugin-manager' 10import { PluginManager } from '../../lib/plugins/plugin-manager'
7import { isBooleanValid, isSafePath, toBooleanOrNull, exists, toIntOrNull } from '../../helpers/custom-validators/misc'
8import { PluginModel } from '../../models/server/plugin' 11import { PluginModel } from '../../models/server/plugin'
9import { InstallOrUpdatePlugin } from '../../../shared/models/plugins/install-plugin.model' 12import { areValidationErrors } from './shared'
10import { PluginType } from '../../../shared/models/plugins/plugin.type'
11import { CONFIG } from '../../initializers/config'
12import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
13 13
14const getPluginValidator = (pluginType: PluginType, withVersion = true) => { 14const getPluginValidator = (pluginType: PluginType, withVersion = true) => {
15 const validators: (ValidationChain | express.Handler)[] = [ 15 const validators: (ValidationChain | express.Handler)[] = [
@@ -31,8 +31,18 @@ const getPluginValidator = (pluginType: PluginType, withVersion = true) => {
31 const npmName = PluginModel.buildNpmName(req.params.pluginName, pluginType) 31 const npmName = PluginModel.buildNpmName(req.params.pluginName, pluginType)
32 const plugin = PluginManager.Instance.getRegisteredPluginOrTheme(npmName) 32 const plugin = PluginManager.Instance.getRegisteredPluginOrTheme(npmName)
33 33
34 if (!plugin) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 34 if (!plugin) {
35 if (withVersion && plugin.version !== req.params.pluginVersion) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 35 return res.fail({
36 status: HttpStatusCode.NOT_FOUND_404,
37 message: 'No plugin found named ' + npmName
38 })
39 }
40 if (withVersion && plugin.version !== req.params.pluginVersion) {
41 return res.fail({
42 status: HttpStatusCode.NOT_FOUND_404,
43 message: 'No plugin found named ' + npmName + ' with version ' + req.params.pluginVersion
44 })
45 }
36 46
37 res.locals.registeredPlugin = plugin 47 res.locals.registeredPlugin = plugin
38 48
@@ -50,10 +60,20 @@ const getExternalAuthValidator = [
50 if (areValidationErrors(req, res)) return 60 if (areValidationErrors(req, res)) return
51 61
52 const plugin = res.locals.registeredPlugin 62 const plugin = res.locals.registeredPlugin
53 if (!plugin.registerHelpers) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 63 if (!plugin.registerHelpers) {
64 return res.fail({
65 status: HttpStatusCode.NOT_FOUND_404,
66 message: 'No registered helpers were found for this plugin'
67 })
68 }
54 69
55 const externalAuth = plugin.registerHelpers.getExternalAuths().find(a => a.authName === req.params.authName) 70 const externalAuth = plugin.registerHelpers.getExternalAuths().find(a => a.authName === req.params.authName)
56 if (!externalAuth) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 71 if (!externalAuth) {
72 return res.fail({
73 status: HttpStatusCode.NOT_FOUND_404,
74 message: 'No external auths were found for this plugin'
75 })
76 }
57 77
58 res.locals.externalAuth = externalAuth 78 res.locals.externalAuth = externalAuth
59 79
@@ -107,8 +127,7 @@ const installOrUpdatePluginValidator = [
107 127
108 const body: InstallOrUpdatePlugin = req.body 128 const body: InstallOrUpdatePlugin = req.body
109 if (!body.path && !body.npmName) { 129 if (!body.path && !body.npmName) {
110 return res.status(HttpStatusCode.BAD_REQUEST_400) 130 return res.fail({ message: 'Should have either a npmName or a path' })
111 .json({ error: 'Should have either a npmName or a path' })
112 } 131 }
113 132
114 return next() 133 return next()
@@ -137,12 +156,13 @@ const existingPluginValidator = [
137 156
138 const plugin = await PluginModel.loadByNpmName(req.params.npmName) 157 const plugin = await PluginModel.loadByNpmName(req.params.npmName)
139 if (!plugin) { 158 if (!plugin) {
140 return res.status(HttpStatusCode.NOT_FOUND_404) 159 return res.fail({
141 .json({ error: 'Plugin not found' }) 160 status: HttpStatusCode.NOT_FOUND_404,
161 message: 'Plugin not found'
162 })
142 } 163 }
143 164
144 res.locals.plugin = plugin 165 res.locals.plugin = plugin
145
146 return next() 166 return next()
147 } 167 }
148] 168]
@@ -177,9 +197,7 @@ const listAvailablePluginsValidator = [
177 if (areValidationErrors(req, res)) return 197 if (areValidationErrors(req, res)) return
178 198
179 if (CONFIG.PLUGINS.INDEX.ENABLED === false) { 199 if (CONFIG.PLUGINS.INDEX.ENABLED === false) {
180 return res.status(HttpStatusCode.BAD_REQUEST_400) 200 return res.fail({ message: 'Plugin index is not enabled' })
181 .json({ error: 'Plugin index is not enabled' })
182 .end()
183 } 201 }
184 202
185 return next() 203 return next()
diff --git a/server/middlewares/validators/redundancy.ts b/server/middlewares/validators/redundancy.ts
index c379aebe4..116c8c611 100644
--- a/server/middlewares/validators/redundancy.ts
+++ b/server/middlewares/validators/redundancy.ts
@@ -1,17 +1,25 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param, query } from 'express-validator' 2import { body, param, query } from 'express-validator'
3import { exists, isBooleanValid, isIdOrUUIDValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' 3import { isVideoRedundancyTarget } from '@server/helpers/custom-validators/video-redundancies'
4import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
5import {
6 exists,
7 isBooleanValid,
8 isIdOrUUIDValid,
9 isIdValid,
10 toBooleanOrNull,
11 toCompleteUUID,
12 toIntOrNull
13} from '../../helpers/custom-validators/misc'
14import { isHostValid } from '../../helpers/custom-validators/servers'
4import { logger } from '../../helpers/logger' 15import { logger } from '../../helpers/logger'
5import { areValidationErrors } from './utils'
6import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy' 16import { VideoRedundancyModel } from '../../models/redundancy/video-redundancy'
7import { isHostValid } from '../../helpers/custom-validators/servers'
8import { ServerModel } from '../../models/server/server' 17import { ServerModel } from '../../models/server/server'
9import { doesVideoExist } from '../../helpers/middlewares' 18import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from './shared'
10import { isVideoRedundancyTarget } from '@server/helpers/custom-validators/video-redundancies'
11import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
12 19
13const videoFileRedundancyGetValidator = [ 20const videoFileRedundancyGetValidator = [
14 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), 21 isValidVideoIdParam('videoId'),
22
15 param('resolution') 23 param('resolution')
16 .customSanitizer(toIntOrNull) 24 .customSanitizer(toIntOrNull)
17 .custom(exists).withMessage('Should have a valid resolution'), 25 .custom(exists).withMessage('Should have a valid resolution'),
@@ -35,11 +43,21 @@ const videoFileRedundancyGetValidator = [
35 return f.resolution === paramResolution && (!req.params.fps || paramFPS) 43 return f.resolution === paramResolution && (!req.params.fps || paramFPS)
36 }) 44 })
37 45
38 if (!videoFile) return res.status(HttpStatusCode.NOT_FOUND_404).json({ error: 'Video file not found.' }) 46 if (!videoFile) {
47 return res.fail({
48 status: HttpStatusCode.NOT_FOUND_404,
49 message: 'Video file not found.'
50 })
51 }
39 res.locals.videoFile = videoFile 52 res.locals.videoFile = videoFile
40 53
41 const videoRedundancy = await VideoRedundancyModel.loadLocalByFileId(videoFile.id) 54 const videoRedundancy = await VideoRedundancyModel.loadLocalByFileId(videoFile.id)
42 if (!videoRedundancy) return res.status(HttpStatusCode.NOT_FOUND_404).json({ error: 'Video redundancy not found.' }) 55 if (!videoRedundancy) {
56 return res.fail({
57 status: HttpStatusCode.NOT_FOUND_404,
58 message: 'Video redundancy not found.'
59 })
60 }
43 res.locals.videoRedundancy = videoRedundancy 61 res.locals.videoRedundancy = videoRedundancy
44 62
45 return next() 63 return next()
@@ -47,9 +65,8 @@ const videoFileRedundancyGetValidator = [
47] 65]
48 66
49const videoPlaylistRedundancyGetValidator = [ 67const videoPlaylistRedundancyGetValidator = [
50 param('videoId') 68 isValidVideoIdParam('videoId'),
51 .custom(isIdOrUUIDValid) 69
52 .not().isEmpty().withMessage('Should have a valid video id'),
53 param('streamingPlaylistType') 70 param('streamingPlaylistType')
54 .customSanitizer(toIntOrNull) 71 .customSanitizer(toIntOrNull)
55 .custom(exists).withMessage('Should have a valid streaming playlist type'), 72 .custom(exists).withMessage('Should have a valid streaming playlist type'),
@@ -65,11 +82,21 @@ const videoPlaylistRedundancyGetValidator = [
65 const paramPlaylistType = req.params.streamingPlaylistType as unknown as number // We casted to int above 82 const paramPlaylistType = req.params.streamingPlaylistType as unknown as number // We casted to int above
66 const videoStreamingPlaylist = video.VideoStreamingPlaylists.find(p => p.type === paramPlaylistType) 83 const videoStreamingPlaylist = video.VideoStreamingPlaylists.find(p => p.type === paramPlaylistType)
67 84
68 if (!videoStreamingPlaylist) return res.status(HttpStatusCode.NOT_FOUND_404).json({ error: 'Video playlist not found.' }) 85 if (!videoStreamingPlaylist) {
86 return res.fail({
87 status: HttpStatusCode.NOT_FOUND_404,
88 message: 'Video playlist not found.'
89 })
90 }
69 res.locals.videoStreamingPlaylist = videoStreamingPlaylist 91 res.locals.videoStreamingPlaylist = videoStreamingPlaylist
70 92
71 const videoRedundancy = await VideoRedundancyModel.loadLocalByStreamingPlaylistId(videoStreamingPlaylist.id) 93 const videoRedundancy = await VideoRedundancyModel.loadLocalByStreamingPlaylistId(videoStreamingPlaylist.id)
72 if (!videoRedundancy) return res.status(HttpStatusCode.NOT_FOUND_404).json({ error: 'Video redundancy not found.' }) 94 if (!videoRedundancy) {
95 return res.fail({
96 status: HttpStatusCode.NOT_FOUND_404,
97 message: 'Video redundancy not found.'
98 })
99 }
73 res.locals.videoRedundancy = videoRedundancy 100 res.locals.videoRedundancy = videoRedundancy
74 101
75 return next() 102 return next()
@@ -90,12 +117,10 @@ const updateServerRedundancyValidator = [
90 const server = await ServerModel.loadByHost(req.params.host) 117 const server = await ServerModel.loadByHost(req.params.host)
91 118
92 if (!server) { 119 if (!server) {
93 return res 120 return res.fail({
94 .status(HttpStatusCode.NOT_FOUND_404) 121 status: HttpStatusCode.NOT_FOUND_404,
95 .json({ 122 message: `Server ${req.params.host} not found.`
96 error: `Server ${req.params.host} not found.` 123 })
97 })
98 .end()
99 } 124 }
100 125
101 res.locals.server = server 126 res.locals.server = server
@@ -118,7 +143,8 @@ const listVideoRedundanciesValidator = [
118 143
119const addVideoRedundancyValidator = [ 144const addVideoRedundancyValidator = [
120 body('videoId') 145 body('videoId')
121 .custom(isIdValid) 146 .customSanitizer(toCompleteUUID)
147 .custom(isIdOrUUIDValid)
122 .withMessage('Should have a valid video id'), 148 .withMessage('Should have a valid video id'),
123 149
124 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 150 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
@@ -129,19 +155,19 @@ const addVideoRedundancyValidator = [
129 if (!await doesVideoExist(req.body.videoId, res, 'only-video')) return 155 if (!await doesVideoExist(req.body.videoId, res, 'only-video')) return
130 156
131 if (res.locals.onlyVideo.remote === false) { 157 if (res.locals.onlyVideo.remote === false) {
132 return res.status(HttpStatusCode.BAD_REQUEST_400) 158 return res.fail({ message: 'Cannot create a redundancy on a local video' })
133 .json({ error: 'Cannot create a redundancy on a local video' })
134 } 159 }
135 160
136 if (res.locals.onlyVideo.isLive) { 161 if (res.locals.onlyVideo.isLive) {
137 return res.status(HttpStatusCode.BAD_REQUEST_400) 162 return res.fail({ message: 'Cannot create a redundancy of a live video' })
138 .json({ error: 'Cannot create a redundancy of a live video' })
139 } 163 }
140 164
141 const alreadyExists = await VideoRedundancyModel.isLocalByVideoUUIDExists(res.locals.onlyVideo.uuid) 165 const alreadyExists = await VideoRedundancyModel.isLocalByVideoUUIDExists(res.locals.onlyVideo.uuid)
142 if (alreadyExists) { 166 if (alreadyExists) {
143 return res.status(HttpStatusCode.CONFLICT_409) 167 return res.fail({
144 .json({ error: 'This video is already duplicated by your instance.' }) 168 status: HttpStatusCode.CONFLICT_409,
169 message: 'This video is already duplicated by your instance.'
170 })
145 } 171 }
146 172
147 return next() 173 return next()
@@ -160,9 +186,10 @@ const removeVideoRedundancyValidator = [
160 186
161 const redundancy = await VideoRedundancyModel.loadByIdWithVideo(parseInt(req.params.redundancyId, 10)) 187 const redundancy = await VideoRedundancyModel.loadByIdWithVideo(parseInt(req.params.redundancyId, 10))
162 if (!redundancy) { 188 if (!redundancy) {
163 return res.status(HttpStatusCode.NOT_FOUND_404) 189 return res.fail({
164 .json({ error: 'Video redundancy not found' }) 190 status: HttpStatusCode.NOT_FOUND_404,
165 .end() 191 message: 'Video redundancy not found'
192 })
166 } 193 }
167 194
168 res.locals.videoRedundancy = redundancy 195 res.locals.videoRedundancy = redundancy
diff --git a/server/middlewares/validators/search.ts b/server/middlewares/validators/search.ts
index 78213c70d..7bbf81048 100644
--- a/server/middlewares/validators/search.ts
+++ b/server/middlewares/validators/search.ts
@@ -1,18 +1,26 @@
1import * as express from 'express' 1import * as express from 'express'
2import { areValidationErrors } from './utils'
3import { logger } from '../../helpers/logger'
4import { query } from 'express-validator' 2import { query } from 'express-validator'
5import { isDateValid } from '../../helpers/custom-validators/misc'
6import { isSearchTargetValid } from '@server/helpers/custom-validators/search' 3import { isSearchTargetValid } from '@server/helpers/custom-validators/search'
4import { isDateValid } from '../../helpers/custom-validators/misc'
5import { logger } from '../../helpers/logger'
6import { areValidationErrors } from './shared'
7 7
8const videosSearchValidator = [ 8const videosSearchValidator = [
9 query('search').optional().not().isEmpty().withMessage('Should have a valid search'), 9 query('search').optional().not().isEmpty().withMessage('Should have a valid search'),
10 10
11 query('startDate').optional().custom(isDateValid).withMessage('Should have a valid start date'), 11 query('startDate')
12 query('endDate').optional().custom(isDateValid).withMessage('Should have a valid end date'), 12 .optional()
13 .custom(isDateValid).withMessage('Should have a start date that conforms to ISO 8601'),
14 query('endDate')
15 .optional()
16 .custom(isDateValid).withMessage('Should have a end date that conforms to ISO 8601'),
13 17
14 query('originallyPublishedStartDate').optional().custom(isDateValid).withMessage('Should have a valid published start date'), 18 query('originallyPublishedStartDate')
15 query('originallyPublishedEndDate').optional().custom(isDateValid).withMessage('Should have a valid published end date'), 19 .optional()
20 .custom(isDateValid).withMessage('Should have a published start date that conforms to ISO 8601'),
21 query('originallyPublishedEndDate')
22 .optional()
23 .custom(isDateValid).withMessage('Should have a published end date that conforms to ISO 8601'),
16 24
17 query('durationMin').optional().isInt().withMessage('Should have a valid min duration'), 25 query('durationMin').optional().isInt().withMessage('Should have a valid min duration'),
18 query('durationMax').optional().isInt().withMessage('Should have a valid max duration'), 26 query('durationMax').optional().isInt().withMessage('Should have a valid max duration'),
@@ -41,11 +49,12 @@ const videoChannelsListSearchValidator = [
41 } 49 }
42] 50]
43 51
44const videoChannelsOwnSearchValidator = [ 52const videoPlaylistsListSearchValidator = [
45 query('search').optional().not().isEmpty().withMessage('Should have a valid search'), 53 query('search').not().isEmpty().withMessage('Should have a valid search'),
54 query('searchTarget').optional().custom(isSearchTargetValid).withMessage('Should have a valid search target'),
46 55
47 (req: express.Request, res: express.Response, next: express.NextFunction) => { 56 (req: express.Request, res: express.Response, next: express.NextFunction) => {
48 logger.debug('Checking video channels search query', { parameters: req.query }) 57 logger.debug('Checking video playlists search query', { parameters: req.query })
49 58
50 if (areValidationErrors(req, res)) return 59 if (areValidationErrors(req, res)) return
51 60
@@ -58,5 +67,5 @@ const videoChannelsOwnSearchValidator = [
58export { 67export {
59 videosSearchValidator, 68 videosSearchValidator,
60 videoChannelsListSearchValidator, 69 videoChannelsListSearchValidator,
61 videoChannelsOwnSearchValidator 70 videoPlaylistsListSearchValidator
62} 71}
diff --git a/server/middlewares/validators/server.ts b/server/middlewares/validators/server.ts
index fe6704716..fc7239b25 100644
--- a/server/middlewares/validators/server.ts
+++ b/server/middlewares/validators/server.ts
@@ -1,13 +1,13 @@
1import * as express from 'express' 1import * as express from 'express'
2import { logger } from '../../helpers/logger'
3import { areValidationErrors } from './utils'
4import { isHostValid, isValidContactBody } from '../../helpers/custom-validators/servers'
5import { ServerModel } from '../../models/server/server'
6import { body } from 'express-validator' 2import { body } from 'express-validator'
3import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
4import { isHostValid, isValidContactBody } from '../../helpers/custom-validators/servers'
7import { isUserDisplayNameValid } from '../../helpers/custom-validators/users' 5import { isUserDisplayNameValid } from '../../helpers/custom-validators/users'
8import { Redis } from '../../lib/redis' 6import { logger } from '../../helpers/logger'
9import { CONFIG, isEmailEnabled } from '../../initializers/config' 7import { CONFIG, isEmailEnabled } from '../../initializers/config'
10import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' 8import { Redis } from '../../lib/redis'
9import { ServerModel } from '../../models/server/server'
10import { areValidationErrors } from './shared'
11 11
12const serverGetValidator = [ 12const serverGetValidator = [
13 body('host').custom(isHostValid).withMessage('Should have a valid host'), 13 body('host').custom(isHostValid).withMessage('Should have a valid host'),
@@ -19,9 +19,10 @@ const serverGetValidator = [
19 19
20 const server = await ServerModel.loadByHost(req.body.host) 20 const server = await ServerModel.loadByHost(req.body.host)
21 if (!server) { 21 if (!server) {
22 return res.status(HttpStatusCode.NOT_FOUND_404) 22 return res.fail({
23 .send({ error: 'Server host not found.' }) 23 status: HttpStatusCode.NOT_FOUND_404,
24 .end() 24 message: 'Server host not found.'
25 })
25 } 26 }
26 27
27 res.locals.server = server 28 res.locals.server = server
@@ -44,26 +45,26 @@ const contactAdministratorValidator = [
44 if (areValidationErrors(req, res)) return 45 if (areValidationErrors(req, res)) return
45 46
46 if (CONFIG.CONTACT_FORM.ENABLED === false) { 47 if (CONFIG.CONTACT_FORM.ENABLED === false) {
47 return res 48 return res.fail({
48 .status(HttpStatusCode.CONFLICT_409) 49 status: HttpStatusCode.CONFLICT_409,
49 .send({ error: 'Contact form is not enabled on this instance.' }) 50 message: 'Contact form is not enabled on this instance.'
50 .end() 51 })
51 } 52 }
52 53
53 if (isEmailEnabled() === false) { 54 if (isEmailEnabled() === false) {
54 return res 55 return res.fail({
55 .status(HttpStatusCode.CONFLICT_409) 56 status: HttpStatusCode.CONFLICT_409,
56 .send({ error: 'Emailer is not enabled on this instance.' }) 57 message: 'Emailer is not enabled on this instance.'
57 .end() 58 })
58 } 59 }
59 60
60 if (await Redis.Instance.doesContactFormIpExist(req.ip)) { 61 if (await Redis.Instance.doesContactFormIpExist(req.ip)) {
61 logger.info('Refusing a contact form by %s: already sent one recently.', req.ip) 62 logger.info('Refusing a contact form by %s: already sent one recently.', req.ip)
62 63
63 return res 64 return res.fail({
64 .status(HttpStatusCode.FORBIDDEN_403) 65 status: HttpStatusCode.FORBIDDEN_403,
65 .send({ error: 'You already sent a contact form recently.' }) 66 message: 'You already sent a contact form recently.'
66 .end() 67 })
67 } 68 }
68 69
69 return next() 70 return next()
diff --git a/server/helpers/middlewares/abuses.ts b/server/middlewares/validators/shared/abuses.ts
index c53bd9efd..4a20a55fa 100644
--- a/server/helpers/middlewares/abuses.ts
+++ b/server/middlewares/validators/shared/abuses.ts
@@ -1,13 +1,15 @@
1import { Response } from 'express' 1import { Response } from 'express'
2import { AbuseModel } from '../../models/abuse/abuse' 2import { AbuseModel } from '@server/models/abuse/abuse'
3import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' 3import { HttpStatusCode } from '@shared/core-utils'
4 4
5async function doesAbuseExist (abuseId: number | string, res: Response) { 5async function doesAbuseExist (abuseId: number | string, res: Response) {
6 const abuse = await AbuseModel.loadByIdWithReporter(parseInt(abuseId + '', 10)) 6 const abuse = await AbuseModel.loadByIdWithReporter(parseInt(abuseId + '', 10))
7 7
8 if (!abuse) { 8 if (!abuse) {
9 res.status(HttpStatusCode.NOT_FOUND_404) 9 res.fail({
10 .json({ error: 'Abuse not found' }) 10 status: HttpStatusCode.NOT_FOUND_404,
11 message: 'Abuse not found'
12 })
11 13
12 return false 14 return false
13 } 15 }
diff --git a/server/helpers/middlewares/accounts.ts b/server/middlewares/validators/shared/accounts.ts
index 13ae6cdf4..04da15441 100644
--- a/server/helpers/middlewares/accounts.ts
+++ b/server/middlewares/validators/shared/accounts.ts
@@ -1,8 +1,8 @@
1import { Response } from 'express' 1import { Response } from 'express'
2import { UserModel } from '@server/models/account/user' 2import { AccountModel } from '@server/models/account/account'
3import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' 3import { UserModel } from '@server/models/user/user'
4import { AccountModel } from '../../models/account/account' 4import { MAccountDefault } from '@server/types/models'
5import { MAccountDefault } from '../../types/models' 5import { HttpStatusCode } from '@shared/core-utils'
6 6
7function doesAccountIdExist (id: number | string, res: Response, sendNotFound = true) { 7function doesAccountIdExist (id: number | string, res: Response, sendNotFound = true) {
8 const promise = AccountModel.load(parseInt(id + '', 10)) 8 const promise = AccountModel.load(parseInt(id + '', 10))
@@ -27,15 +27,15 @@ async function doesAccountExist (p: Promise<MAccountDefault>, res: Response, sen
27 27
28 if (!account) { 28 if (!account) {
29 if (sendNotFound === true) { 29 if (sendNotFound === true) {
30 res.status(HttpStatusCode.NOT_FOUND_404) 30 res.fail({
31 .json({ error: 'Account not found' }) 31 status: HttpStatusCode.NOT_FOUND_404,
32 message: 'Account not found'
33 })
32 } 34 }
33
34 return false 35 return false
35 } 36 }
36 37
37 res.locals.account = account 38 res.locals.account = account
38
39 return true 39 return true
40} 40}
41 41
@@ -43,14 +43,14 @@ async function doesUserFeedTokenCorrespond (id: number, token: string, res: Resp
43 const user = await UserModel.loadByIdWithChannels(parseInt(id + '', 10)) 43 const user = await UserModel.loadByIdWithChannels(parseInt(id + '', 10))
44 44
45 if (token !== user.feedToken) { 45 if (token !== user.feedToken) {
46 res.status(HttpStatusCode.FORBIDDEN_403) 46 res.fail({
47 .json({ error: 'User and token mismatch' }) 47 status: HttpStatusCode.FORBIDDEN_403,
48 48 message: 'User and token mismatch'
49 })
49 return false 50 return false
50 } 51 }
51 52
52 res.locals.user = user 53 res.locals.user = user
53
54 return true 54 return true
55} 55}
56 56
diff --git a/server/helpers/middlewares/index.ts b/server/middlewares/validators/shared/index.ts
index f57f3ad31..fa89d05f2 100644
--- a/server/helpers/middlewares/index.ts
+++ b/server/middlewares/validators/shared/index.ts
@@ -1,7 +1,11 @@
1export * from './abuses' 1export * from './abuses'
2export * from './accounts' 2export * from './accounts'
3export * from './utils'
3export * from './video-blacklists' 4export * from './video-blacklists'
4export * from './video-captions' 5export * from './video-captions'
5export * from './video-channels' 6export * from './video-channels'
7export * from './video-comments'
8export * from './video-imports'
9export * from './video-ownerships'
6export * from './video-playlists' 10export * from './video-playlists'
7export * from './videos' 11export * from './videos'
diff --git a/server/middlewares/validators/utils.ts b/server/middlewares/validators/shared/utils.ts
index 4167f6d43..4f08560af 100644
--- a/server/middlewares/validators/utils.ts
+++ b/server/middlewares/validators/shared/utils.ts
@@ -1,15 +1,20 @@
1import * as express from 'express' 1import * as express from 'express'
2import { query, validationResult } from 'express-validator' 2import { param, query, validationResult } from 'express-validator'
3import { logger } from '../../helpers/logger' 3import { isIdOrUUIDValid, toCompleteUUID } from '@server/helpers/custom-validators/misc'
4import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' 4import { logger } from '../../../helpers/logger'
5 5
6function areValidationErrors (req: express.Request, res: express.Response) { 6function areValidationErrors (req: express.Request, res: express.Response) {
7 const errors = validationResult(req) 7 const errors = validationResult(req)
8 8
9 if (!errors.isEmpty()) { 9 if (!errors.isEmpty()) {
10 logger.warn('Incorrect request parameters', { path: req.originalUrl, err: errors.mapped() }) 10 logger.warn('Incorrect request parameters', { path: req.originalUrl, err: errors.mapped() })
11 res.status(HttpStatusCode.BAD_REQUEST_400) 11 res.fail({
12 .json({ errors: errors.mapped() }) 12 message: 'Incorrect request parameters: ' + Object.keys(errors.mapped()).join(', '),
13 instance: req.originalUrl,
14 data: {
15 'invalid-params': errors.mapped()
16 }
17 })
13 18
14 return true 19 return true
15 } 20 }
@@ -37,10 +42,24 @@ function createSortableColumns (sortableColumns: string[]) {
37 return sortableColumns.concat(sortableColumnDesc) 42 return sortableColumns.concat(sortableColumnDesc)
38} 43}
39 44
45function isValidVideoIdParam (paramName: string) {
46 return param(paramName)
47 .customSanitizer(toCompleteUUID)
48 .custom(isIdOrUUIDValid).withMessage('Should have a valid video id')
49}
50
51function isValidPlaylistIdParam (paramName: string) {
52 return param(paramName)
53 .customSanitizer(toCompleteUUID)
54 .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id')
55}
56
40// --------------------------------------------------------------------------- 57// ---------------------------------------------------------------------------
41 58
42export { 59export {
43 areValidationErrors, 60 areValidationErrors,
44 checkSort, 61 checkSort,
45 createSortableColumns 62 createSortableColumns,
63 isValidVideoIdParam,
64 isValidPlaylistIdParam
46} 65}
diff --git a/server/helpers/middlewares/video-blacklists.ts b/server/middlewares/validators/shared/video-blacklists.ts
index eda1324d3..01491c10f 100644
--- a/server/helpers/middlewares/video-blacklists.ts
+++ b/server/middlewares/validators/shared/video-blacklists.ts
@@ -1,15 +1,15 @@
1import { Response } from 'express' 1import { Response } from 'express'
2import { VideoBlacklistModel } from '../../models/video/video-blacklist' 2import { VideoBlacklistModel } from '@server/models/video/video-blacklist'
3import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' 3import { HttpStatusCode } from '@shared/core-utils'
4 4
5async function doesVideoBlacklistExist (videoId: number, res: Response) { 5async function doesVideoBlacklistExist (videoId: number, res: Response) {
6 const videoBlacklist = await VideoBlacklistModel.loadByVideoId(videoId) 6 const videoBlacklist = await VideoBlacklistModel.loadByVideoId(videoId)
7 7
8 if (videoBlacklist === null) { 8 if (videoBlacklist === null) {
9 res.status(HttpStatusCode.NOT_FOUND_404) 9 res.fail({
10 .json({ error: 'Blacklisted video not found' }) 10 status: HttpStatusCode.NOT_FOUND_404,
11 .end() 11 message: 'Blacklisted video not found'
12 12 })
13 return false 13 return false
14 } 14 }
15 15
diff --git a/server/helpers/middlewares/video-captions.ts b/server/middlewares/validators/shared/video-captions.ts
index 226d3c5f8..80f6c5a52 100644
--- a/server/helpers/middlewares/video-captions.ts
+++ b/server/middlewares/validators/shared/video-captions.ts
@@ -1,15 +1,16 @@
1import { Response } from 'express' 1import { Response } from 'express'
2import { VideoCaptionModel } from '../../models/video/video-caption' 2import { VideoCaptionModel } from '@server/models/video/video-caption'
3import { MVideoId } from '@server/types/models' 3import { MVideoId } from '@server/types/models'
4import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' 4import { HttpStatusCode } from '@shared/core-utils'
5 5
6async function doesVideoCaptionExist (video: MVideoId, language: string, res: Response) { 6async function doesVideoCaptionExist (video: MVideoId, language: string, res: Response) {
7 const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(video.id, language) 7 const videoCaption = await VideoCaptionModel.loadByVideoIdAndLanguage(video.id, language)
8 8
9 if (!videoCaption) { 9 if (!videoCaption) {
10 res.status(HttpStatusCode.NOT_FOUND_404) 10 res.fail({
11 .json({ error: 'Video caption not found' }) 11 status: HttpStatusCode.NOT_FOUND_404,
12 12 message: 'Video caption not found'
13 })
13 return false 14 return false
14 } 15 }
15 16
diff --git a/server/helpers/middlewares/video-channels.ts b/server/middlewares/validators/shared/video-channels.ts
index e6eab65a2..fe2e663b7 100644
--- a/server/helpers/middlewares/video-channels.ts
+++ b/server/middlewares/validators/shared/video-channels.ts
@@ -1,7 +1,7 @@
1import * as express from 'express' 1import * as express from 'express'
2import { VideoChannelModel } from '@server/models/video/video-channel'
2import { MChannelBannerAccountDefault } from '@server/types/models' 3import { MChannelBannerAccountDefault } from '@server/types/models'
3import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' 4import { HttpStatusCode } from '@shared/core-utils'
4import { VideoChannelModel } from '../../models/video/video-channel'
5 5
6async function doesLocalVideoChannelNameExist (name: string, res: express.Response) { 6async function doesLocalVideoChannelNameExist (name: string, res: express.Response) {
7 const videoChannel = await VideoChannelModel.loadLocalByNameAndPopulateAccount(name) 7 const videoChannel = await VideoChannelModel.loadLocalByNameAndPopulateAccount(name)
@@ -31,9 +31,10 @@ export {
31 31
32function processVideoChannelExist (videoChannel: MChannelBannerAccountDefault, res: express.Response) { 32function processVideoChannelExist (videoChannel: MChannelBannerAccountDefault, res: express.Response) {
33 if (!videoChannel) { 33 if (!videoChannel) {
34 res.status(HttpStatusCode.NOT_FOUND_404) 34 res.fail({
35 .json({ error: 'Video channel not found' }) 35 status: HttpStatusCode.NOT_FOUND_404,
36 36 message: 'Video channel not found'
37 })
37 return false 38 return false
38 } 39 }
39 40
diff --git a/server/middlewares/validators/shared/video-comments.ts b/server/middlewares/validators/shared/video-comments.ts
new file mode 100644
index 000000000..83ea15c98
--- /dev/null
+++ b/server/middlewares/validators/shared/video-comments.ts
@@ -0,0 +1,73 @@
1import * as express from 'express'
2import { VideoCommentModel } from '@server/models/video/video-comment'
3import { MVideoId } from '@server/types/models'
4import { HttpStatusCode } from '@shared/core-utils'
5
6async function doesVideoCommentThreadExist (idArg: number | string, video: MVideoId, res: express.Response) {
7 const id = parseInt(idArg + '', 10)
8 const videoComment = await VideoCommentModel.loadById(id)
9
10 if (!videoComment) {
11 res.fail({
12 status: HttpStatusCode.NOT_FOUND_404,
13 message: 'Video comment thread not found'
14 })
15 return false
16 }
17
18 if (videoComment.videoId !== video.id) {
19 res.fail({ message: 'Video comment is not associated to this video.' })
20 return false
21 }
22
23 if (videoComment.inReplyToCommentId !== null) {
24 res.fail({ message: 'Video comment is not a thread.' })
25 return false
26 }
27
28 res.locals.videoCommentThread = videoComment
29 return true
30}
31
32async function doesVideoCommentExist (idArg: number | string, video: MVideoId, res: express.Response) {
33 const id = parseInt(idArg + '', 10)
34 const videoComment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(id)
35
36 if (!videoComment) {
37 res.fail({
38 status: HttpStatusCode.NOT_FOUND_404,
39 message: 'Video comment thread not found'
40 })
41 return false
42 }
43
44 if (videoComment.videoId !== video.id) {
45 res.fail({ message: 'Video comment is not associated to this video.' })
46 return false
47 }
48
49 res.locals.videoCommentFull = videoComment
50 return true
51}
52
53async function doesCommentIdExist (idArg: number | string, res: express.Response) {
54 const id = parseInt(idArg + '', 10)
55 const videoComment = await VideoCommentModel.loadByIdAndPopulateVideoAndAccountAndReply(id)
56
57 if (!videoComment) {
58 res.fail({
59 status: HttpStatusCode.NOT_FOUND_404,
60 message: 'Video comment thread not found'
61 })
62 return false
63 }
64
65 res.locals.videoCommentFull = videoComment
66 return true
67}
68
69export {
70 doesVideoCommentThreadExist,
71 doesVideoCommentExist,
72 doesCommentIdExist
73}
diff --git a/server/middlewares/validators/shared/video-imports.ts b/server/middlewares/validators/shared/video-imports.ts
new file mode 100644
index 000000000..0f984bc17
--- /dev/null
+++ b/server/middlewares/validators/shared/video-imports.ts
@@ -0,0 +1,22 @@
1import * as express from 'express'
2import { VideoImportModel } from '@server/models/video/video-import'
3import { HttpStatusCode } from '@shared/core-utils'
4
5async function doesVideoImportExist (id: number, res: express.Response) {
6 const videoImport = await VideoImportModel.loadAndPopulateVideo(id)
7
8 if (!videoImport) {
9 res.fail({
10 status: HttpStatusCode.NOT_FOUND_404,
11 message: 'Video import not found'
12 })
13 return false
14 }
15
16 res.locals.videoImport = videoImport
17 return true
18}
19
20export {
21 doesVideoImportExist
22}
diff --git a/server/middlewares/validators/shared/video-ownerships.ts b/server/middlewares/validators/shared/video-ownerships.ts
new file mode 100644
index 000000000..fc27006ce
--- /dev/null
+++ b/server/middlewares/validators/shared/video-ownerships.ts
@@ -0,0 +1,24 @@
1import * as express from 'express'
2import { VideoChangeOwnershipModel } from '@server/models/video/video-change-ownership'
3import { HttpStatusCode } from '@shared/core-utils'
4
5async function doesChangeVideoOwnershipExist (idArg: number | string, res: express.Response) {
6 const id = parseInt(idArg + '', 10)
7 const videoChangeOwnership = await VideoChangeOwnershipModel.load(id)
8
9 if (!videoChangeOwnership) {
10 res.fail({
11 status: HttpStatusCode.NOT_FOUND_404,
12 message: 'Video change ownership not found'
13 })
14 return false
15 }
16
17 res.locals.videoChangeOwnership = videoChangeOwnership
18
19 return true
20}
21
22export {
23 doesChangeVideoOwnershipExist
24}
diff --git a/server/helpers/middlewares/video-playlists.ts b/server/middlewares/validators/shared/video-playlists.ts
index d2dd80a35..d762859a8 100644
--- a/server/helpers/middlewares/video-playlists.ts
+++ b/server/middlewares/validators/shared/video-playlists.ts
@@ -1,7 +1,7 @@
1import * as express from 'express' 1import * as express from 'express'
2import { VideoPlaylistModel } from '../../models/video/video-playlist' 2import { VideoPlaylistModel } from '@server/models/video/video-playlist'
3import { MVideoPlaylist } from '../../types/models/video/video-playlist' 3import { MVideoPlaylist } from '@server/types/models'
4import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' 4import { HttpStatusCode } from '@shared/core-utils'
5 5
6export type VideoPlaylistFetchType = 'summary' | 'all' 6export type VideoPlaylistFetchType = 'summary' | 'all'
7async function doesVideoPlaylistExist (id: number | string, res: express.Response, fetchType: VideoPlaylistFetchType = 'summary') { 7async function doesVideoPlaylistExist (id: number | string, res: express.Response, fetchType: VideoPlaylistFetchType = 'summary') {
@@ -28,10 +28,10 @@ export {
28 28
29function handleVideoPlaylist (videoPlaylist: MVideoPlaylist, res: express.Response) { 29function handleVideoPlaylist (videoPlaylist: MVideoPlaylist, res: express.Response) {
30 if (!videoPlaylist) { 30 if (!videoPlaylist) {
31 res.status(HttpStatusCode.NOT_FOUND_404) 31 res.fail({
32 .json({ error: 'Video playlist not found' }) 32 status: HttpStatusCode.NOT_FOUND_404,
33 .end() 33 message: 'Video playlist not found'
34 34 })
35 return false 35 return false
36 } 36 }
37 37
diff --git a/server/helpers/middlewares/videos.ts b/server/middlewares/validators/shared/videos.ts
index 403cae092..2c66c1a3a 100644
--- a/server/helpers/middlewares/videos.ts
+++ b/server/middlewares/validators/shared/videos.ts
@@ -1,34 +1,38 @@
1import { Response } from 'express' 1import { Response } from 'express'
2import { fetchVideo, VideoFetchType } from '../video' 2import { loadVideo, VideoLoadType } from '@server/lib/model-loaders'
3import { UserRight } from '../../../shared/models/users' 3import { VideoChannelModel } from '@server/models/video/video-channel'
4import { VideoChannelModel } from '../../models/video/video-channel' 4import { VideoFileModel } from '@server/models/video/video-file'
5import { 5import {
6 MUser, 6 MUser,
7 MUserAccountId, 7 MUserAccountId,
8 MVideoAccountLight, 8 MVideoAccountLight,
9 MVideoFormattableDetails,
9 MVideoFullLight, 10 MVideoFullLight,
10 MVideoIdThumbnail, 11 MVideoId,
11 MVideoImmutable, 12 MVideoImmutable,
12 MVideoThumbnail, 13 MVideoThumbnail
13 MVideoWithRights
14} from '@server/types/models' 14} from '@server/types/models'
15import { VideoFileModel } from '@server/models/video/video-file' 15import { HttpStatusCode } from '@shared/core-utils'
16import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' 16import { UserRight } from '@shared/models'
17 17
18async function doesVideoExist (id: number | string, res: Response, fetchType: VideoFetchType = 'all') { 18async function doesVideoExist (id: number | string, res: Response, fetchType: VideoLoadType = 'all') {
19 const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined 19 const userId = res.locals.oauth ? res.locals.oauth.token.User.id : undefined
20 20
21 const video = await fetchVideo(id, fetchType, userId) 21 const video = await loadVideo(id, fetchType, userId)
22 22
23 if (video === null) { 23 if (video === null) {
24 res.status(HttpStatusCode.NOT_FOUND_404) 24 res.fail({
25 .json({ error: 'Video not found' }) 25 status: HttpStatusCode.NOT_FOUND_404,
26 .end() 26 message: 'Video not found'
27 27 })
28 return false 28 return false
29 } 29 }
30 30
31 switch (fetchType) { 31 switch (fetchType) {
32 case 'for-api':
33 res.locals.videoAPI = video as MVideoFormattableDetails
34 break
35
32 case 'all': 36 case 'all':
33 res.locals.videoAll = video as MVideoFullLight 37 res.locals.videoAll = video as MVideoFullLight
34 break 38 break
@@ -38,16 +42,12 @@ async function doesVideoExist (id: number | string, res: Response, fetchType: Vi
38 break 42 break
39 43
40 case 'id': 44 case 'id':
41 res.locals.videoId = video as MVideoIdThumbnail 45 res.locals.videoId = video as MVideoId
42 break 46 break
43 47
44 case 'only-video': 48 case 'only-video':
45 res.locals.onlyVideo = video as MVideoThumbnail 49 res.locals.onlyVideo = video as MVideoThumbnail
46 break 50 break
47
48 case 'only-video-with-rights':
49 res.locals.onlyVideoWithRights = video as MVideoWithRights
50 break
51 } 51 }
52 52
53 return true 53 return true
@@ -55,10 +55,10 @@ async function doesVideoExist (id: number | string, res: Response, fetchType: Vi
55 55
56async function doesVideoFileOfVideoExist (id: number, videoIdOrUUID: number | string, res: Response) { 56async function doesVideoFileOfVideoExist (id: number, videoIdOrUUID: number | string, res: Response) {
57 if (!await VideoFileModel.doesVideoExistForVideoFile(id, videoIdOrUUID)) { 57 if (!await VideoFileModel.doesVideoExistForVideoFile(id, videoIdOrUUID)) {
58 res.status(HttpStatusCode.NOT_FOUND_404) 58 res.fail({
59 .json({ error: 'VideoFile matching Video not found' }) 59 status: HttpStatusCode.NOT_FOUND_404,
60 .end() 60 message: 'VideoFile matching Video not found'
61 61 })
62 return false 62 return false
63 } 63 }
64 64
@@ -69,9 +69,7 @@ async function doesVideoChannelOfAccountExist (channelId: number, user: MUserAcc
69 const videoChannel = await VideoChannelModel.loadAndPopulateAccount(channelId) 69 const videoChannel = await VideoChannelModel.loadAndPopulateAccount(channelId)
70 70
71 if (videoChannel === null) { 71 if (videoChannel === null) {
72 res.status(HttpStatusCode.BAD_REQUEST_400) 72 res.fail({ message: 'Unknown video "video channel" for this instance.' })
73 .json({ error: 'Unknown video "video channel" for this instance.' })
74
75 return false 73 return false
76 } 74 }
77 75
@@ -82,9 +80,9 @@ async function doesVideoChannelOfAccountExist (channelId: number, user: MUserAcc
82 } 80 }
83 81
84 if (videoChannel.Account.id !== user.Account.id) { 82 if (videoChannel.Account.id !== user.Account.id) {
85 res.status(HttpStatusCode.BAD_REQUEST_400) 83 res.fail({
86 .json({ error: 'Unknown video "video channel" for this account.' }) 84 message: 'Unknown video "video channel" for this account.'
87 85 })
88 return false 86 return false
89 } 87 }
90 88
@@ -95,9 +93,10 @@ async function doesVideoChannelOfAccountExist (channelId: number, user: MUserAcc
95function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: UserRight, res: Response, onlyOwned = true) { 93function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right: UserRight, res: Response, onlyOwned = true) {
96 // Retrieve the user who did the request 94 // Retrieve the user who did the request
97 if (onlyOwned && video.isOwned() === false) { 95 if (onlyOwned && video.isOwned() === false) {
98 res.status(HttpStatusCode.FORBIDDEN_403) 96 res.fail({
99 .json({ error: 'Cannot manage a video of another server.' }) 97 status: HttpStatusCode.FORBIDDEN_403,
100 .end() 98 message: 'Cannot manage a video of another server.'
99 })
101 return false 100 return false
102 } 101 }
103 102
@@ -106,9 +105,10 @@ function checkUserCanManageVideo (user: MUser, video: MVideoAccountLight, right:
106 // Or if s/he is the video's account 105 // Or if s/he is the video's account
107 const account = video.VideoChannel.Account 106 const account = video.VideoChannel.Account
108 if (user.hasRight(right) === false && account.userId !== user.id) { 107 if (user.hasRight(right) === false && account.userId !== user.id) {
109 res.status(HttpStatusCode.FORBIDDEN_403) 108 res.fail({
110 .json({ error: 'Cannot manage a video of another user.' }) 109 status: HttpStatusCode.FORBIDDEN_403,
111 .end() 110 message: 'Cannot manage a video of another user.'
111 })
112 return false 112 return false
113 } 113 }
114 114
diff --git a/server/middlewares/validators/sort.ts b/server/middlewares/validators/sort.ts
index beecc155b..473010460 100644
--- a/server/middlewares/validators/sort.ts
+++ b/server/middlewares/validators/sort.ts
@@ -1,5 +1,5 @@
1import { SORTABLE_COLUMNS } from '../../initializers/constants' 1import { SORTABLE_COLUMNS } from '../../initializers/constants'
2import { checkSort, createSortableColumns } from './utils' 2import { checkSort, createSortableColumns } from './shared'
3 3
4// Initialize constants here for better performances 4// Initialize constants here for better performances
5const SORTABLE_USERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USERS) 5const SORTABLE_USERS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.USERS)
@@ -9,6 +9,7 @@ const SORTABLE_ABUSES_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.ABUSES)
9const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS) 9const SORTABLE_VIDEOS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS)
10const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH) 10const SORTABLE_VIDEOS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEOS_SEARCH)
11const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH) 11const SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_CHANNELS_SEARCH)
12const SORTABLE_VIDEO_PLAYLISTS_SEARCH_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_PLAYLISTS_SEARCH)
12const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS) 13const SORTABLE_VIDEO_IMPORTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_IMPORTS)
13const SORTABLE_VIDEO_COMMENTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) 14const SORTABLE_VIDEO_COMMENTS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
14const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS) 15const SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS = createSortableColumns(SORTABLE_COLUMNS.VIDEO_COMMENT_THREADS)
@@ -34,6 +35,7 @@ const videosSortValidator = checkSort(SORTABLE_VIDEOS_COLUMNS)
34const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS) 35const videoImportsSortValidator = checkSort(SORTABLE_VIDEO_IMPORTS_COLUMNS)
35const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS) 36const videosSearchSortValidator = checkSort(SORTABLE_VIDEOS_SEARCH_COLUMNS)
36const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS) 37const videoChannelsSearchSortValidator = checkSort(SORTABLE_VIDEO_CHANNELS_SEARCH_COLUMNS)
38const videoPlaylistsSearchSortValidator = checkSort(SORTABLE_VIDEO_PLAYLISTS_SEARCH_COLUMNS)
37const videoCommentsValidator = checkSort(SORTABLE_VIDEO_COMMENTS_COLUMNS) 39const videoCommentsValidator = checkSort(SORTABLE_VIDEO_COMMENTS_COLUMNS)
38const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS) 40const videoCommentThreadsSortValidator = checkSort(SORTABLE_VIDEO_COMMENT_THREADS_COLUMNS)
39const videoRatesSortValidator = checkSort(SORTABLE_VIDEO_RATES_COLUMNS) 41const videoRatesSortValidator = checkSort(SORTABLE_VIDEO_RATES_COLUMNS)
@@ -75,5 +77,6 @@ export {
75 userNotificationsSortValidator, 77 userNotificationsSortValidator,
76 videoPlaylistsSortValidator, 78 videoPlaylistsSortValidator,
77 videoRedundanciesSortValidator, 79 videoRedundanciesSortValidator,
80 videoPlaylistsSearchSortValidator,
78 pluginsSortValidator 81 pluginsSortValidator
79} 82}
diff --git a/server/middlewares/validators/themes.ts b/server/middlewares/validators/themes.ts
index a726a567b..d4716257f 100644
--- a/server/middlewares/validators/themes.ts
+++ b/server/middlewares/validators/themes.ts
@@ -1,11 +1,11 @@
1import * as express from 'express' 1import * as express from 'express'
2import { param } from 'express-validator' 2import { param } from 'express-validator'
3import { logger } from '../../helpers/logger' 3import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
4import { areValidationErrors } from './utils' 4import { isSafePath } from '../../helpers/custom-validators/misc'
5import { isPluginNameValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins' 5import { isPluginNameValid, isPluginVersionValid } from '../../helpers/custom-validators/plugins'
6import { logger } from '../../helpers/logger'
6import { PluginManager } from '../../lib/plugins/plugin-manager' 7import { PluginManager } from '../../lib/plugins/plugin-manager'
7import { isSafePath } from '../../helpers/custom-validators/misc' 8import { areValidationErrors } from './shared'
8import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
9 9
10const serveThemeCSSValidator = [ 10const serveThemeCSSValidator = [
11 param('themeName').custom(isPluginNameValid).withMessage('Should have a valid theme name'), 11 param('themeName').custom(isPluginNameValid).withMessage('Should have a valid theme name'),
@@ -20,11 +20,17 @@ const serveThemeCSSValidator = [
20 const theme = PluginManager.Instance.getRegisteredThemeByShortName(req.params.themeName) 20 const theme = PluginManager.Instance.getRegisteredThemeByShortName(req.params.themeName)
21 21
22 if (!theme || theme.version !== req.params.themeVersion) { 22 if (!theme || theme.version !== req.params.themeVersion) {
23 return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 23 return res.fail({
24 status: HttpStatusCode.NOT_FOUND_404,
25 message: 'No theme named ' + req.params.themeName + ' was found with version ' + req.params.themeVersion
26 })
24 } 27 }
25 28
26 if (theme.css.includes(req.params.staticEndpoint) === false) { 29 if (theme.css.includes(req.params.staticEndpoint) === false) {
27 return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 30 return res.fail({
31 status: HttpStatusCode.NOT_FOUND_404,
32 message: 'No static endpoint was found for this theme'
33 })
28 } 34 }
29 35
30 res.locals.registeredPlugin = theme 36 res.locals.registeredPlugin = theme
diff --git a/server/middlewares/validators/user-history.ts b/server/middlewares/validators/user-history.ts
index 058bf7758..1db0d9b26 100644
--- a/server/middlewares/validators/user-history.ts
+++ b/server/middlewares/validators/user-history.ts
@@ -1,8 +1,8 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, query } from 'express-validator' 2import { body, query } from 'express-validator'
3import { logger } from '../../helpers/logger'
4import { areValidationErrors } from './utils'
5import { exists, isDateValid } from '../../helpers/custom-validators/misc' 3import { exists, isDateValid } from '../../helpers/custom-validators/misc'
4import { logger } from '../../helpers/logger'
5import { areValidationErrors } from './shared'
6 6
7const userHistoryListValidator = [ 7const userHistoryListValidator = [
8 query('search') 8 query('search')
@@ -21,7 +21,7 @@ const userHistoryListValidator = [
21const userHistoryRemoveValidator = [ 21const userHistoryRemoveValidator = [
22 body('beforeDate') 22 body('beforeDate')
23 .optional() 23 .optional()
24 .custom(isDateValid).withMessage('Should have a valid before date'), 24 .custom(isDateValid).withMessage('Should have a before date that conforms to ISO 8601'),
25 25
26 (req: express.Request, res: express.Response, next: express.NextFunction) => { 26 (req: express.Request, res: express.Response, next: express.NextFunction) => {
27 logger.debug('Checking userHistoryRemoveValidator parameters', { parameters: req.body }) 27 logger.debug('Checking userHistoryRemoveValidator parameters', { parameters: req.body })
diff --git a/server/middlewares/validators/user-notifications.ts b/server/middlewares/validators/user-notifications.ts
index 21a7be08d..2f8e7686e 100644
--- a/server/middlewares/validators/user-notifications.ts
+++ b/server/middlewares/validators/user-notifications.ts
@@ -1,9 +1,9 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, query } from 'express-validator' 2import { body, query } from 'express-validator'
3import { logger } from '../../helpers/logger'
4import { areValidationErrors } from './utils'
5import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
6import { isNotEmptyIntArray, toBooleanOrNull } from '../../helpers/custom-validators/misc' 3import { isNotEmptyIntArray, toBooleanOrNull } from '../../helpers/custom-validators/misc'
4import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
5import { logger } from '../../helpers/logger'
6import { areValidationErrors } from './shared'
7 7
8const listUserNotificationsValidator = [ 8const listUserNotificationsValidator = [
9 query('unread') 9 query('unread')
diff --git a/server/middlewares/validators/user-subscriptions.ts b/server/middlewares/validators/user-subscriptions.ts
index 0d0c8ccbf..ab7962923 100644
--- a/server/middlewares/validators/user-subscriptions.ts
+++ b/server/middlewares/validators/user-subscriptions.ts
@@ -1,12 +1,12 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param, query } from 'express-validator' 2import { body, param, query } from 'express-validator'
3import { logger } from '../../helpers/logger' 3import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
4import { areValidationErrors } from './utils'
5import { ActorFollowModel } from '../../models/activitypub/actor-follow'
6import { areValidActorHandles, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor' 4import { areValidActorHandles, isValidActorHandle } from '../../helpers/custom-validators/activitypub/actor'
7import { toArray } from '../../helpers/custom-validators/misc' 5import { toArray } from '../../helpers/custom-validators/misc'
6import { logger } from '../../helpers/logger'
8import { WEBSERVER } from '../../initializers/constants' 7import { WEBSERVER } from '../../initializers/constants'
9import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' 8import { ActorFollowModel } from '../../models/actor/actor-follow'
9import { areValidationErrors } from './shared'
10 10
11const userSubscriptionListValidator = [ 11const userSubscriptionListValidator = [
12 query('search').optional().not().isEmpty().withMessage('Should have a valid search'), 12 query('search').optional().not().isEmpty().withMessage('Should have a valid search'),
@@ -61,11 +61,10 @@ const userSubscriptionGetValidator = [
61 const subscription = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI(user.Account.Actor.id, name, host) 61 const subscription = await ActorFollowModel.loadByActorAndTargetNameAndHostForAPI(user.Account.Actor.id, name, host)
62 62
63 if (!subscription || !subscription.ActorFollowing.VideoChannel) { 63 if (!subscription || !subscription.ActorFollowing.VideoChannel) {
64 return res 64 return res.fail({
65 .status(HttpStatusCode.NOT_FOUND_404) 65 status: HttpStatusCode.NOT_FOUND_404,
66 .json({ 66 message: `Subscription ${req.params.uri} not found.`
67 error: `Subscription ${req.params.uri} not found.` 67 })
68 })
69 } 68 }
70 69
71 res.locals.subscription = subscription 70 res.locals.subscription = subscription
diff --git a/server/middlewares/validators/users.ts b/server/middlewares/validators/users.ts
index 37119e279..698d7d814 100644
--- a/server/middlewares/validators/users.ts
+++ b/server/middlewares/validators/users.ts
@@ -7,7 +7,7 @@ import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-code
7import { UserRole } from '../../../shared/models/users' 7import { UserRole } from '../../../shared/models/users'
8import { UserRegister } from '../../../shared/models/users/user-register.model' 8import { UserRegister } from '../../../shared/models/users/user-register.model'
9import { isActorPreferredUsernameValid } from '../../helpers/custom-validators/activitypub/actor' 9import { isActorPreferredUsernameValid } from '../../helpers/custom-validators/activitypub/actor'
10import { isIdOrUUIDValid, toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc' 10import { toBooleanOrNull, toIntOrNull } from '../../helpers/custom-validators/misc'
11import { isThemeNameValid } from '../../helpers/custom-validators/plugins' 11import { isThemeNameValid } from '../../helpers/custom-validators/plugins'
12import { 12import {
13 isNoInstanceConfigWarningModal, 13 isNoInstanceConfigWarningModal,
@@ -30,13 +30,12 @@ import {
30} from '../../helpers/custom-validators/users' 30} from '../../helpers/custom-validators/users'
31import { isVideoChannelNameValid } from '../../helpers/custom-validators/video-channels' 31import { isVideoChannelNameValid } from '../../helpers/custom-validators/video-channels'
32import { logger } from '../../helpers/logger' 32import { logger } from '../../helpers/logger'
33import { doesVideoExist } from '../../helpers/middlewares'
34import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../helpers/signup'
35import { isThemeRegistered } from '../../lib/plugins/theme-utils' 33import { isThemeRegistered } from '../../lib/plugins/theme-utils'
36import { Redis } from '../../lib/redis' 34import { Redis } from '../../lib/redis'
37import { UserModel } from '../../models/account/user' 35import { isSignupAllowed, isSignupAllowedForCurrentIP } from '../../lib/signup'
38import { ActorModel } from '../../models/activitypub/actor' 36import { ActorModel } from '../../models/actor/actor'
39import { areValidationErrors } from './utils' 37import { UserModel } from '../../models/user/user'
38import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from './shared'
40 39
41const usersListValidator = [ 40const usersListValidator = [
42 query('blocked') 41 query('blocked')
@@ -73,23 +72,23 @@ const usersAddValidator = [
73 72
74 const authUser = res.locals.oauth.token.User 73 const authUser = res.locals.oauth.token.User
75 if (authUser.role !== UserRole.ADMINISTRATOR && req.body.role !== UserRole.USER) { 74 if (authUser.role !== UserRole.ADMINISTRATOR && req.body.role !== UserRole.USER) {
76 return res 75 return res.fail({
77 .status(HttpStatusCode.FORBIDDEN_403) 76 status: HttpStatusCode.FORBIDDEN_403,
78 .json({ error: 'You can only create users (and not administrators or moderators)' }) 77 message: 'You can only create users (and not administrators or moderators)'
78 })
79 } 79 }
80 80
81 if (req.body.channelName) { 81 if (req.body.channelName) {
82 if (req.body.channelName === req.body.username) { 82 if (req.body.channelName === req.body.username) {
83 return res 83 return res.fail({ message: 'Channel name cannot be the same as user username.' })
84 .status(HttpStatusCode.BAD_REQUEST_400)
85 .json({ error: 'Channel name cannot be the same as user username.' })
86 } 84 }
87 85
88 const existing = await ActorModel.loadLocalByName(req.body.channelName) 86 const existing = await ActorModel.loadLocalByName(req.body.channelName)
89 if (existing) { 87 if (existing) {
90 return res 88 return res.fail({
91 .status(HttpStatusCode.CONFLICT_409) 89 status: HttpStatusCode.CONFLICT_409,
92 .json({ error: `Channel with name ${req.body.channelName} already exists.` }) 90 message: `Channel with name ${req.body.channelName} already exists.`
91 })
93 } 92 }
94 } 93 }
95 94
@@ -121,20 +120,19 @@ const usersRegisterValidator = [
121 const body: UserRegister = req.body 120 const body: UserRegister = req.body
122 if (body.channel) { 121 if (body.channel) {
123 if (!body.channel.name || !body.channel.displayName) { 122 if (!body.channel.name || !body.channel.displayName) {
124 return res 123 return res.fail({ message: 'Channel is optional but if you specify it, channel.name and channel.displayName are required.' })
125 .status(HttpStatusCode.BAD_REQUEST_400)
126 .json({ error: 'Channel is optional but if you specify it, channel.name and channel.displayName are required.' })
127 } 124 }
128 125
129 if (body.channel.name === body.username) { 126 if (body.channel.name === body.username) {
130 return res.status(HttpStatusCode.BAD_REQUEST_400) 127 return res.fail({ message: 'Channel name cannot be the same as user username.' })
131 .json({ error: 'Channel name cannot be the same as user username.' })
132 } 128 }
133 129
134 const existing = await ActorModel.loadLocalByName(body.channel.name) 130 const existing = await ActorModel.loadLocalByName(body.channel.name)
135 if (existing) { 131 if (existing) {
136 return res.status(HttpStatusCode.CONFLICT_409) 132 return res.fail({
137 .json({ error: `Channel with name ${body.channel.name} already exists.` }) 133 status: HttpStatusCode.CONFLICT_409,
134 message: `Channel with name ${body.channel.name} already exists.`
135 })
138 } 136 }
139 } 137 }
140 138
@@ -153,8 +151,7 @@ const usersRemoveValidator = [
153 151
154 const user = res.locals.user 152 const user = res.locals.user
155 if (user.username === 'root') { 153 if (user.username === 'root') {
156 return res.status(HttpStatusCode.BAD_REQUEST_400) 154 return res.fail({ message: 'Cannot remove the root user' })
157 .json({ error: 'Cannot remove the root user' })
158 } 155 }
159 156
160 return next() 157 return next()
@@ -173,8 +170,7 @@ const usersBlockingValidator = [
173 170
174 const user = res.locals.user 171 const user = res.locals.user
175 if (user.username === 'root') { 172 if (user.username === 'root') {
176 return res.status(HttpStatusCode.BAD_REQUEST_400) 173 return res.fail({ message: 'Cannot block the root user' })
177 .json({ error: 'Cannot block the root user' })
178 } 174 }
179 175
180 return next() 176 return next()
@@ -185,9 +181,7 @@ const deleteMeValidator = [
185 (req: express.Request, res: express.Response, next: express.NextFunction) => { 181 (req: express.Request, res: express.Response, next: express.NextFunction) => {
186 const user = res.locals.oauth.token.User 182 const user = res.locals.oauth.token.User
187 if (user.username === 'root') { 183 if (user.username === 'root') {
188 return res.status(HttpStatusCode.BAD_REQUEST_400) 184 return res.fail({ message: 'You cannot delete your root account.' })
189 .json({ error: 'You cannot delete your root account.' })
190 .end()
191 } 185 }
192 186
193 return next() 187 return next()
@@ -217,8 +211,7 @@ const usersUpdateValidator = [
217 211
218 const user = res.locals.user 212 const user = res.locals.user
219 if (user.username === 'root' && req.body.role !== undefined && user.role !== req.body.role) { 213 if (user.username === 'root' && req.body.role !== undefined && user.role !== req.body.role) {
220 return res.status(HttpStatusCode.BAD_REQUEST_400) 214 return res.fail({ message: 'Cannot change root role.' })
221 .json({ error: 'Cannot change root role.' })
222 } 215 }
223 216
224 return next() 217 return next()
@@ -273,18 +266,18 @@ const usersUpdateMeValidator = [
273 266
274 if (req.body.password || req.body.email) { 267 if (req.body.password || req.body.email) {
275 if (user.pluginAuth !== null) { 268 if (user.pluginAuth !== null) {
276 return res.status(HttpStatusCode.BAD_REQUEST_400) 269 return res.fail({ message: 'You cannot update your email or password that is associated with an external auth system.' })
277 .json({ error: 'You cannot update your email or password that is associated with an external auth system.' })
278 } 270 }
279 271
280 if (!req.body.currentPassword) { 272 if (!req.body.currentPassword) {
281 return res.status(HttpStatusCode.BAD_REQUEST_400) 273 return res.fail({ message: 'currentPassword parameter is missing.' })
282 .json({ error: 'currentPassword parameter is missing.' })
283 } 274 }
284 275
285 if (await user.isPasswordMatch(req.body.currentPassword) !== true) { 276 if (await user.isPasswordMatch(req.body.currentPassword) !== true) {
286 return res.status(HttpStatusCode.UNAUTHORIZED_401) 277 return res.fail({
287 .json({ error: 'currentPassword is invalid.' }) 278 status: HttpStatusCode.UNAUTHORIZED_401,
279 message: 'currentPassword is invalid.'
280 })
288 } 281 }
289 } 282 }
290 283
@@ -309,7 +302,7 @@ const usersGetValidator = [
309] 302]
310 303
311const usersVideoRatingValidator = [ 304const usersVideoRatingValidator = [
312 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), 305 isValidVideoIdParam('videoId'),
313 306
314 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 307 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
315 logger.debug('Checking usersVideoRating parameters', { parameters: req.params }) 308 logger.debug('Checking usersVideoRating parameters', { parameters: req.params })
@@ -335,8 +328,10 @@ const ensureUserRegistrationAllowed = [
335 ) 328 )
336 329
337 if (allowedResult.allowed === false) { 330 if (allowedResult.allowed === false) {
338 return res.status(HttpStatusCode.FORBIDDEN_403) 331 return res.fail({
339 .json({ error: allowedResult.errorMessage || 'User registration is not enabled or user limit is reached.' }) 332 status: HttpStatusCode.FORBIDDEN_403,
333 message: allowedResult.errorMessage || 'User registration is not enabled or user limit is reached.'
334 })
340 } 335 }
341 336
342 return next() 337 return next()
@@ -348,8 +343,10 @@ const ensureUserRegistrationAllowedForIP = [
348 const allowed = isSignupAllowedForCurrentIP(req.ip) 343 const allowed = isSignupAllowedForCurrentIP(req.ip)
349 344
350 if (allowed === false) { 345 if (allowed === false) {
351 return res.status(HttpStatusCode.FORBIDDEN_403) 346 return res.fail({
352 .json({ error: 'You are not on a network authorized for registration.' }) 347 status: HttpStatusCode.FORBIDDEN_403,
348 message: 'You are not on a network authorized for registration.'
349 })
353 } 350 }
354 351
355 return next() 352 return next()
@@ -390,9 +387,10 @@ const usersResetPasswordValidator = [
390 const redisVerificationString = await Redis.Instance.getResetPasswordLink(user.id) 387 const redisVerificationString = await Redis.Instance.getResetPasswordLink(user.id)
391 388
392 if (redisVerificationString !== req.body.verificationString) { 389 if (redisVerificationString !== req.body.verificationString) {
393 return res 390 return res.fail({
394 .status(HttpStatusCode.FORBIDDEN_403) 391 status: HttpStatusCode.FORBIDDEN_403,
395 .json({ error: 'Invalid verification string.' }) 392 message: 'Invalid verification string.'
393 })
396 } 394 }
397 395
398 return next() 396 return next()
@@ -437,9 +435,10 @@ const usersVerifyEmailValidator = [
437 const redisVerificationString = await Redis.Instance.getVerifyEmailLink(user.id) 435 const redisVerificationString = await Redis.Instance.getVerifyEmailLink(user.id)
438 436
439 if (redisVerificationString !== req.body.verificationString) { 437 if (redisVerificationString !== req.body.verificationString) {
440 return res 438 return res.fail({
441 .status(HttpStatusCode.FORBIDDEN_403) 439 status: HttpStatusCode.FORBIDDEN_403,
442 .json({ error: 'Invalid verification string.' }) 440 message: 'Invalid verification string.'
441 })
443 } 442 }
444 443
445 return next() 444 return next()
@@ -455,8 +454,10 @@ const ensureAuthUserOwnsAccountValidator = [
455 const user = res.locals.oauth.token.User 454 const user = res.locals.oauth.token.User
456 455
457 if (res.locals.account.id !== user.Account.id) { 456 if (res.locals.account.id !== user.Account.id) {
458 return res.status(HttpStatusCode.FORBIDDEN_403) 457 return res.fail({
459 .json({ error: 'Only owner can access ratings list.' }) 458 status: HttpStatusCode.FORBIDDEN_403,
459 message: 'Only owner can access ratings list.'
460 })
460 } 461 }
461 462
462 return next() 463 return next()
@@ -471,8 +472,10 @@ const ensureCanManageUser = [
471 if (authUser.role === UserRole.ADMINISTRATOR) return next() 472 if (authUser.role === UserRole.ADMINISTRATOR) return next()
472 if (authUser.role === UserRole.MODERATOR && onUser.role === UserRole.USER) return next() 473 if (authUser.role === UserRole.MODERATOR && onUser.role === UserRole.USER) return next()
473 474
474 return res.status(HttpStatusCode.FORBIDDEN_403) 475 return res.fail({
475 .json({ error: 'A moderator can only manager users.' }) 476 status: HttpStatusCode.FORBIDDEN_403,
477 message: 'A moderator can only manager users.'
478 })
476 } 479 }
477] 480]
478 481
@@ -515,15 +518,19 @@ async function checkUserNameOrEmailDoesNotAlreadyExist (username: string, email:
515 const user = await UserModel.loadByUsernameOrEmail(username, email) 518 const user = await UserModel.loadByUsernameOrEmail(username, email)
516 519
517 if (user) { 520 if (user) {
518 res.status(HttpStatusCode.CONFLICT_409) 521 res.fail({
519 .json({ error: 'User with this username or email already exists.' }) 522 status: HttpStatusCode.CONFLICT_409,
523 message: 'User with this username or email already exists.'
524 })
520 return false 525 return false
521 } 526 }
522 527
523 const actor = await ActorModel.loadLocalByName(username) 528 const actor = await ActorModel.loadLocalByName(username)
524 if (actor) { 529 if (actor) {
525 res.status(HttpStatusCode.CONFLICT_409) 530 res.fail({
526 .json({ error: 'Another actor (account/channel) with this name on this instance already exists or has already existed.' }) 531 status: HttpStatusCode.CONFLICT_409,
532 message: 'Another actor (account/channel) with this name on this instance already exists or has already existed.'
533 })
527 return false 534 return false
528 } 535 }
529 536
@@ -535,14 +542,15 @@ async function checkUserExist (finder: () => Promise<MUserDefault>, res: express
535 542
536 if (!user) { 543 if (!user) {
537 if (abortResponse === true) { 544 if (abortResponse === true) {
538 res.status(HttpStatusCode.NOT_FOUND_404) 545 res.fail({
539 .json({ error: 'User not found' }) 546 status: HttpStatusCode.NOT_FOUND_404,
547 message: 'User not found'
548 })
540 } 549 }
541 550
542 return false 551 return false
543 } 552 }
544 553
545 res.locals.user = user 554 res.locals.user = user
546
547 return true 555 return true
548} 556}
diff --git a/server/middlewares/validators/videos/index.ts b/server/middlewares/validators/videos/index.ts
index 1eabada0a..369c2c9b6 100644
--- a/server/middlewares/validators/videos/index.ts
+++ b/server/middlewares/validators/videos/index.ts
@@ -3,6 +3,8 @@ export * from './video-captions'
3export * from './video-channels' 3export * from './video-channels'
4export * from './video-comments' 4export * from './video-comments'
5export * from './video-imports' 5export * from './video-imports'
6export * from './video-live'
7export * from './video-ownership-changes'
6export * from './video-watch' 8export * from './video-watch'
7export * from './video-rates' 9export * from './video-rates'
8export * from './video-shares' 10export * from './video-shares'
diff --git a/server/middlewares/validators/videos/video-blacklist.ts b/server/middlewares/validators/videos/video-blacklist.ts
index 88c788a43..21141d84d 100644
--- a/server/middlewares/validators/videos/video-blacklist.ts
+++ b/server/middlewares/validators/videos/video-blacklist.ts
@@ -1,14 +1,13 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param, query } from 'express-validator' 2import { body, query } from 'express-validator'
3import { isBooleanValid, isIdOrUUIDValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc' 3import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
4import { isBooleanValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc'
4import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../../helpers/custom-validators/video-blacklist' 5import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../../helpers/custom-validators/video-blacklist'
5import { logger } from '../../../helpers/logger' 6import { logger } from '../../../helpers/logger'
6import { doesVideoBlacklistExist, doesVideoExist } from '../../../helpers/middlewares' 7import { areValidationErrors, doesVideoBlacklistExist, doesVideoExist, isValidVideoIdParam } from '../shared'
7import { areValidationErrors } from '../utils'
8import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
9 8
10const videosBlacklistRemoveValidator = [ 9const videosBlacklistRemoveValidator = [
11 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 10 isValidVideoIdParam('videoId'),
12 11
13 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 12 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
14 logger.debug('Checking blacklistRemove parameters.', { parameters: req.params }) 13 logger.debug('Checking blacklistRemove parameters.', { parameters: req.params })
@@ -22,7 +21,8 @@ const videosBlacklistRemoveValidator = [
22] 21]
23 22
24const videosBlacklistAddValidator = [ 23const videosBlacklistAddValidator = [
25 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 24 isValidVideoIdParam('videoId'),
25
26 body('unfederate') 26 body('unfederate')
27 .optional() 27 .optional()
28 .customSanitizer(toBooleanOrNull) 28 .customSanitizer(toBooleanOrNull)
@@ -39,10 +39,10 @@ const videosBlacklistAddValidator = [
39 39
40 const video = res.locals.videoAll 40 const video = res.locals.videoAll
41 if (req.body.unfederate === true && video.remote === true) { 41 if (req.body.unfederate === true && video.remote === true) {
42 return res 42 return res.fail({
43 .status(HttpStatusCode.CONFLICT_409) 43 status: HttpStatusCode.CONFLICT_409,
44 .send({ error: 'You cannot unfederate a remote video.' }) 44 message: 'You cannot unfederate a remote video.'
45 .end() 45 })
46 } 46 }
47 47
48 return next() 48 return next()
@@ -50,7 +50,8 @@ const videosBlacklistAddValidator = [
50] 50]
51 51
52const videosBlacklistUpdateValidator = [ 52const videosBlacklistUpdateValidator = [
53 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 53 isValidVideoIdParam('videoId'),
54
54 body('reason') 55 body('reason')
55 .optional() 56 .optional()
56 .custom(isVideoBlacklistReasonValid).withMessage('Should have a valid reason'), 57 .custom(isVideoBlacklistReasonValid).withMessage('Should have a valid reason'),
diff --git a/server/middlewares/validators/videos/video-captions.ts b/server/middlewares/validators/videos/video-captions.ts
index 872d9c2ab..2946f3e15 100644
--- a/server/middlewares/validators/videos/video-captions.ts
+++ b/server/middlewares/validators/videos/video-captions.ts
@@ -1,17 +1,18 @@
1import * as express from 'express' 1import * as express from 'express'
2import { areValidationErrors } from '../utils'
3import { isIdOrUUIDValid } from '../../../helpers/custom-validators/misc'
4import { body, param } from 'express-validator' 2import { body, param } from 'express-validator'
5import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../../initializers/constants'
6import { UserRight } from '../../../../shared' 3import { UserRight } from '../../../../shared'
7import { logger } from '../../../helpers/logger'
8import { isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions' 4import { isVideoCaptionFile, isVideoCaptionLanguageValid } from '../../../helpers/custom-validators/video-captions'
9import { cleanUpReqFiles } from '../../../helpers/express-utils' 5import { cleanUpReqFiles } from '../../../helpers/express-utils'
10import { checkUserCanManageVideo, doesVideoCaptionExist, doesVideoExist } from '../../../helpers/middlewares' 6import { logger } from '../../../helpers/logger'
7import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../../initializers/constants'
8import { areValidationErrors, checkUserCanManageVideo, doesVideoCaptionExist, doesVideoExist, isValidVideoIdParam } from '../shared'
11 9
12const addVideoCaptionValidator = [ 10const addVideoCaptionValidator = [
13 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), 11 isValidVideoIdParam('videoId'),
14 param('captionLanguage').custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'), 12
13 param('captionLanguage')
14 .custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'),
15
15 body('captionfile') 16 body('captionfile')
16 .custom((_, { req }) => isVideoCaptionFile(req.files, 'captionfile')) 17 .custom((_, { req }) => isVideoCaptionFile(req.files, 'captionfile'))
17 .withMessage( 18 .withMessage(
@@ -35,8 +36,10 @@ const addVideoCaptionValidator = [
35] 36]
36 37
37const deleteVideoCaptionValidator = [ 38const deleteVideoCaptionValidator = [
38 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), 39 isValidVideoIdParam('videoId'),
39 param('captionLanguage').custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'), 40
41 param('captionLanguage')
42 .custom(isVideoCaptionLanguageValid).not().isEmpty().withMessage('Should have a valid caption language'),
40 43
41 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 44 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
42 logger.debug('Checking deleteVideoCaption parameters', { parameters: req.params }) 45 logger.debug('Checking deleteVideoCaption parameters', { parameters: req.params })
@@ -54,7 +57,7 @@ const deleteVideoCaptionValidator = [
54] 57]
55 58
56const listVideoCaptionsValidator = [ 59const listVideoCaptionsValidator = [
57 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid video id'), 60 isValidVideoIdParam('videoId'),
58 61
59 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 62 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
60 logger.debug('Checking listVideoCaptions parameters', { parameters: req.params }) 63 logger.debug('Checking listVideoCaptions parameters', { parameters: req.params })
diff --git a/server/middlewares/validators/videos/video-channels.ts b/server/middlewares/validators/videos/video-channels.ts
index 2463d281c..e7df185e4 100644
--- a/server/middlewares/validators/videos/video-channels.ts
+++ b/server/middlewares/validators/videos/video-channels.ts
@@ -3,6 +3,7 @@ import { body, param, query } from 'express-validator'
3import { VIDEO_CHANNELS } from '@server/initializers/constants' 3import { VIDEO_CHANNELS } from '@server/initializers/constants'
4import { MChannelAccountDefault, MUser } from '@server/types/models' 4import { MChannelAccountDefault, MUser } from '@server/types/models'
5import { UserRight } from '../../../../shared' 5import { UserRight } from '../../../../shared'
6import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
6import { isActorPreferredUsernameValid } from '../../../helpers/custom-validators/activitypub/actor' 7import { isActorPreferredUsernameValid } from '../../../helpers/custom-validators/activitypub/actor'
7import { isBooleanValid, toBooleanOrNull } from '../../../helpers/custom-validators/misc' 8import { isBooleanValid, toBooleanOrNull } from '../../../helpers/custom-validators/misc'
8import { 9import {
@@ -11,11 +12,9 @@ import {
11 isVideoChannelSupportValid 12 isVideoChannelSupportValid
12} from '../../../helpers/custom-validators/video-channels' 13} from '../../../helpers/custom-validators/video-channels'
13import { logger } from '../../../helpers/logger' 14import { logger } from '../../../helpers/logger'
14import { doesLocalVideoChannelNameExist, doesVideoChannelNameWithHostExist } from '../../../helpers/middlewares' 15import { ActorModel } from '../../../models/actor/actor'
15import { ActorModel } from '../../../models/activitypub/actor'
16import { VideoChannelModel } from '../../../models/video/video-channel' 16import { VideoChannelModel } from '../../../models/video/video-channel'
17import { areValidationErrors } from '../utils' 17import { areValidationErrors, doesLocalVideoChannelNameExist, doesVideoChannelNameWithHostExist } from '../shared'
18import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
19 18
20const videoChannelsAddValidator = [ 19const videoChannelsAddValidator = [
21 body('name').custom(isActorPreferredUsernameValid).withMessage('Should have a valid channel name'), 20 body('name').custom(isActorPreferredUsernameValid).withMessage('Should have a valid channel name'),
@@ -30,17 +29,16 @@ const videoChannelsAddValidator = [
30 29
31 const actor = await ActorModel.loadLocalByName(req.body.name) 30 const actor = await ActorModel.loadLocalByName(req.body.name)
32 if (actor) { 31 if (actor) {
33 res.status(HttpStatusCode.CONFLICT_409) 32 res.fail({
34 .send({ error: 'Another actor (account/channel) with this name on this instance already exists or has already existed.' }) 33 status: HttpStatusCode.CONFLICT_409,
35 .end() 34 message: 'Another actor (account/channel) with this name on this instance already exists or has already existed.'
35 })
36 return false 36 return false
37 } 37 }
38 38
39 const count = await VideoChannelModel.countByAccount(res.locals.oauth.token.User.Account.id) 39 const count = await VideoChannelModel.countByAccount(res.locals.oauth.token.User.Account.id)
40 if (count >= VIDEO_CHANNELS.MAX_PER_USER) { 40 if (count >= VIDEO_CHANNELS.MAX_PER_USER) {
41 res.status(HttpStatusCode.BAD_REQUEST_400) 41 res.fail({ message: `You cannot create more than ${VIDEO_CHANNELS.MAX_PER_USER} channels` })
42 .send({ error: `You cannot create more than ${VIDEO_CHANNELS.MAX_PER_USER} channels` })
43 .end()
44 return false 42 return false
45 } 43 }
46 44
@@ -71,13 +69,17 @@ const videoChannelsUpdateValidator = [
71 69
72 // We need to make additional checks 70 // We need to make additional checks
73 if (res.locals.videoChannel.Actor.isOwned() === false) { 71 if (res.locals.videoChannel.Actor.isOwned() === false) {
74 return res.status(HttpStatusCode.FORBIDDEN_403) 72 return res.fail({
75 .json({ error: 'Cannot update video channel of another server' }) 73 status: HttpStatusCode.FORBIDDEN_403,
74 message: 'Cannot update video channel of another server'
75 })
76 } 76 }
77 77
78 if (res.locals.videoChannel.Account.userId !== res.locals.oauth.token.User.id) { 78 if (res.locals.videoChannel.Account.userId !== res.locals.oauth.token.User.id) {
79 return res.status(HttpStatusCode.FORBIDDEN_403) 79 return res.fail({
80 .json({ error: 'Cannot update video channel of another user' }) 80 status: HttpStatusCode.FORBIDDEN_403,
81 message: 'Cannot update video channel of another user'
82 })
81 } 83 }
82 84
83 return next() 85 return next()
@@ -139,6 +141,18 @@ const videoChannelStatsValidator = [
139 } 141 }
140] 142]
141 143
144const videoChannelsListValidator = [
145 query('search').optional().not().isEmpty().withMessage('Should have a valid search'),
146
147 (req: express.Request, res: express.Response, next: express.NextFunction) => {
148 logger.debug('Checking video channels search query', { parameters: req.query })
149
150 if (areValidationErrors(req, res)) return
151
152 return next()
153 }
154]
155
142// --------------------------------------------------------------------------- 156// ---------------------------------------------------------------------------
143 157
144export { 158export {
@@ -146,6 +160,7 @@ export {
146 videoChannelsUpdateValidator, 160 videoChannelsUpdateValidator,
147 videoChannelsRemoveValidator, 161 videoChannelsRemoveValidator,
148 videoChannelsNameWithHostValidator, 162 videoChannelsNameWithHostValidator,
163 videoChannelsListValidator,
149 localVideoChannelValidator, 164 localVideoChannelValidator,
150 videoChannelStatsValidator 165 videoChannelStatsValidator
151} 166}
@@ -154,10 +169,10 @@ export {
154 169
155function checkUserCanDeleteVideoChannel (user: MUser, videoChannel: MChannelAccountDefault, res: express.Response) { 170function checkUserCanDeleteVideoChannel (user: MUser, videoChannel: MChannelAccountDefault, res: express.Response) {
156 if (videoChannel.Actor.isOwned() === false) { 171 if (videoChannel.Actor.isOwned() === false) {
157 res.status(HttpStatusCode.FORBIDDEN_403) 172 res.fail({
158 .json({ error: 'Cannot remove video channel of another server.' }) 173 status: HttpStatusCode.FORBIDDEN_403,
159 .end() 174 message: 'Cannot remove video channel of another server.'
160 175 })
161 return false 176 return false
162 } 177 }
163 178
@@ -165,10 +180,10 @@ function checkUserCanDeleteVideoChannel (user: MUser, videoChannel: MChannelAcco
165 // The user can delete it if s/he is an admin 180 // The user can delete it if s/he is an admin
166 // Or if s/he is the video channel's account 181 // Or if s/he is the video channel's account
167 if (user.hasRight(UserRight.REMOVE_ANY_VIDEO_CHANNEL) === false && videoChannel.Account.userId !== user.id) { 182 if (user.hasRight(UserRight.REMOVE_ANY_VIDEO_CHANNEL) === false && videoChannel.Account.userId !== user.id) {
168 res.status(HttpStatusCode.FORBIDDEN_403) 183 res.fail({
169 .json({ error: 'Cannot remove video channel of another user' }) 184 status: HttpStatusCode.FORBIDDEN_403,
170 .end() 185 message: 'Cannot remove video channel of another user'
171 186 })
172 return false 187 return false
173 } 188 }
174 189
@@ -179,10 +194,10 @@ async function checkVideoChannelIsNotTheLastOne (res: express.Response) {
179 const count = await VideoChannelModel.countByAccount(res.locals.oauth.token.User.Account.id) 194 const count = await VideoChannelModel.countByAccount(res.locals.oauth.token.User.Account.id)
180 195
181 if (count <= 1) { 196 if (count <= 1) {
182 res.status(HttpStatusCode.CONFLICT_409) 197 res.fail({
183 .json({ error: 'Cannot remove the last channel of this user' }) 198 status: HttpStatusCode.CONFLICT_409,
184 .end() 199 message: 'Cannot remove the last channel of this user'
185 200 })
186 return false 201 return false
187 } 202 }
188 203
diff --git a/server/middlewares/validators/videos/video-comments.ts b/server/middlewares/validators/videos/video-comments.ts
index 1afacfed8..885506ebe 100644
--- a/server/middlewares/validators/videos/video-comments.ts
+++ b/server/middlewares/validators/videos/video-comments.ts
@@ -2,19 +2,14 @@ import * as express from 'express'
2import { body, param, query } from 'express-validator' 2import { body, param, query } from 'express-validator'
3import { MUserAccountUrl } from '@server/types/models' 3import { MUserAccountUrl } from '@server/types/models'
4import { UserRight } from '../../../../shared' 4import { UserRight } from '../../../../shared'
5import { exists, isBooleanValid, isIdOrUUIDValid, isIdValid, toBooleanOrNull } from '../../../helpers/custom-validators/misc' 5import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
6import { 6import { exists, isBooleanValid, isIdValid, toBooleanOrNull } from '../../../helpers/custom-validators/misc'
7 doesVideoCommentExist, 7import { isValidVideoCommentText } from '../../../helpers/custom-validators/video-comments'
8 doesVideoCommentThreadExist,
9 isValidVideoCommentText
10} from '../../../helpers/custom-validators/video-comments'
11import { logger } from '../../../helpers/logger' 8import { logger } from '../../../helpers/logger'
12import { doesVideoExist } from '../../../helpers/middlewares'
13import { AcceptResult, isLocalVideoCommentReplyAccepted, isLocalVideoThreadAccepted } from '../../../lib/moderation' 9import { AcceptResult, isLocalVideoCommentReplyAccepted, isLocalVideoThreadAccepted } from '../../../lib/moderation'
14import { Hooks } from '../../../lib/plugins/hooks' 10import { Hooks } from '../../../lib/plugins/hooks'
15import { MCommentOwnerVideoReply, MVideo, MVideoFullLight } from '../../../types/models/video' 11import { MCommentOwnerVideoReply, MVideo, MVideoFullLight } from '../../../types/models/video'
16import { areValidationErrors } from '../utils' 12import { areValidationErrors, doesVideoCommentExist, doesVideoCommentThreadExist, doesVideoExist, isValidVideoIdParam } from '../shared'
17import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
18 13
19const listVideoCommentsValidator = [ 14const listVideoCommentsValidator = [
20 query('isLocal') 15 query('isLocal')
@@ -45,7 +40,7 @@ const listVideoCommentsValidator = [
45] 40]
46 41
47const listVideoCommentThreadsValidator = [ 42const listVideoCommentThreadsValidator = [
48 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 43 isValidVideoIdParam('videoId'),
49 44
50 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 45 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
51 logger.debug('Checking listVideoCommentThreads parameters.', { parameters: req.params }) 46 logger.debug('Checking listVideoCommentThreads parameters.', { parameters: req.params })
@@ -58,8 +53,10 @@ const listVideoCommentThreadsValidator = [
58] 53]
59 54
60const listVideoThreadCommentsValidator = [ 55const listVideoThreadCommentsValidator = [
61 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 56 isValidVideoIdParam('videoId'),
62 param('threadId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid threadId'), 57
58 param('threadId')
59 .custom(isIdValid).not().isEmpty().withMessage('Should have a valid threadId'),
63 60
64 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 61 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
65 logger.debug('Checking listVideoThreadComments parameters.', { parameters: req.params }) 62 logger.debug('Checking listVideoThreadComments parameters.', { parameters: req.params })
@@ -73,8 +70,10 @@ const listVideoThreadCommentsValidator = [
73] 70]
74 71
75const addVideoCommentThreadValidator = [ 72const addVideoCommentThreadValidator = [
76 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 73 isValidVideoIdParam('videoId'),
77 body('text').custom(isValidVideoCommentText).not().isEmpty().withMessage('Should have a valid comment text'), 74
75 body('text')
76 .custom(isValidVideoCommentText).not().isEmpty().withMessage('Should have a valid comment text'),
78 77
79 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 78 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
80 logger.debug('Checking addVideoCommentThread parameters.', { parameters: req.params, body: req.body }) 79 logger.debug('Checking addVideoCommentThread parameters.', { parameters: req.params, body: req.body })
@@ -89,8 +88,10 @@ const addVideoCommentThreadValidator = [
89] 88]
90 89
91const addVideoCommentReplyValidator = [ 90const addVideoCommentReplyValidator = [
92 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 91 isValidVideoIdParam('videoId'),
92
93 param('commentId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid commentId'), 93 param('commentId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid commentId'),
94
94 body('text').custom(isValidVideoCommentText).not().isEmpty().withMessage('Should have a valid comment text'), 95 body('text').custom(isValidVideoCommentText).not().isEmpty().withMessage('Should have a valid comment text'),
95 96
96 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 97 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
@@ -107,8 +108,10 @@ const addVideoCommentReplyValidator = [
107] 108]
108 109
109const videoCommentGetValidator = [ 110const videoCommentGetValidator = [
110 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 111 isValidVideoIdParam('videoId'),
111 param('commentId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid commentId'), 112
113 param('commentId')
114 .custom(isIdValid).not().isEmpty().withMessage('Should have a valid commentId'),
112 115
113 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 116 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
114 logger.debug('Checking videoCommentGetValidator parameters.', { parameters: req.params }) 117 logger.debug('Checking videoCommentGetValidator parameters.', { parameters: req.params })
@@ -122,7 +125,8 @@ const videoCommentGetValidator = [
122] 125]
123 126
124const removeVideoCommentValidator = [ 127const removeVideoCommentValidator = [
125 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 128 isValidVideoIdParam('videoId'),
129
126 param('commentId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid commentId'), 130 param('commentId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid commentId'),
127 131
128 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 132 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
@@ -155,9 +159,10 @@ export {
155 159
156function isVideoCommentsEnabled (video: MVideo, res: express.Response) { 160function isVideoCommentsEnabled (video: MVideo, res: express.Response) {
157 if (video.commentsEnabled !== true) { 161 if (video.commentsEnabled !== true) {
158 res.status(HttpStatusCode.CONFLICT_409) 162 res.fail({
159 .json({ error: 'Video comments are disabled for this video.' }) 163 status: HttpStatusCode.CONFLICT_409,
160 164 message: 'Video comments are disabled for this video.'
165 })
161 return false 166 return false
162 } 167 }
163 168
@@ -166,9 +171,10 @@ function isVideoCommentsEnabled (video: MVideo, res: express.Response) {
166 171
167function checkUserCanDeleteVideoComment (user: MUserAccountUrl, videoComment: MCommentOwnerVideoReply, res: express.Response) { 172function checkUserCanDeleteVideoComment (user: MUserAccountUrl, videoComment: MCommentOwnerVideoReply, res: express.Response) {
168 if (videoComment.isDeleted()) { 173 if (videoComment.isDeleted()) {
169 res.status(HttpStatusCode.CONFLICT_409) 174 res.fail({
170 .json({ error: 'This comment is already deleted' }) 175 status: HttpStatusCode.CONFLICT_409,
171 176 message: 'This comment is already deleted'
177 })
172 return false 178 return false
173 } 179 }
174 180
@@ -179,9 +185,10 @@ function checkUserCanDeleteVideoComment (user: MUserAccountUrl, videoComment: MC
179 videoComment.accountId !== userAccount.id && // Not the comment owner 185 videoComment.accountId !== userAccount.id && // Not the comment owner
180 videoComment.Video.VideoChannel.accountId !== userAccount.id // Not the video owner 186 videoComment.Video.VideoChannel.accountId !== userAccount.id // Not the video owner
181 ) { 187 ) {
182 res.status(HttpStatusCode.FORBIDDEN_403) 188 res.fail({
183 .json({ error: 'Cannot remove video comment of another user' }) 189 status: HttpStatusCode.FORBIDDEN_403,
184 190 message: 'Cannot remove video comment of another user'
191 })
185 return false 192 return false
186 } 193 }
187 194
@@ -215,9 +222,11 @@ async function isVideoCommentAccepted (req: express.Request, res: express.Respon
215 222
216 if (!acceptedResult || acceptedResult.accepted !== true) { 223 if (!acceptedResult || acceptedResult.accepted !== true) {
217 logger.info('Refused local comment.', { acceptedResult, acceptParameters }) 224 logger.info('Refused local comment.', { acceptedResult, acceptParameters })
218 res.status(HttpStatusCode.FORBIDDEN_403)
219 .json({ error: acceptedResult?.errorMessage || 'Refused local comment' })
220 225
226 res.fail({
227 status: HttpStatusCode.FORBIDDEN_403,
228 message: acceptedResult?.errorMessage || 'Refused local comment'
229 })
221 return false 230 return false
222 } 231 }
223 232
diff --git a/server/middlewares/validators/videos/video-imports.ts b/server/middlewares/validators/videos/video-imports.ts
index c53af3861..85dc647ce 100644
--- a/server/middlewares/validators/videos/video-imports.ts
+++ b/server/middlewares/validators/videos/video-imports.ts
@@ -2,18 +2,17 @@ import * as express from 'express'
2import { body } from 'express-validator' 2import { body } from 'express-validator'
3import { isPreImportVideoAccepted } from '@server/lib/moderation' 3import { isPreImportVideoAccepted } from '@server/lib/moderation'
4import { Hooks } from '@server/lib/plugins/hooks' 4import { Hooks } from '@server/lib/plugins/hooks'
5import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
5import { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model' 6import { VideoImportCreate } from '@shared/models/videos/import/video-import-create.model'
6import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc' 7import { isIdValid, toIntOrNull } from '../../../helpers/custom-validators/misc'
7import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports' 8import { isVideoImportTargetUrlValid, isVideoImportTorrentFile } from '../../../helpers/custom-validators/video-imports'
8import { isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos' 9import { isVideoMagnetUriValid, isVideoNameValid } from '../../../helpers/custom-validators/videos'
9import { cleanUpReqFiles } from '../../../helpers/express-utils' 10import { cleanUpReqFiles } from '../../../helpers/express-utils'
10import { logger } from '../../../helpers/logger' 11import { logger } from '../../../helpers/logger'
11import { doesVideoChannelOfAccountExist } from '../../../helpers/middlewares'
12import { CONFIG } from '../../../initializers/config' 12import { CONFIG } from '../../../initializers/config'
13import { CONSTRAINTS_FIELDS } from '../../../initializers/constants' 13import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
14import { areValidationErrors } from '../utils' 14import { areValidationErrors, doesVideoChannelOfAccountExist } from '../shared'
15import { getCommonVideoEditAttributes } from './videos' 15import { getCommonVideoEditAttributes } from './videos'
16import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
17 16
18const videoImportAddValidator = getCommonVideoEditAttributes().concat([ 17const videoImportAddValidator = getCommonVideoEditAttributes().concat([
19 body('channelId') 18 body('channelId')
@@ -33,7 +32,9 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
33 ), 32 ),
34 body('name') 33 body('name')
35 .optional() 34 .optional()
36 .custom(isVideoNameValid).withMessage('Should have a valid name'), 35 .custom(isVideoNameValid).withMessage(
36 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
37 ),
37 38
38 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 39 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
39 logger.debug('Checking videoImportAddValidator parameters', { parameters: req.body }) 40 logger.debug('Checking videoImportAddValidator parameters', { parameters: req.body })
@@ -45,16 +46,20 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
45 46
46 if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) { 47 if (CONFIG.IMPORT.VIDEOS.HTTP.ENABLED !== true && req.body.targetUrl) {
47 cleanUpReqFiles(req) 48 cleanUpReqFiles(req)
48 return res.status(HttpStatusCode.CONFLICT_409) 49
49 .json({ error: 'HTTP import is not enabled on this instance.' }) 50 return res.fail({
50 .end() 51 status: HttpStatusCode.CONFLICT_409,
52 message: 'HTTP import is not enabled on this instance.'
53 })
51 } 54 }
52 55
53 if (CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED !== true && (req.body.magnetUri || torrentFile)) { 56 if (CONFIG.IMPORT.VIDEOS.TORRENT.ENABLED !== true && (req.body.magnetUri || torrentFile)) {
54 cleanUpReqFiles(req) 57 cleanUpReqFiles(req)
55 return res.status(HttpStatusCode.CONFLICT_409) 58
56 .json({ error: 'Torrent/magnet URI import is not enabled on this instance.' }) 59 return res.fail({
57 .end() 60 status: HttpStatusCode.CONFLICT_409,
61 message: 'Torrent/magnet URI import is not enabled on this instance.'
62 })
58 } 63 }
59 64
60 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req) 65 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return cleanUpReqFiles(req)
@@ -63,9 +68,7 @@ const videoImportAddValidator = getCommonVideoEditAttributes().concat([
63 if (!req.body.targetUrl && !req.body.magnetUri && !torrentFile) { 68 if (!req.body.targetUrl && !req.body.magnetUri && !torrentFile) {
64 cleanUpReqFiles(req) 69 cleanUpReqFiles(req)
65 70
66 return res.status(HttpStatusCode.BAD_REQUEST_400) 71 return res.fail({ message: 'Should have a magnetUri or a targetUrl or a torrent file.' })
67 .json({ error: 'Should have a magnetUri or a targetUrl or a torrent file.' })
68 .end()
69 } 72 }
70 73
71 if (!await isImportAccepted(req, res)) return cleanUpReqFiles(req) 74 if (!await isImportAccepted(req, res)) return cleanUpReqFiles(req)
@@ -101,9 +104,11 @@ async function isImportAccepted (req: express.Request, res: express.Response) {
101 104
102 if (!acceptedResult || acceptedResult.accepted !== true) { 105 if (!acceptedResult || acceptedResult.accepted !== true) {
103 logger.info('Refused to import video.', { acceptedResult, acceptParameters }) 106 logger.info('Refused to import video.', { acceptedResult, acceptParameters })
104 res.status(HttpStatusCode.FORBIDDEN_403)
105 .json({ error: acceptedResult.errorMessage || 'Refused to import video' })
106 107
108 res.fail({
109 status: HttpStatusCode.FORBIDDEN_403,
110 message: acceptedResult.errorMessage || 'Refused to import video'
111 })
107 return false 112 return false
108 } 113 }
109 114
diff --git a/server/middlewares/validators/videos/video-live.ts b/server/middlewares/validators/videos/video-live.ts
index 3a73e1272..7cfb935e3 100644
--- a/server/middlewares/validators/videos/video-live.ts
+++ b/server/middlewares/validators/videos/video-live.ts
@@ -1,22 +1,28 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param } from 'express-validator' 2import { body } from 'express-validator'
3import { checkUserCanManageVideo, doesVideoChannelOfAccountExist, doesVideoExist } from '@server/helpers/middlewares/videos' 3import { CONSTRAINTS_FIELDS } from '@server/initializers/constants'
4import { isLocalLiveVideoAccepted } from '@server/lib/moderation'
5import { Hooks } from '@server/lib/plugins/hooks'
6import { VideoModel } from '@server/models/video/video'
4import { VideoLiveModel } from '@server/models/video/video-live' 7import { VideoLiveModel } from '@server/models/video/video-live'
8import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
5import { ServerErrorCode, UserRight, VideoState } from '@shared/models' 9import { ServerErrorCode, UserRight, VideoState } from '@shared/models'
6import { isBooleanValid, isIdOrUUIDValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc' 10import { isBooleanValid, isIdValid, toBooleanOrNull, toIntOrNull } from '../../../helpers/custom-validators/misc'
7import { isVideoNameValid } from '../../../helpers/custom-validators/videos' 11import { isVideoNameValid } from '../../../helpers/custom-validators/videos'
8import { cleanUpReqFiles } from '../../../helpers/express-utils' 12import { cleanUpReqFiles } from '../../../helpers/express-utils'
9import { logger } from '../../../helpers/logger' 13import { logger } from '../../../helpers/logger'
10import { CONFIG } from '../../../initializers/config' 14import { CONFIG } from '../../../initializers/config'
11import { areValidationErrors } from '../utils' 15import {
16 areValidationErrors,
17 checkUserCanManageVideo,
18 doesVideoChannelOfAccountExist,
19 doesVideoExist,
20 isValidVideoIdParam
21} from '../shared'
12import { getCommonVideoEditAttributes } from './videos' 22import { getCommonVideoEditAttributes } from './videos'
13import { VideoModel } from '@server/models/video/video'
14import { Hooks } from '@server/lib/plugins/hooks'
15import { isLocalLiveVideoAccepted } from '@server/lib/moderation'
16import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
17 23
18const videoLiveGetValidator = [ 24const videoLiveGetValidator = [
19 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid videoId'), 25 isValidVideoIdParam('videoId'),
20 26
21 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 27 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
22 logger.debug('Checking videoLiveGetValidator parameters', { parameters: req.params, user: res.locals.oauth.token.User.username }) 28 logger.debug('Checking videoLiveGetValidator parameters', { parameters: req.params, user: res.locals.oauth.token.User.username })
@@ -29,7 +35,12 @@ const videoLiveGetValidator = [
29 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.GET_ANY_LIVE, res, false)) return 35 if (!checkUserCanManageVideo(user, res.locals.videoAll, UserRight.GET_ANY_LIVE, res, false)) return
30 36
31 const videoLive = await VideoLiveModel.loadByVideoId(res.locals.videoAll.id) 37 const videoLive = await VideoLiveModel.loadByVideoId(res.locals.videoAll.id)
32 if (!videoLive) return res.sendStatus(HttpStatusCode.NOT_FOUND_404) 38 if (!videoLive) {
39 return res.fail({
40 status: HttpStatusCode.NOT_FOUND_404,
41 message: 'Live video not found'
42 })
43 }
33 44
34 res.locals.videoLive = videoLive 45 res.locals.videoLive = videoLive
35 46
@@ -43,7 +54,9 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
43 .custom(isIdValid).withMessage('Should have correct video channel id'), 54 .custom(isIdValid).withMessage('Should have correct video channel id'),
44 55
45 body('name') 56 body('name')
46 .custom(isVideoNameValid).withMessage('Should have a valid name'), 57 .custom(isVideoNameValid).withMessage(
58 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
59 ),
47 60
48 body('saveReplay') 61 body('saveReplay')
49 .optional() 62 .optional()
@@ -63,22 +76,27 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
63 if (CONFIG.LIVE.ENABLED !== true) { 76 if (CONFIG.LIVE.ENABLED !== true) {
64 cleanUpReqFiles(req) 77 cleanUpReqFiles(req)
65 78
66 return res.status(HttpStatusCode.FORBIDDEN_403) 79 return res.fail({
67 .json({ error: 'Live is not enabled on this instance' }) 80 status: HttpStatusCode.FORBIDDEN_403,
81 message: 'Live is not enabled on this instance',
82 type: ServerErrorCode.LIVE_NOT_ENABLED
83 })
68 } 84 }
69 85
70 if (CONFIG.LIVE.ALLOW_REPLAY !== true && req.body.saveReplay === true) { 86 if (CONFIG.LIVE.ALLOW_REPLAY !== true && req.body.saveReplay === true) {
71 cleanUpReqFiles(req) 87 cleanUpReqFiles(req)
72 88
73 return res.status(HttpStatusCode.FORBIDDEN_403) 89 return res.fail({
74 .json({ error: 'Saving live replay is not allowed instance' }) 90 status: HttpStatusCode.FORBIDDEN_403,
91 message: 'Saving live replay is not enabled on this instance',
92 type: ServerErrorCode.LIVE_NOT_ALLOWING_REPLAY
93 })
75 } 94 }
76 95
77 if (req.body.permanentLive && req.body.saveReplay) { 96 if (req.body.permanentLive && req.body.saveReplay) {
78 cleanUpReqFiles(req) 97 cleanUpReqFiles(req)
79 98
80 return res.status(HttpStatusCode.BAD_REQUEST_400) 99 return res.fail({ message: 'Cannot set this live as permanent while saving its replay' })
81 .json({ error: 'Cannot set this live as permanent while saving its replay' })
82 } 100 }
83 101
84 const user = res.locals.oauth.token.User 102 const user = res.locals.oauth.token.User
@@ -90,11 +108,11 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
90 if (totalInstanceLives >= CONFIG.LIVE.MAX_INSTANCE_LIVES) { 108 if (totalInstanceLives >= CONFIG.LIVE.MAX_INSTANCE_LIVES) {
91 cleanUpReqFiles(req) 109 cleanUpReqFiles(req)
92 110
93 return res.status(HttpStatusCode.FORBIDDEN_403) 111 return res.fail({
94 .json({ 112 status: HttpStatusCode.FORBIDDEN_403,
95 code: ServerErrorCode.MAX_INSTANCE_LIVES_LIMIT_REACHED, 113 message: 'Cannot create this live because the max instance lives limit is reached.',
96 error: 'Cannot create this live because the max instance lives limit is reached.' 114 type: ServerErrorCode.MAX_INSTANCE_LIVES_LIMIT_REACHED
97 }) 115 })
98 } 116 }
99 } 117 }
100 118
@@ -104,11 +122,11 @@ const videoLiveAddValidator = getCommonVideoEditAttributes().concat([
104 if (totalUserLives >= CONFIG.LIVE.MAX_USER_LIVES) { 122 if (totalUserLives >= CONFIG.LIVE.MAX_USER_LIVES) {
105 cleanUpReqFiles(req) 123 cleanUpReqFiles(req)
106 124
107 return res.status(HttpStatusCode.FORBIDDEN_403) 125 return res.fail({
108 .json({ 126 status: HttpStatusCode.FORBIDDEN_403,
109 code: ServerErrorCode.MAX_USER_LIVES_LIMIT_REACHED, 127 message: 'Cannot create this live because the max user lives limit is reached.',
110 error: 'Cannot create this live because the max user lives limit is reached.' 128 type: ServerErrorCode.MAX_USER_LIVES_LIMIT_REACHED
111 }) 129 })
112 } 130 }
113 } 131 }
114 132
@@ -130,18 +148,18 @@ const videoLiveUpdateValidator = [
130 if (areValidationErrors(req, res)) return 148 if (areValidationErrors(req, res)) return
131 149
132 if (req.body.permanentLive && req.body.saveReplay) { 150 if (req.body.permanentLive && req.body.saveReplay) {
133 return res.status(HttpStatusCode.BAD_REQUEST_400) 151 return res.fail({ message: 'Cannot set this live as permanent while saving its replay' })
134 .json({ error: 'Cannot set this live as permanent while saving its replay' })
135 } 152 }
136 153
137 if (CONFIG.LIVE.ALLOW_REPLAY !== true && req.body.saveReplay === true) { 154 if (CONFIG.LIVE.ALLOW_REPLAY !== true && req.body.saveReplay === true) {
138 return res.status(HttpStatusCode.FORBIDDEN_403) 155 return res.fail({
139 .json({ error: 'Saving live replay is not allowed instance' }) 156 status: HttpStatusCode.FORBIDDEN_403,
157 message: 'Saving live replay is not allowed instance'
158 })
140 } 159 }
141 160
142 if (res.locals.videoAll.state !== VideoState.WAITING_FOR_LIVE) { 161 if (res.locals.videoAll.state !== VideoState.WAITING_FOR_LIVE) {
143 return res.status(HttpStatusCode.BAD_REQUEST_400) 162 return res.fail({ message: 'Cannot update a live that has already started' })
144 .json({ error: 'Cannot update a live that has already started' })
145 } 163 }
146 164
147 // Check the user can manage the live 165 // Check the user can manage the live
@@ -177,9 +195,10 @@ async function isLiveVideoAccepted (req: express.Request, res: express.Response)
177 if (!acceptedResult || acceptedResult.accepted !== true) { 195 if (!acceptedResult || acceptedResult.accepted !== true) {
178 logger.info('Refused local live video.', { acceptedResult, acceptParameters }) 196 logger.info('Refused local live video.', { acceptedResult, acceptParameters })
179 197
180 res.status(HttpStatusCode.FORBIDDEN_403) 198 res.fail({
181 .json({ error: acceptedResult.errorMessage || 'Refused local live video' }) 199 status: HttpStatusCode.FORBIDDEN_403,
182 200 message: acceptedResult.errorMessage || 'Refused local live video'
201 })
183 return false 202 return false
184 } 203 }
185 204
diff --git a/server/middlewares/validators/videos/video-ownership-changes.ts b/server/middlewares/validators/videos/video-ownership-changes.ts
new file mode 100644
index 000000000..54ac46c99
--- /dev/null
+++ b/server/middlewares/validators/videos/video-ownership-changes.ts
@@ -0,0 +1,121 @@
1import * as express from 'express'
2import { param } from 'express-validator'
3import { isIdValid } from '@server/helpers/custom-validators/misc'
4import { checkUserCanTerminateOwnershipChange } from '@server/helpers/custom-validators/video-ownership'
5import { logger } from '@server/helpers/logger'
6import { isAbleToUploadVideo } from '@server/lib/user'
7import { AccountModel } from '@server/models/account/account'
8import { MVideoWithAllFiles } from '@server/types/models'
9import { HttpStatusCode } from '@shared/core-utils'
10import { ServerErrorCode, UserRight, VideoChangeOwnershipAccept, VideoChangeOwnershipStatus, VideoState } from '@shared/models'
11import {
12 areValidationErrors,
13 checkUserCanManageVideo,
14 doesChangeVideoOwnershipExist,
15 doesVideoChannelOfAccountExist,
16 doesVideoExist,
17 isValidVideoIdParam
18} from '../shared'
19
20const videosChangeOwnershipValidator = [
21 isValidVideoIdParam('videoId'),
22
23 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
24 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
25
26 if (areValidationErrors(req, res)) return
27 if (!await doesVideoExist(req.params.videoId, res)) return
28
29 // Check if the user who did the request is able to change the ownership of the video
30 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return
31
32 const nextOwner = await AccountModel.loadLocalByName(req.body.username)
33 if (!nextOwner) {
34 res.fail({ message: 'Changing video ownership to a remote account is not supported yet' })
35 return
36 }
37
38 res.locals.nextOwner = nextOwner
39 return next()
40 }
41]
42
43const videosTerminateChangeOwnershipValidator = [
44 param('id')
45 .custom(isIdValid).withMessage('Should have a valid id'),
46
47 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
48 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
49
50 if (areValidationErrors(req, res)) return
51 if (!await doesChangeVideoOwnershipExist(req.params.id, res)) return
52
53 // Check if the user who did the request is able to change the ownership of the video
54 if (!checkUserCanTerminateOwnershipChange(res.locals.oauth.token.User, res.locals.videoChangeOwnership, res)) return
55
56 const videoChangeOwnership = res.locals.videoChangeOwnership
57
58 if (videoChangeOwnership.status !== VideoChangeOwnershipStatus.WAITING) {
59 res.fail({
60 status: HttpStatusCode.FORBIDDEN_403,
61 message: 'Ownership already accepted or refused'
62 })
63 return
64 }
65
66 return next()
67 }
68]
69
70const videosAcceptChangeOwnershipValidator = [
71 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
72 const body = req.body as VideoChangeOwnershipAccept
73 if (!await doesVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return
74
75 const videoChangeOwnership = res.locals.videoChangeOwnership
76
77 const video = videoChangeOwnership.Video
78
79 if (!await checkCanAccept(video, res)) return
80
81 return next()
82 }
83]
84
85export {
86 videosChangeOwnershipValidator,
87 videosTerminateChangeOwnershipValidator,
88 videosAcceptChangeOwnershipValidator
89}
90
91// ---------------------------------------------------------------------------
92
93async function checkCanAccept (video: MVideoWithAllFiles, res: express.Response): Promise<boolean> {
94 if (video.isLive) {
95
96 if (video.state !== VideoState.WAITING_FOR_LIVE) {
97 res.fail({
98 status: HttpStatusCode.BAD_REQUEST_400,
99 message: 'You can accept an ownership change of a published live.'
100 })
101
102 return false
103 }
104
105 return true
106 }
107
108 const user = res.locals.oauth.token.User
109
110 if (!await isAbleToUploadVideo(user.id, video.getMaxQualityFile().size)) {
111 res.fail({
112 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
113 message: 'The user video quota is exceeded with this video.',
114 type: ServerErrorCode.QUOTA_REACHED
115 })
116
117 return false
118 }
119
120 return true
121}
diff --git a/server/middlewares/validators/videos/video-playlists.ts b/server/middlewares/validators/videos/video-playlists.ts
index c872d045e..5ee7ee0ce 100644
--- a/server/middlewares/validators/videos/video-playlists.ts
+++ b/server/middlewares/validators/videos/video-playlists.ts
@@ -11,6 +11,7 @@ import {
11 isIdOrUUIDValid, 11 isIdOrUUIDValid,
12 isIdValid, 12 isIdValid,
13 isUUIDValid, 13 isUUIDValid,
14 toCompleteUUID,
14 toIntArray, 15 toIntArray,
15 toIntOrNull, 16 toIntOrNull,
16 toValueOrNull 17 toValueOrNull
@@ -25,12 +26,18 @@ import {
25import { isVideoImage } from '../../../helpers/custom-validators/videos' 26import { isVideoImage } from '../../../helpers/custom-validators/videos'
26import { cleanUpReqFiles } from '../../../helpers/express-utils' 27import { cleanUpReqFiles } from '../../../helpers/express-utils'
27import { logger } from '../../../helpers/logger' 28import { logger } from '../../../helpers/logger'
28import { doesVideoChannelIdExist, doesVideoExist, doesVideoPlaylistExist, VideoPlaylistFetchType } from '../../../helpers/middlewares'
29import { CONSTRAINTS_FIELDS } from '../../../initializers/constants' 29import { CONSTRAINTS_FIELDS } from '../../../initializers/constants'
30import { VideoPlaylistElementModel } from '../../../models/video/video-playlist-element' 30import { VideoPlaylistElementModel } from '../../../models/video/video-playlist-element'
31import { MVideoPlaylist } from '../../../types/models/video/video-playlist' 31import { MVideoPlaylist } from '../../../types/models/video/video-playlist'
32import { authenticatePromiseIfNeeded } from '../../auth' 32import { authenticatePromiseIfNeeded } from '../../auth'
33import { areValidationErrors } from '../utils' 33import {
34 areValidationErrors,
35 doesVideoChannelIdExist,
36 doesVideoExist,
37 doesVideoPlaylistExist,
38 isValidPlaylistIdParam,
39 VideoPlaylistFetchType
40} from '../shared'
34 41
35const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([ 42const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([
36 body('displayName') 43 body('displayName')
@@ -44,10 +51,13 @@ const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([
44 const body: VideoPlaylistCreate = req.body 51 const body: VideoPlaylistCreate = req.body
45 if (body.videoChannelId && !await doesVideoChannelIdExist(body.videoChannelId, res)) return cleanUpReqFiles(req) 52 if (body.videoChannelId && !await doesVideoChannelIdExist(body.videoChannelId, res)) return cleanUpReqFiles(req)
46 53
47 if (body.privacy === VideoPlaylistPrivacy.PUBLIC && !body.videoChannelId) { 54 if (
55 !body.videoChannelId &&
56 (body.privacy === VideoPlaylistPrivacy.PUBLIC || body.privacy === VideoPlaylistPrivacy.UNLISTED)
57 ) {
48 cleanUpReqFiles(req) 58 cleanUpReqFiles(req)
49 return res.status(HttpStatusCode.BAD_REQUEST_400) 59
50 .json({ error: 'Cannot set "public" a playlist that is not assigned to a channel.' }) 60 return res.fail({ message: 'Cannot set "public" or "unlisted" a playlist that is not assigned to a channel.' })
51 } 61 }
52 62
53 return next() 63 return next()
@@ -55,8 +65,7 @@ const videoPlaylistsAddValidator = getCommonPlaylistEditAttributes().concat([
55]) 65])
56 66
57const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([ 67const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([
58 param('playlistId') 68 isValidPlaylistIdParam('playlistId'),
59 .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
60 69
61 body('displayName') 70 body('displayName')
62 .optional() 71 .optional()
@@ -85,14 +94,14 @@ const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([
85 ) 94 )
86 ) { 95 ) {
87 cleanUpReqFiles(req) 96 cleanUpReqFiles(req)
88 return res.status(HttpStatusCode.BAD_REQUEST_400) 97
89 .json({ error: 'Cannot set "public" a playlist that is not assigned to a channel.' }) 98 return res.fail({ message: 'Cannot set "public" a playlist that is not assigned to a channel.' })
90 } 99 }
91 100
92 if (videoPlaylist.type === VideoPlaylistType.WATCH_LATER) { 101 if (videoPlaylist.type === VideoPlaylistType.WATCH_LATER) {
93 cleanUpReqFiles(req) 102 cleanUpReqFiles(req)
94 return res.status(HttpStatusCode.BAD_REQUEST_400) 103
95 .json({ error: 'Cannot update a watch later playlist.' }) 104 return res.fail({ message: 'Cannot update a watch later playlist.' })
96 } 105 }
97 106
98 if (body.videoChannelId && !await doesVideoChannelIdExist(body.videoChannelId, res)) return cleanUpReqFiles(req) 107 if (body.videoChannelId && !await doesVideoChannelIdExist(body.videoChannelId, res)) return cleanUpReqFiles(req)
@@ -102,8 +111,7 @@ const videoPlaylistsUpdateValidator = getCommonPlaylistEditAttributes().concat([
102]) 111])
103 112
104const videoPlaylistsDeleteValidator = [ 113const videoPlaylistsDeleteValidator = [
105 param('playlistId') 114 isValidPlaylistIdParam('playlistId'),
106 .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
107 115
108 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 116 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
109 logger.debug('Checking videoPlaylistsDeleteValidator parameters', { parameters: req.params }) 117 logger.debug('Checking videoPlaylistsDeleteValidator parameters', { parameters: req.params })
@@ -114,8 +122,7 @@ const videoPlaylistsDeleteValidator = [
114 122
115 const videoPlaylist = getPlaylist(res) 123 const videoPlaylist = getPlaylist(res)
116 if (videoPlaylist.type === VideoPlaylistType.WATCH_LATER) { 124 if (videoPlaylist.type === VideoPlaylistType.WATCH_LATER) {
117 return res.status(HttpStatusCode.BAD_REQUEST_400) 125 return res.fail({ message: 'Cannot delete a watch later playlist.' })
118 .json({ error: 'Cannot delete a watch later playlist.' })
119 } 126 }
120 127
121 if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, videoPlaylist, UserRight.REMOVE_ANY_VIDEO_PLAYLIST, res)) { 128 if (!checkUserCanManageVideoPlaylist(res.locals.oauth.token.User, videoPlaylist, UserRight.REMOVE_ANY_VIDEO_PLAYLIST, res)) {
@@ -128,8 +135,7 @@ const videoPlaylistsDeleteValidator = [
128 135
129const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => { 136const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => {
130 return [ 137 return [
131 param('playlistId') 138 isValidPlaylistIdParam('playlistId'),
132 .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
133 139
134 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 140 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
135 logger.debug('Checking videoPlaylistsGetValidator parameters', { parameters: req.params }) 141 logger.debug('Checking videoPlaylistsGetValidator parameters', { parameters: req.params })
@@ -144,7 +150,10 @@ const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => {
144 if (videoPlaylist.privacy === VideoPlaylistPrivacy.UNLISTED) { 150 if (videoPlaylist.privacy === VideoPlaylistPrivacy.UNLISTED) {
145 if (isUUIDValid(req.params.playlistId)) return next() 151 if (isUUIDValid(req.params.playlistId)) return next()
146 152
147 return res.status(HttpStatusCode.NOT_FOUND_404).end() 153 return res.fail({
154 status: HttpStatusCode.NOT_FOUND_404,
155 message: 'Playlist not found'
156 })
148 } 157 }
149 158
150 if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) { 159 if (videoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
@@ -156,8 +165,10 @@ const videoPlaylistsGetValidator = (fetchType: VideoPlaylistFetchType) => {
156 !user || 165 !user ||
157 (videoPlaylist.OwnerAccount.id !== user.Account.id && !user.hasRight(UserRight.UPDATE_ANY_VIDEO_PLAYLIST)) 166 (videoPlaylist.OwnerAccount.id !== user.Account.id && !user.hasRight(UserRight.UPDATE_ANY_VIDEO_PLAYLIST))
158 ) { 167 ) {
159 return res.status(HttpStatusCode.FORBIDDEN_403) 168 return res.fail({
160 .json({ error: 'Cannot get this private video playlist.' }) 169 status: HttpStatusCode.FORBIDDEN_403,
170 message: 'Cannot get this private video playlist.'
171 })
161 } 172 }
162 173
163 return next() 174 return next()
@@ -181,9 +192,10 @@ const videoPlaylistsSearchValidator = [
181] 192]
182 193
183const videoPlaylistsAddVideoValidator = [ 194const videoPlaylistsAddVideoValidator = [
184 param('playlistId') 195 isValidPlaylistIdParam('playlistId'),
185 .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'), 196
186 body('videoId') 197 body('videoId')
198 .customSanitizer(toCompleteUUID)
187 .custom(isIdOrUUIDValid).withMessage('Should have a valid video id/uuid'), 199 .custom(isIdOrUUIDValid).withMessage('Should have a valid video id/uuid'),
188 body('startTimestamp') 200 body('startTimestamp')
189 .optional() 201 .optional()
@@ -211,9 +223,9 @@ const videoPlaylistsAddVideoValidator = [
211] 223]
212 224
213const videoPlaylistsUpdateOrRemoveVideoValidator = [ 225const videoPlaylistsUpdateOrRemoveVideoValidator = [
214 param('playlistId') 226 isValidPlaylistIdParam('playlistId'),
215 .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
216 param('playlistElementId') 227 param('playlistElementId')
228 .customSanitizer(toCompleteUUID)
217 .custom(isIdValid).withMessage('Should have an element id/uuid'), 229 .custom(isIdValid).withMessage('Should have an element id/uuid'),
218 body('startTimestamp') 230 body('startTimestamp')
219 .optional() 231 .optional()
@@ -233,10 +245,10 @@ const videoPlaylistsUpdateOrRemoveVideoValidator = [
233 245
234 const videoPlaylistElement = await VideoPlaylistElementModel.loadById(req.params.playlistElementId) 246 const videoPlaylistElement = await VideoPlaylistElementModel.loadById(req.params.playlistElementId)
235 if (!videoPlaylistElement) { 247 if (!videoPlaylistElement) {
236 res.status(HttpStatusCode.NOT_FOUND_404) 248 res.fail({
237 .json({ error: 'Video playlist element not found' }) 249 status: HttpStatusCode.NOT_FOUND_404,
238 .end() 250 message: 'Video playlist element not found'
239 251 })
240 return 252 return
241 } 253 }
242 res.locals.videoPlaylistElement = videoPlaylistElement 254 res.locals.videoPlaylistElement = videoPlaylistElement
@@ -248,8 +260,7 @@ const videoPlaylistsUpdateOrRemoveVideoValidator = [
248] 260]
249 261
250const videoPlaylistElementAPGetValidator = [ 262const videoPlaylistElementAPGetValidator = [
251 param('playlistId') 263 isValidPlaylistIdParam('playlistId'),
252 .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
253 param('playlistElementId') 264 param('playlistElementId')
254 .custom(isIdValid).withMessage('Should have an playlist element id'), 265 .custom(isIdValid).withMessage('Should have an playlist element id'),
255 266
@@ -263,15 +274,18 @@ const videoPlaylistElementAPGetValidator = [
263 274
264 const videoPlaylistElement = await VideoPlaylistElementModel.loadByPlaylistAndElementIdForAP(playlistId, playlistElementId) 275 const videoPlaylistElement = await VideoPlaylistElementModel.loadByPlaylistAndElementIdForAP(playlistId, playlistElementId)
265 if (!videoPlaylistElement) { 276 if (!videoPlaylistElement) {
266 res.status(HttpStatusCode.NOT_FOUND_404) 277 res.fail({
267 .json({ error: 'Video playlist element not found' }) 278 status: HttpStatusCode.NOT_FOUND_404,
268 .end() 279 message: 'Video playlist element not found'
269 280 })
270 return 281 return
271 } 282 }
272 283
273 if (videoPlaylistElement.VideoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) { 284 if (videoPlaylistElement.VideoPlaylist.privacy === VideoPlaylistPrivacy.PRIVATE) {
274 return res.status(HttpStatusCode.FORBIDDEN_403).end() 285 return res.fail({
286 status: HttpStatusCode.FORBIDDEN_403,
287 message: 'Cannot get this private video playlist.'
288 })
275 } 289 }
276 290
277 res.locals.videoPlaylistElementAP = videoPlaylistElement 291 res.locals.videoPlaylistElementAP = videoPlaylistElement
@@ -281,8 +295,7 @@ const videoPlaylistElementAPGetValidator = [
281] 295]
282 296
283const videoPlaylistsReorderVideosValidator = [ 297const videoPlaylistsReorderVideosValidator = [
284 param('playlistId') 298 isValidPlaylistIdParam('playlistId'),
285 .custom(isIdOrUUIDValid).withMessage('Should have a valid playlist id/uuid'),
286 body('startPosition') 299 body('startPosition')
287 .isInt({ min: 1 }).withMessage('Should have a valid start position'), 300 .isInt({ min: 1 }).withMessage('Should have a valid start position'),
288 body('insertAfterPosition') 301 body('insertAfterPosition')
@@ -307,18 +320,12 @@ const videoPlaylistsReorderVideosValidator = [
307 const reorderLength: number = req.body.reorderLength 320 const reorderLength: number = req.body.reorderLength
308 321
309 if (startPosition >= nextPosition || insertAfterPosition >= nextPosition) { 322 if (startPosition >= nextPosition || insertAfterPosition >= nextPosition) {
310 res.status(HttpStatusCode.BAD_REQUEST_400) 323 res.fail({ message: `Start position or insert after position exceed the playlist limits (max: ${nextPosition - 1})` })
311 .json({ error: `Start position or insert after position exceed the playlist limits (max: ${nextPosition - 1})` })
312 .end()
313
314 return 324 return
315 } 325 }
316 326
317 if (reorderLength && reorderLength + startPosition > nextPosition) { 327 if (reorderLength && reorderLength + startPosition > nextPosition) {
318 res.status(HttpStatusCode.BAD_REQUEST_400) 328 res.fail({ message: `Reorder length with this start position exceeds the playlist limits (max: ${nextPosition - startPosition})` })
319 .json({ error: `Reorder length with this start position exceeds the playlist limits (max: ${nextPosition - startPosition})` })
320 .end()
321
322 return 329 return
323 } 330 }
324 331
@@ -401,10 +408,10 @@ function getCommonPlaylistEditAttributes () {
401 408
402function checkUserCanManageVideoPlaylist (user: MUserAccountId, videoPlaylist: MVideoPlaylist, right: UserRight, res: express.Response) { 409function checkUserCanManageVideoPlaylist (user: MUserAccountId, videoPlaylist: MVideoPlaylist, right: UserRight, res: express.Response) {
403 if (videoPlaylist.isOwned() === false) { 410 if (videoPlaylist.isOwned() === false) {
404 res.status(HttpStatusCode.FORBIDDEN_403) 411 res.fail({
405 .json({ error: 'Cannot manage video playlist of another server.' }) 412 status: HttpStatusCode.FORBIDDEN_403,
406 .end() 413 message: 'Cannot manage video playlist of another server.'
407 414 })
408 return false 415 return false
409 } 416 }
410 417
@@ -412,10 +419,10 @@ function checkUserCanManageVideoPlaylist (user: MUserAccountId, videoPlaylist: M
412 // The user can delete it if s/he is an admin 419 // The user can delete it if s/he is an admin
413 // Or if s/he is the video playlist's owner 420 // Or if s/he is the video playlist's owner
414 if (user.hasRight(right) === false && videoPlaylist.ownerAccountId !== user.Account.id) { 421 if (user.hasRight(right) === false && videoPlaylist.ownerAccountId !== user.Account.id) {
415 res.status(HttpStatusCode.FORBIDDEN_403) 422 res.fail({
416 .json({ error: 'Cannot manage video playlist of another user' }) 423 status: HttpStatusCode.FORBIDDEN_403,
417 .end() 424 message: 'Cannot manage video playlist of another user'
418 425 })
419 return false 426 return false
420 } 427 }
421 428
diff --git a/server/middlewares/validators/videos/video-rates.ts b/server/middlewares/validators/videos/video-rates.ts
index 01bdef25f..5d5dfb222 100644
--- a/server/middlewares/validators/videos/video-rates.ts
+++ b/server/middlewares/validators/videos/video-rates.ts
@@ -1,18 +1,18 @@
1import * as express from 'express' 1import * as express from 'express'
2import { body, param, query } from 'express-validator' 2import { body, param, query } from 'express-validator'
3import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc' 3import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
4import { VideoRateType } from '../../../../shared/models/videos'
5import { isAccountNameValid } from '../../../helpers/custom-validators/accounts'
6import { isIdValid } from '../../../helpers/custom-validators/misc'
4import { isRatingValid } from '../../../helpers/custom-validators/video-rates' 7import { isRatingValid } from '../../../helpers/custom-validators/video-rates'
5import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos' 8import { isVideoRatingTypeValid } from '../../../helpers/custom-validators/videos'
6import { logger } from '../../../helpers/logger' 9import { logger } from '../../../helpers/logger'
7import { areValidationErrors } from '../utils'
8import { AccountVideoRateModel } from '../../../models/account/account-video-rate' 10import { AccountVideoRateModel } from '../../../models/account/account-video-rate'
9import { VideoRateType } from '../../../../shared/models/videos' 11import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared'
10import { isAccountNameValid } from '../../../helpers/custom-validators/accounts'
11import { doesVideoExist } from '../../../helpers/middlewares'
12import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
13 12
14const videoUpdateRateValidator = [ 13const videoUpdateRateValidator = [
15 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), 14 isValidVideoIdParam('id'),
15
16 body('rating').custom(isVideoRatingTypeValid).withMessage('Should have a valid rate type'), 16 body('rating').custom(isVideoRatingTypeValid).withMessage('Should have a valid rate type'),
17 17
18 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 18 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
@@ -37,8 +37,10 @@ const getAccountVideoRateValidatorFactory = function (rateType: VideoRateType) {
37 37
38 const rate = await AccountVideoRateModel.loadLocalAndPopulateVideo(rateType, req.params.name, +req.params.videoId) 38 const rate = await AccountVideoRateModel.loadLocalAndPopulateVideo(rateType, req.params.name, +req.params.videoId)
39 if (!rate) { 39 if (!rate) {
40 return res.status(HttpStatusCode.NOT_FOUND_404) 40 return res.fail({
41 .json({ error: 'Video rate not found' }) 41 status: HttpStatusCode.NOT_FOUND_404,
42 message: 'Video rate not found'
43 })
42 } 44 }
43 45
44 res.locals.accountVideoRate = rate 46 res.locals.accountVideoRate = rate
diff --git a/server/middlewares/validators/videos/video-shares.ts b/server/middlewares/validators/videos/video-shares.ts
index f0d8e0c36..7e54b6fc0 100644
--- a/server/middlewares/validators/videos/video-shares.ts
+++ b/server/middlewares/validators/videos/video-shares.ts
@@ -1,15 +1,16 @@
1import * as express from 'express' 1import * as express from 'express'
2import { param } from 'express-validator' 2import { param } from 'express-validator'
3import { isIdOrUUIDValid, isIdValid } from '../../../helpers/custom-validators/misc' 3import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
4import { isIdValid } from '../../../helpers/custom-validators/misc'
4import { logger } from '../../../helpers/logger' 5import { logger } from '../../../helpers/logger'
5import { VideoShareModel } from '../../../models/video/video-share' 6import { VideoShareModel } from '../../../models/video/video-share'
6import { areValidationErrors } from '../utils' 7import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared'
7import { doesVideoExist } from '../../../helpers/middlewares'
8import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
9 8
10const videosShareValidator = [ 9const videosShareValidator = [
11 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), 10 isValidVideoIdParam('id'),
12 param('actorId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid actor id'), 11
12 param('actorId')
13 .custom(isIdValid).not().isEmpty().withMessage('Should have a valid actor id'),
13 14
14 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 15 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
15 logger.debug('Checking videoShare parameters', { parameters: req.params }) 16 logger.debug('Checking videoShare parameters', { parameters: req.params })
diff --git a/server/middlewares/validators/videos/video-watch.ts b/server/middlewares/validators/videos/video-watch.ts
index 29ce0dab6..43306f7cd 100644
--- a/server/middlewares/validators/videos/video-watch.ts
+++ b/server/middlewares/validators/videos/video-watch.ts
@@ -1,13 +1,13 @@
1import { body, param } from 'express-validator'
2import * as express from 'express' 1import * as express from 'express'
3import { isIdOrUUIDValid, toIntOrNull } from '../../../helpers/custom-validators/misc' 2import { body } from 'express-validator'
4import { areValidationErrors } from '../utils'
5import { logger } from '../../../helpers/logger'
6import { doesVideoExist } from '../../../helpers/middlewares'
7import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 3import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
4import { toIntOrNull } from '../../../helpers/custom-validators/misc'
5import { logger } from '../../../helpers/logger'
6import { areValidationErrors, doesVideoExist, isValidVideoIdParam } from '../shared'
8 7
9const videoWatchingValidator = [ 8const videoWatchingValidator = [
10 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), 9 isValidVideoIdParam('videoId'),
10
11 body('currentTime') 11 body('currentTime')
12 .customSanitizer(toIntOrNull) 12 .customSanitizer(toIntOrNull)
13 .isInt().withMessage('Should have correct current time'), 13 .isInt().withMessage('Should have correct current time'),
@@ -21,7 +21,10 @@ const videoWatchingValidator = [
21 const user = res.locals.oauth.token.User 21 const user = res.locals.oauth.token.User
22 if (user.videosHistoryEnabled === false) { 22 if (user.videosHistoryEnabled === false) {
23 logger.warn('Cannot set videos to watch by user %d: videos history is disabled.', user.id) 23 logger.warn('Cannot set videos to watch by user %d: videos history is disabled.', user.id)
24 return res.status(HttpStatusCode.CONFLICT_409).end() 24 return res.fail({
25 status: HttpStatusCode.CONFLICT_409,
26 message: 'Video history is disabled'
27 })
25 } 28 }
26 29
27 return next() 30 return next()
diff --git a/server/middlewares/validators/videos/videos.ts b/server/middlewares/validators/videos/videos.ts
index d26bcd4a6..49e10e2b5 100644
--- a/server/middlewares/validators/videos/videos.ts
+++ b/server/middlewares/validators/videos/videos.ts
@@ -4,16 +4,14 @@ import { getResumableUploadPath } from '@server/helpers/upload'
4import { isAbleToUploadVideo } from '@server/lib/user' 4import { isAbleToUploadVideo } from '@server/lib/user'
5import { getServerActor } from '@server/models/application/application' 5import { getServerActor } from '@server/models/application/application'
6import { ExpressPromiseHandler } from '@server/types/express' 6import { ExpressPromiseHandler } from '@server/types/express'
7import { MUserAccountId, MVideoWithRights } from '@server/types/models' 7import { MUserAccountId, MVideoFullLight } from '@server/types/models'
8import { ServerErrorCode, UserRight, VideoChangeOwnershipStatus, VideoPrivacy } from '../../../../shared' 8import { ServerErrorCode, UserRight, VideoPrivacy } from '../../../../shared'
9import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 9import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
10import { VideoChangeOwnershipAccept } from '../../../../shared/models/videos/video-change-ownership-accept.model'
11import { 10import {
12 exists, 11 exists,
13 isBooleanValid, 12 isBooleanValid,
14 isDateValid, 13 isDateValid,
15 isFileFieldValid, 14 isFileFieldValid,
16 isIdOrUUIDValid,
17 isIdValid, 15 isIdValid,
18 isUUIDValid, 16 isUUIDValid,
19 toArray, 17 toArray,
@@ -22,7 +20,6 @@ import {
22 toValueOrNull 20 toValueOrNull
23} from '../../../helpers/custom-validators/misc' 21} from '../../../helpers/custom-validators/misc'
24import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search' 22import { isBooleanBothQueryValid, isNumberArray, isStringArray } from '../../../helpers/custom-validators/search'
25import { checkUserCanTerminateOwnershipChange, doesChangeVideoOwnershipExist } from '../../../helpers/custom-validators/video-ownership'
26import { 23import {
27 isScheduleVideoUpdatePrivacyValid, 24 isScheduleVideoUpdatePrivacyValid,
28 isVideoCategoryValid, 25 isVideoCategoryValid,
@@ -42,22 +39,22 @@ import {
42import { cleanUpReqFiles } from '../../../helpers/express-utils' 39import { cleanUpReqFiles } from '../../../helpers/express-utils'
43import { getDurationFromVideoFile } from '../../../helpers/ffprobe-utils' 40import { getDurationFromVideoFile } from '../../../helpers/ffprobe-utils'
44import { logger } from '../../../helpers/logger' 41import { logger } from '../../../helpers/logger'
45import {
46 checkUserCanManageVideo,
47 doesVideoChannelOfAccountExist,
48 doesVideoExist,
49 doesVideoFileOfVideoExist
50} from '../../../helpers/middlewares'
51import { deleteFileAndCatch } from '../../../helpers/utils' 42import { deleteFileAndCatch } from '../../../helpers/utils'
52import { getVideoWithAttributes } from '../../../helpers/video' 43import { getVideoWithAttributes } from '../../../helpers/video'
53import { CONFIG } from '../../../initializers/config' 44import { CONFIG } from '../../../initializers/config'
54import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants' 45import { CONSTRAINTS_FIELDS, OVERVIEWS } from '../../../initializers/constants'
55import { isLocalVideoAccepted } from '../../../lib/moderation' 46import { isLocalVideoAccepted } from '../../../lib/moderation'
56import { Hooks } from '../../../lib/plugins/hooks' 47import { Hooks } from '../../../lib/plugins/hooks'
57import { AccountModel } from '../../../models/account/account'
58import { VideoModel } from '../../../models/video/video' 48import { VideoModel } from '../../../models/video/video'
59import { authenticatePromiseIfNeeded } from '../../auth' 49import { authenticatePromiseIfNeeded } from '../../auth'
60import { areValidationErrors } from '../utils' 50import {
51 areValidationErrors,
52 checkUserCanManageVideo,
53 doesVideoChannelOfAccountExist,
54 doesVideoExist,
55 doesVideoFileOfVideoExist,
56 isValidVideoIdParam
57} from '../shared'
61 58
62const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([ 59const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
63 body('videofile') 60 body('videofile')
@@ -65,8 +62,9 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
65 .withMessage('Should have a file'), 62 .withMessage('Should have a file'),
66 body('name') 63 body('name')
67 .trim() 64 .trim()
68 .custom(isVideoNameValid) 65 .custom(isVideoNameValid).withMessage(
69 .withMessage('Should have a valid name'), 66 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
67 ),
70 body('channelId') 68 body('channelId')
71 .customSanitizer(toIntOrNull) 69 .customSanitizer(toIntOrNull)
72 .custom(isIdValid).withMessage('Should have correct video channel id'), 70 .custom(isIdValid).withMessage('Should have correct video channel id'),
@@ -87,9 +85,11 @@ const videosAddLegacyValidator = getCommonVideoEditAttributes().concat([
87 if (!videoFile.duration) await addDurationToVideo(videoFile) 85 if (!videoFile.duration) await addDurationToVideo(videoFile)
88 } catch (err) { 86 } catch (err) {
89 logger.error('Invalid input file in videosAddLegacyValidator.', { err }) 87 logger.error('Invalid input file in videosAddLegacyValidator.', { err })
90 res.status(HttpStatusCode.UNPROCESSABLE_ENTITY_422)
91 .json({ error: 'Video file unreadable.' })
92 88
89 res.fail({
90 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
91 message: 'Video file unreadable.'
92 })
93 return cleanUpReqFiles(req) 93 return cleanUpReqFiles(req)
94 } 94 }
95 95
@@ -117,9 +117,11 @@ const videosAddResumableValidator = [
117 if (!file.duration) await addDurationToVideo(file) 117 if (!file.duration) await addDurationToVideo(file)
118 } catch (err) { 118 } catch (err) {
119 logger.error('Invalid input file in videosAddResumableValidator.', { err }) 119 logger.error('Invalid input file in videosAddResumableValidator.', { err })
120 res.status(HttpStatusCode.UNPROCESSABLE_ENTITY_422)
121 .json({ error: 'Video file unreadable.' })
122 120
121 res.fail({
122 status: HttpStatusCode.UNPROCESSABLE_ENTITY_422,
123 message: 'Video file unreadable.'
124 })
123 return cleanup() 125 return cleanup()
124 } 126 }
125 127
@@ -146,8 +148,9 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
146 .withMessage('Should have a valid filename'), 148 .withMessage('Should have a valid filename'),
147 body('name') 149 body('name')
148 .trim() 150 .trim()
149 .custom(isVideoNameValid) 151 .custom(isVideoNameValid).withMessage(
150 .withMessage('Should have a valid name'), 152 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
153 ),
151 body('channelId') 154 body('channelId')
152 .customSanitizer(toIntOrNull) 155 .customSanitizer(toIntOrNull)
153 .custom(isIdValid).withMessage('Should have correct video channel id'), 156 .custom(isIdValid).withMessage('Should have correct video channel id'),
@@ -192,11 +195,14 @@ const videosAddResumableInitValidator = getCommonVideoEditAttributes().concat([
192]) 195])
193 196
194const videosUpdateValidator = getCommonVideoEditAttributes().concat([ 197const videosUpdateValidator = getCommonVideoEditAttributes().concat([
195 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), 198 isValidVideoIdParam('id'),
199
196 body('name') 200 body('name')
197 .optional() 201 .optional()
198 .trim() 202 .trim()
199 .custom(isVideoNameValid).withMessage('Should have a valid name'), 203 .custom(isVideoNameValid).withMessage(
204 `Should have a video name between ${CONSTRAINTS_FIELDS.VIDEOS.NAME.min} and ${CONSTRAINTS_FIELDS.VIDEOS.NAME.max} characters long`
205 ),
200 body('channelId') 206 body('channelId')
201 .optional() 207 .optional()
202 .customSanitizer(toIntOrNull) 208 .customSanitizer(toIntOrNull)
@@ -238,20 +244,22 @@ async function checkVideoFollowConstraints (req: express.Request, res: express.R
238 const serverActor = await getServerActor() 244 const serverActor = await getServerActor()
239 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next() 245 if (await VideoModel.checkVideoHasInstanceFollow(video.id, serverActor.id) === true) return next()
240 246
241 return res.status(HttpStatusCode.FORBIDDEN_403) 247 return res.fail({
242 .json({ 248 status: HttpStatusCode.FORBIDDEN_403,
243 errorCode: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS, 249 message: 'Cannot get this video regarding follow constraints',
244 error: 'Cannot get this video regarding follow constraints.', 250 type: ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS,
245 originUrl: video.url 251 data: {
246 }) 252 originUrl: video.url
253 }
254 })
247} 255}
248 256
249const videosCustomGetValidator = ( 257const videosCustomGetValidator = (
250 fetchType: 'all' | 'only-video' | 'only-video-with-rights' | 'only-immutable-attributes', 258 fetchType: 'for-api' | 'all' | 'only-video' | 'only-immutable-attributes',
251 authenticateInQuery = false 259 authenticateInQuery = false
252) => { 260) => {
253 return [ 261 return [
254 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), 262 isValidVideoIdParam('id'),
255 263
256 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 264 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
257 logger.debug('Checking videosGet parameters', { parameters: req.params }) 265 logger.debug('Checking videosGet parameters', { parameters: req.params })
@@ -262,7 +270,7 @@ const videosCustomGetValidator = (
262 // Controllers does not need to check video rights 270 // Controllers does not need to check video rights
263 if (fetchType === 'only-immutable-attributes') return next() 271 if (fetchType === 'only-immutable-attributes') return next()
264 272
265 const video = getVideoWithAttributes(res) as MVideoWithRights 273 const video = getVideoWithAttributes(res) as MVideoFullLight
266 274
267 // Video private or blacklisted 275 // Video private or blacklisted
268 if (video.requiresAuth()) { 276 if (video.requiresAuth()) {
@@ -270,10 +278,12 @@ const videosCustomGetValidator = (
270 278
271 const user = res.locals.oauth ? res.locals.oauth.token.User : null 279 const user = res.locals.oauth ? res.locals.oauth.token.User : null
272 280
273 // Only the owner or a user that have blacklist rights can see the video 281 // Only the owner or a user that have blocklist rights can see the video
274 if (!user || !user.canGetVideo(video)) { 282 if (!user || !user.canGetVideo(video)) {
275 return res.status(HttpStatusCode.FORBIDDEN_403) 283 return res.fail({
276 .json({ error: 'Cannot get this private/internal or blacklisted video.' }) 284 status: HttpStatusCode.FORBIDDEN_403,
285 message: 'Cannot get this private/internal or blocklisted video'
286 })
277 } 287 }
278 288
279 return next() 289 return next()
@@ -287,7 +297,10 @@ const videosCustomGetValidator = (
287 if (isUUIDValid(req.params.id)) return next() 297 if (isUUIDValid(req.params.id)) return next()
288 298
289 // Don't leak this unlisted video 299 // Don't leak this unlisted video
290 return res.status(HttpStatusCode.NOT_FOUND_404).end() 300 return res.fail({
301 status: HttpStatusCode.NOT_FOUND_404,
302 message: 'Video not found'
303 })
291 } 304 }
292 } 305 }
293 ] 306 ]
@@ -297,8 +310,10 @@ const videosGetValidator = videosCustomGetValidator('all')
297const videosDownloadValidator = videosCustomGetValidator('all', true) 310const videosDownloadValidator = videosCustomGetValidator('all', true)
298 311
299const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([ 312const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
300 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), 313 isValidVideoIdParam('id'),
301 param('videoFileId').custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'), 314
315 param('videoFileId')
316 .custom(isIdValid).not().isEmpty().withMessage('Should have a valid videoFileId'),
302 317
303 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 318 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
304 logger.debug('Checking videoFileMetadataGet parameters', { parameters: req.params }) 319 logger.debug('Checking videoFileMetadataGet parameters', { parameters: req.params })
@@ -311,7 +326,7 @@ const videoFileMetadataGetValidator = getCommonVideoEditAttributes().concat([
311]) 326])
312 327
313const videosRemoveValidator = [ 328const videosRemoveValidator = [
314 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'), 329 isValidVideoIdParam('id'),
315 330
316 async (req: express.Request, res: express.Response, next: express.NextFunction) => { 331 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
317 logger.debug('Checking videosRemove parameters', { parameters: req.params }) 332 logger.debug('Checking videosRemove parameters', { parameters: req.params })
@@ -326,74 +341,6 @@ const videosRemoveValidator = [
326 } 341 }
327] 342]
328 343
329const videosChangeOwnershipValidator = [
330 param('videoId').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
331
332 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
333 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
334
335 if (areValidationErrors(req, res)) return
336 if (!await doesVideoExist(req.params.videoId, res)) return
337
338 // Check if the user who did the request is able to change the ownership of the video
339 if (!checkUserCanManageVideo(res.locals.oauth.token.User, res.locals.videoAll, UserRight.CHANGE_VIDEO_OWNERSHIP, res)) return
340
341 const nextOwner = await AccountModel.loadLocalByName(req.body.username)
342 if (!nextOwner) {
343 res.status(HttpStatusCode.BAD_REQUEST_400)
344 .json({ error: 'Changing video ownership to a remote account is not supported yet' })
345
346 return
347 }
348 res.locals.nextOwner = nextOwner
349
350 return next()
351 }
352]
353
354const videosTerminateChangeOwnershipValidator = [
355 param('id').custom(isIdOrUUIDValid).not().isEmpty().withMessage('Should have a valid id'),
356
357 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
358 logger.debug('Checking changeOwnership parameters', { parameters: req.params })
359
360 if (areValidationErrors(req, res)) return
361 if (!await doesChangeVideoOwnershipExist(req.params.id, res)) return
362
363 // Check if the user who did the request is able to change the ownership of the video
364 if (!checkUserCanTerminateOwnershipChange(res.locals.oauth.token.User, res.locals.videoChangeOwnership, res)) return
365
366 const videoChangeOwnership = res.locals.videoChangeOwnership
367
368 if (videoChangeOwnership.status !== VideoChangeOwnershipStatus.WAITING) {
369 res.status(HttpStatusCode.FORBIDDEN_403)
370 .json({ error: 'Ownership already accepted or refused' })
371 return
372 }
373
374 return next()
375 }
376]
377
378const videosAcceptChangeOwnershipValidator = [
379 async (req: express.Request, res: express.Response, next: express.NextFunction) => {
380 const body = req.body as VideoChangeOwnershipAccept
381 if (!await doesVideoChannelOfAccountExist(body.channelId, res.locals.oauth.token.User, res)) return
382
383 const user = res.locals.oauth.token.User
384 const videoChangeOwnership = res.locals.videoChangeOwnership
385 const isAble = await isAbleToUploadVideo(user.id, videoChangeOwnership.Video.getMaxQualityFile().size)
386 if (isAble === false) {
387 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413)
388 .json({ error: 'The user video quota is exceeded with this video.' })
389
390 return
391 }
392
393 return next()
394 }
395]
396
397const videosOverviewValidator = [ 344const videosOverviewValidator = [
398 query('page') 345 query('page')
399 .optional() 346 .optional()
@@ -455,7 +402,11 @@ function getCommonVideoEditAttributes () {
455 body('tags') 402 body('tags')
456 .optional() 403 .optional()
457 .customSanitizer(toValueOrNull) 404 .customSanitizer(toValueOrNull)
458 .custom(isVideoTagsValid).withMessage('Should have correct tags'), 405 .custom(isVideoTagsValid)
406 .withMessage(
407 `Should have an array of up to ${CONSTRAINTS_FIELDS.VIDEOS.TAGS.max} tags between ` +
408 `${CONSTRAINTS_FIELDS.VIDEOS.TAG.min} and ${CONSTRAINTS_FIELDS.VIDEOS.TAG.max} characters each`
409 ),
459 body('commentsEnabled') 410 body('commentsEnabled')
460 .optional() 411 .optional()
461 .customSanitizer(toBooleanOrNull) 412 .customSanitizer(toBooleanOrNull)
@@ -473,7 +424,7 @@ function getCommonVideoEditAttributes () {
473 .customSanitizer(toValueOrNull), 424 .customSanitizer(toValueOrNull),
474 body('scheduleUpdate.updateAt') 425 body('scheduleUpdate.updateAt')
475 .optional() 426 .optional()
476 .custom(isDateValid).withMessage('Should have a valid schedule update date'), 427 .custom(isDateValid).withMessage('Should have a schedule update date that conforms to ISO 8601'),
477 body('scheduleUpdate.privacy') 428 body('scheduleUpdate.privacy')
478 .optional() 429 .optional()
479 .customSanitizer(toIntOrNull) 430 .customSanitizer(toIntOrNull)
@@ -530,9 +481,10 @@ const commonVideosFiltersValidator = [
530 (req.query.filter === 'all-local' || req.query.filter === 'all') && 481 (req.query.filter === 'all-local' || req.query.filter === 'all') &&
531 (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false) 482 (!user || user.hasRight(UserRight.SEE_ALL_VIDEOS) === false)
532 ) { 483 ) {
533 res.status(HttpStatusCode.UNAUTHORIZED_401) 484 res.fail({
534 .json({ error: 'You are not allowed to see all local videos.' }) 485 status: HttpStatusCode.UNAUTHORIZED_401,
535 486 message: 'You are not allowed to see all local videos.'
487 })
536 return 488 return
537 } 489 }
538 490
@@ -555,10 +507,6 @@ export {
555 videosCustomGetValidator, 507 videosCustomGetValidator,
556 videosRemoveValidator, 508 videosRemoveValidator,
557 509
558 videosChangeOwnershipValidator,
559 videosTerminateChangeOwnershipValidator,
560 videosAcceptChangeOwnershipValidator,
561
562 getCommonVideoEditAttributes, 510 getCommonVideoEditAttributes,
563 511
564 commonVideosFiltersValidator, 512 commonVideosFiltersValidator,
@@ -573,9 +521,7 @@ function areErrorsInScheduleUpdate (req: express.Request, res: express.Response)
573 if (!req.body.scheduleUpdate.updateAt) { 521 if (!req.body.scheduleUpdate.updateAt) {
574 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.') 522 logger.warn('Invalid parameters: scheduleUpdate.updateAt is mandatory.')
575 523
576 res.status(HttpStatusCode.BAD_REQUEST_400) 524 res.fail({ message: 'Schedule update at is mandatory.' })
577 .json({ error: 'Schedule update at is mandatory.' })
578
579 return true 525 return true
580 } 526 }
581 } 527 }
@@ -597,26 +543,29 @@ async function commonVideoChecksPass (parameters: {
597 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false 543 if (!await doesVideoChannelOfAccountExist(req.body.channelId, user, res)) return false
598 544
599 if (!isVideoFileMimeTypeValid(files)) { 545 if (!isVideoFileMimeTypeValid(files)) {
600 res.status(HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415) 546 res.fail({
601 .json({ 547 status: HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415,
602 error: 'This file is not supported. Please, make sure it is of the following type: ' + 548 message: 'This file is not supported. Please, make sure it is of the following type: ' +
603 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ') 549 CONSTRAINTS_FIELDS.VIDEOS.EXTNAME.join(', ')
604 }) 550 })
605
606 return false 551 return false
607 } 552 }
608 553
609 if (!isVideoFileSizeValid(videoFileSize.toString())) { 554 if (!isVideoFileSizeValid(videoFileSize.toString())) {
610 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413) 555 res.fail({
611 .json({ error: 'This file is too large. It exceeds the maximum file size authorized.' }) 556 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
612 557 message: 'This file is too large. It exceeds the maximum file size authorized.',
558 type: ServerErrorCode.MAX_FILE_SIZE_REACHED
559 })
613 return false 560 return false
614 } 561 }
615 562
616 if (await isAbleToUploadVideo(user.id, videoFileSize) === false) { 563 if (await isAbleToUploadVideo(user.id, videoFileSize) === false) {
617 res.status(HttpStatusCode.PAYLOAD_TOO_LARGE_413) 564 res.fail({
618 .json({ error: 'The user video quota is exceeded with this video.' }) 565 status: HttpStatusCode.PAYLOAD_TOO_LARGE_413,
619 566 message: 'The user video quota is exceeded with this video.',
567 type: ServerErrorCode.QUOTA_REACHED
568 })
620 return false 569 return false
621 } 570 }
622 571
@@ -642,9 +591,10 @@ export async function isVideoAccepted (
642 591
643 if (!acceptedResult || acceptedResult.accepted !== true) { 592 if (!acceptedResult || acceptedResult.accepted !== true) {
644 logger.info('Refused local video.', { acceptedResult, acceptParameters }) 593 logger.info('Refused local video.', { acceptedResult, acceptParameters })
645 res.status(HttpStatusCode.FORBIDDEN_403) 594 res.fail({
646 .json({ error: acceptedResult.errorMessage || 'Refused local video' }) 595 status: HttpStatusCode.FORBIDDEN_403,
647 596 message: acceptedResult.errorMessage || 'Refused local video'
597 })
648 return false 598 return false
649 } 599 }
650 600
diff --git a/server/middlewares/validators/webfinger.ts b/server/middlewares/validators/webfinger.ts
index a71422ed8..bcdd136c6 100644
--- a/server/middlewares/validators/webfinger.ts
+++ b/server/middlewares/validators/webfinger.ts
@@ -1,11 +1,11 @@
1import * as express from 'express' 1import * as express from 'express'
2import { query } from 'express-validator' 2import { query } from 'express-validator'
3import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
3import { isWebfingerLocalResourceValid } from '../../helpers/custom-validators/webfinger' 4import { isWebfingerLocalResourceValid } from '../../helpers/custom-validators/webfinger'
4import { logger } from '../../helpers/logger'
5import { ActorModel } from '../../models/activitypub/actor'
6import { areValidationErrors } from './utils'
7import { getHostWithPort } from '../../helpers/express-utils' 5import { getHostWithPort } from '../../helpers/express-utils'
8import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes' 6import { logger } from '../../helpers/logger'
7import { ActorModel } from '../../models/actor/actor'
8import { areValidationErrors } from './shared'
9 9
10const webfingerValidator = [ 10const webfingerValidator = [
11 query('resource').custom(isWebfingerLocalResourceValid).withMessage('Should have a valid webfinger resource'), 11 query('resource').custom(isWebfingerLocalResourceValid).withMessage('Should have a valid webfinger resource'),
@@ -21,9 +21,10 @@ const webfingerValidator = [
21 21
22 const actor = await ActorModel.loadLocalUrlByName(name) 22 const actor = await ActorModel.loadLocalUrlByName(name)
23 if (!actor) { 23 if (!actor) {
24 return res.status(HttpStatusCode.NOT_FOUND_404) 24 return res.fail({
25 .send({ error: 'Actor not found' }) 25 status: HttpStatusCode.NOT_FOUND_404,
26 .end() 26 message: 'Actor not found'
27 })
27 } 28 }
28 29
29 res.locals.actorUrl = actor 30 res.locals.actorUrl = actor
diff --git a/server/models/abuse/abuse-message.ts b/server/models/abuse/abuse-message.ts
index 7e51b3e07..2c5987e96 100644
--- a/server/models/abuse/abuse-message.ts
+++ b/server/models/abuse/abuse-message.ts
@@ -1,6 +1,7 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { isAbuseMessageValid } from '@server/helpers/custom-validators/abuses' 2import { isAbuseMessageValid } from '@server/helpers/custom-validators/abuses'
3import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models' 3import { MAbuseMessage, MAbuseMessageFormattable } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils'
4import { AbuseMessage } from '@shared/models' 5import { AbuseMessage } from '@shared/models'
5import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account' 6import { AccountModel, ScopeNames as AccountScopeNames } from '../account/account'
6import { getSort, throwIfNotValid } from '../utils' 7import { getSort, throwIfNotValid } from '../utils'
@@ -17,7 +18,7 @@ import { AbuseModel } from './abuse'
17 } 18 }
18 ] 19 ]
19}) 20})
20export class AbuseMessageModel extends Model { 21export class AbuseMessageModel extends Model<Partial<AttributesOnly<AbuseMessageModel>>> {
21 22
22 @AllowNull(false) 23 @AllowNull(false)
23 @Is('AbuseMessage', value => throwIfNotValid(value, isAbuseMessageValid, 'message')) 24 @Is('AbuseMessage', value => throwIfNotValid(value, isAbuseMessageValid, 'message'))
diff --git a/server/models/abuse/abuse.ts b/server/models/abuse/abuse.ts
index 262f364f1..3518f5c02 100644
--- a/server/models/abuse/abuse.ts
+++ b/server/models/abuse/abuse.ts
@@ -16,7 +16,7 @@ import {
16 UpdatedAt 16 UpdatedAt
17} from 'sequelize-typescript' 17} from 'sequelize-typescript'
18import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses' 18import { isAbuseModerationCommentValid, isAbuseReasonValid, isAbuseStateValid } from '@server/helpers/custom-validators/abuses'
19import { abusePredefinedReasonsMap } from '@shared/core-utils/abuse' 19import { abusePredefinedReasonsMap, AttributesOnly } from '@shared/core-utils'
20import { 20import {
21 AbuseFilter, 21 AbuseFilter,
22 AbuseObject, 22 AbuseObject,
@@ -187,7 +187,7 @@ export enum ScopeNames {
187 } 187 }
188 ] 188 ]
189}) 189})
190export class AbuseModel extends Model { 190export class AbuseModel extends Model<Partial<AttributesOnly<AbuseModel>>> {
191 191
192 @AllowNull(false) 192 @AllowNull(false)
193 @Default(null) 193 @Default(null)
diff --git a/server/models/abuse/video-abuse.ts b/server/models/abuse/video-abuse.ts
index 90aa0695e..95bff50d0 100644
--- a/server/models/abuse/video-abuse.ts
+++ b/server/models/abuse/video-abuse.ts
@@ -1,4 +1,5 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { AttributesOnly } from '@shared/core-utils'
2import { VideoDetails } from '@shared/models' 3import { VideoDetails } from '@shared/models'
3import { VideoModel } from '../video/video' 4import { VideoModel } from '../video/video'
4import { AbuseModel } from './abuse' 5import { AbuseModel } from './abuse'
@@ -14,7 +15,7 @@ import { AbuseModel } from './abuse'
14 } 15 }
15 ] 16 ]
16}) 17})
17export class VideoAbuseModel extends Model { 18export class VideoAbuseModel extends Model<Partial<AttributesOnly<VideoAbuseModel>>> {
18 19
19 @CreatedAt 20 @CreatedAt
20 createdAt: Date 21 createdAt: Date
diff --git a/server/models/abuse/video-comment-abuse.ts b/server/models/abuse/video-comment-abuse.ts
index d3fce76a5..32cb2ca64 100644
--- a/server/models/abuse/video-comment-abuse.ts
+++ b/server/models/abuse/video-comment-abuse.ts
@@ -1,4 +1,5 @@
1import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { AttributesOnly } from '@shared/core-utils'
2import { VideoCommentModel } from '../video/video-comment' 3import { VideoCommentModel } from '../video/video-comment'
3import { AbuseModel } from './abuse' 4import { AbuseModel } from './abuse'
4 5
@@ -13,7 +14,7 @@ import { AbuseModel } from './abuse'
13 } 14 }
14 ] 15 ]
15}) 16})
16export class VideoCommentAbuseModel extends Model { 17export class VideoCommentAbuseModel extends Model<Partial<AttributesOnly<VideoCommentAbuseModel>>> {
17 18
18 @CreatedAt 19 @CreatedAt
19 createdAt: Date 20 createdAt: Date
diff --git a/server/models/account/account-blocklist.ts b/server/models/account/account-blocklist.ts
index fe9168ab8..b2375b006 100644
--- a/server/models/account/account-blocklist.ts
+++ b/server/models/account/account-blocklist.ts
@@ -1,8 +1,9 @@
1import { Op } from 'sequelize' 1import { Op } from 'sequelize'
2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/types/models' 3import { MAccountBlocklist, MAccountBlocklistAccounts, MAccountBlocklistFormattable } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils'
4import { AccountBlock } from '../../../shared/models' 5import { AccountBlock } from '../../../shared/models'
5import { ActorModel } from '../activitypub/actor' 6import { ActorModel } from '../actor/actor'
6import { ServerModel } from '../server/server' 7import { ServerModel } from '../server/server'
7import { getSort, searchAttribute } from '../utils' 8import { getSort, searchAttribute } from '../utils'
8import { AccountModel } from './account' 9import { AccountModel } from './account'
@@ -40,7 +41,7 @@ enum ScopeNames {
40 } 41 }
41 ] 42 ]
42}) 43})
43export class AccountBlocklistModel extends Model { 44export class AccountBlocklistModel extends Model<Partial<AttributesOnly<AccountBlocklistModel>>> {
44 45
45 @CreatedAt 46 @CreatedAt
46 createdAt: Date 47 createdAt: Date
diff --git a/server/models/account/account-video-rate.ts b/server/models/account/account-video-rate.ts
index 801f76bba..ee6dbc6da 100644
--- a/server/models/account/account-video-rate.ts
+++ b/server/models/account/account-video-rate.ts
@@ -7,11 +7,12 @@ import {
7 MAccountVideoRateAccountVideo, 7 MAccountVideoRateAccountVideo,
8 MAccountVideoRateFormattable 8 MAccountVideoRateFormattable
9} from '@server/types/models/video/video-rate' 9} from '@server/types/models/video/video-rate'
10import { AttributesOnly } from '@shared/core-utils'
10import { AccountVideoRate } from '../../../shared' 11import { AccountVideoRate } from '../../../shared'
11import { VideoRateType } from '../../../shared/models/videos' 12import { VideoRateType } from '../../../shared/models/videos'
12import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 13import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
13import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants' 14import { CONSTRAINTS_FIELDS, VIDEO_RATE_TYPES } from '../../initializers/constants'
14import { ActorModel } from '../activitypub/actor' 15import { ActorModel } from '../actor/actor'
15import { buildLocalAccountIdsIn, getSort, throwIfNotValid } from '../utils' 16import { buildLocalAccountIdsIn, getSort, throwIfNotValid } from '../utils'
16import { VideoModel } from '../video/video' 17import { VideoModel } from '../video/video'
17import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel' 18import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from '../video/video-channel'
@@ -42,7 +43,7 @@ import { AccountModel } from './account'
42 } 43 }
43 ] 44 ]
44}) 45})
45export class AccountVideoRateModel extends Model { 46export class AccountVideoRateModel extends Model<Partial<AttributesOnly<AccountVideoRateModel>>> {
46 47
47 @AllowNull(false) 48 @AllowNull(false)
48 @Column(DataType.ENUM(...values(VIDEO_RATE_TYPES))) 49 @Column(DataType.ENUM(...values(VIDEO_RATE_TYPES)))
diff --git a/server/models/account/account.ts b/server/models/account/account.ts
index d33353af7..665ecd595 100644
--- a/server/models/account/account.ts
+++ b/server/models/account/account.ts
@@ -17,10 +17,11 @@ import {
17 UpdatedAt 17 UpdatedAt
18} from 'sequelize-typescript' 18} from 'sequelize-typescript'
19import { ModelCache } from '@server/models/model-cache' 19import { ModelCache } from '@server/models/model-cache'
20import { AttributesOnly } from '@shared/core-utils'
20import { Account, AccountSummary } from '../../../shared/models/actors' 21import { Account, AccountSummary } from '../../../shared/models/actors'
21import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts' 22import { isAccountDescriptionValid } from '../../helpers/custom-validators/accounts'
22import { CONSTRAINTS_FIELDS, SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants' 23import { CONSTRAINTS_FIELDS, SERVER_ACTOR_NAME, WEBSERVER } from '../../initializers/constants'
23import { sendDeleteActor } from '../../lib/activitypub/send' 24import { sendDeleteActor } from '../../lib/activitypub/send/send-delete'
24import { 25import {
25 MAccount, 26 MAccount,
26 MAccountActor, 27 MAccountActor,
@@ -30,19 +31,19 @@ import {
30 MAccountSummaryFormattable, 31 MAccountSummaryFormattable,
31 MChannelActor 32 MChannelActor
32} from '../../types/models' 33} from '../../types/models'
33import { ActorModel } from '../activitypub/actor' 34import { ActorModel } from '../actor/actor'
34import { ActorFollowModel } from '../activitypub/actor-follow' 35import { ActorFollowModel } from '../actor/actor-follow'
36import { ActorImageModel } from '../actor/actor-image'
35import { ApplicationModel } from '../application/application' 37import { ApplicationModel } from '../application/application'
36import { ActorImageModel } from './actor-image'
37import { ServerModel } from '../server/server' 38import { ServerModel } from '../server/server'
38import { ServerBlocklistModel } from '../server/server-blocklist' 39import { ServerBlocklistModel } from '../server/server-blocklist'
40import { UserModel } from '../user/user'
39import { getSort, throwIfNotValid } from '../utils' 41import { getSort, throwIfNotValid } from '../utils'
40import { VideoModel } from '../video/video' 42import { VideoModel } from '../video/video'
41import { VideoChannelModel } from '../video/video-channel' 43import { VideoChannelModel } from '../video/video-channel'
42import { VideoCommentModel } from '../video/video-comment' 44import { VideoCommentModel } from '../video/video-comment'
43import { VideoPlaylistModel } from '../video/video-playlist' 45import { VideoPlaylistModel } from '../video/video-playlist'
44import { AccountBlocklistModel } from './account-blocklist' 46import { AccountBlocklistModel } from './account-blocklist'
45import { UserModel } from './user'
46 47
47export enum ScopeNames { 48export enum ScopeNames {
48 SUMMARY = 'SUMMARY' 49 SUMMARY = 'SUMMARY'
@@ -141,7 +142,7 @@ export type SummaryOptions = {
141 } 142 }
142 ] 143 ]
143}) 144})
144export class AccountModel extends Model { 145export class AccountModel extends Model<Partial<AttributesOnly<AccountModel>>> {
145 146
146 @AllowNull(false) 147 @AllowNull(false)
147 @Column 148 @Column
diff --git a/server/models/account/actor-custom-page.ts b/server/models/account/actor-custom-page.ts
new file mode 100644
index 000000000..893023181
--- /dev/null
+++ b/server/models/account/actor-custom-page.ts
@@ -0,0 +1,69 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { CustomPage } from '@shared/models'
3import { ActorModel } from '../actor/actor'
4import { getServerActor } from '../application/application'
5
6@Table({
7 tableName: 'actorCustomPage',
8 indexes: [
9 {
10 fields: [ 'actorId', 'type' ],
11 unique: true
12 }
13 ]
14})
15export class ActorCustomPageModel extends Model {
16
17 @AllowNull(true)
18 @Column(DataType.TEXT)
19 content: string
20
21 @AllowNull(false)
22 @Column
23 type: 'homepage'
24
25 @CreatedAt
26 createdAt: Date
27
28 @UpdatedAt
29 updatedAt: Date
30
31 @ForeignKey(() => ActorModel)
32 @Column
33 actorId: number
34
35 @BelongsTo(() => ActorModel, {
36 foreignKey: {
37 name: 'actorId',
38 allowNull: false
39 },
40 onDelete: 'cascade'
41 })
42 Actor: ActorModel
43
44 static async updateInstanceHomepage (content: string) {
45 const serverActor = await getServerActor()
46
47 return ActorCustomPageModel.upsert({
48 content,
49 actorId: serverActor.id,
50 type: 'homepage'
51 })
52 }
53
54 static async loadInstanceHomepage () {
55 const serverActor = await getServerActor()
56
57 return ActorCustomPageModel.findOne({
58 where: {
59 actorId: serverActor.id
60 }
61 })
62 }
63
64 toFormattedJSON (): CustomPage {
65 return {
66 content: this.content
67 }
68 }
69}
diff --git a/server/models/activitypub/actor-follow.ts b/server/models/actor/actor-follow.ts
index 4c5f37620..3a09e51d6 100644
--- a/server/models/activitypub/actor-follow.ts
+++ b/server/models/actor/actor-follow.ts
@@ -28,6 +28,7 @@ import {
28 MActorFollowFormattable, 28 MActorFollowFormattable,
29 MActorFollowSubscriptions 29 MActorFollowSubscriptions
30} from '@server/types/models' 30} from '@server/types/models'
31import { AttributesOnly } from '@shared/core-utils'
31import { ActivityPubActorType } from '@shared/models' 32import { ActivityPubActorType } from '@shared/models'
32import { FollowState } from '../../../shared/models/actors' 33import { FollowState } from '../../../shared/models/actors'
33import { ActorFollow } from '../../../shared/models/actors/follow.model' 34import { ActorFollow } from '../../../shared/models/actors/follow.model'
@@ -61,7 +62,7 @@ import { ActorModel, unusedActorAttributesForAPI } from './actor'
61 } 62 }
62 ] 63 ]
63}) 64})
64export class ActorFollowModel extends Model { 65export class ActorFollowModel extends Model<Partial<AttributesOnly<ActorFollowModel>>> {
65 66
66 @AllowNull(false) 67 @AllowNull(false)
67 @Column(DataType.ENUM(...values(FOLLOW_STATES))) 68 @Column(DataType.ENUM(...values(FOLLOW_STATES)))
@@ -619,7 +620,7 @@ export class ActorFollowModel extends Model {
619 if (serverIds.length === 0) return 620 if (serverIds.length === 0) return
620 621
621 const me = await getServerActor() 622 const me = await getServerActor()
622 const serverIdsString = createSafeIn(ActorFollowModel, serverIds) 623 const serverIdsString = createSafeIn(ActorFollowModel.sequelize, serverIds)
623 624
624 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` + 625 const query = `UPDATE "actorFollow" SET "score" = LEAST("score" + ${value}, ${ACTOR_FOLLOW_SCORE.MAX}) ` +
625 'WHERE id IN (' + 626 'WHERE id IN (' +
diff --git a/server/models/account/actor-image.ts b/server/models/actor/actor-image.ts
index ae05b4969..98a7f6fba 100644
--- a/server/models/account/actor-image.ts
+++ b/server/models/actor/actor-image.ts
@@ -2,6 +2,7 @@ import { remove } from 'fs-extra'
2import { join } from 'path' 2import { join } from 'path'
3import { AfterDestroy, AllowNull, Column, CreatedAt, Default, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 3import { AfterDestroy, AllowNull, Column, CreatedAt, Default, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
4import { MActorImageFormattable } from '@server/types/models' 4import { MActorImageFormattable } from '@server/types/models'
5import { AttributesOnly } from '@shared/core-utils'
5import { ActorImageType } from '@shared/models' 6import { ActorImageType } from '@shared/models'
6import { ActorImage } from '../../../shared/models/actors/actor-image.model' 7import { ActorImage } from '../../../shared/models/actors/actor-image.model'
7import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 8import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
@@ -19,7 +20,7 @@ import { throwIfNotValid } from '../utils'
19 } 20 }
20 ] 21 ]
21}) 22})
22export class ActorImageModel extends Model { 23export class ActorImageModel extends Model<Partial<AttributesOnly<ActorImageModel>>> {
23 24
24 @AllowNull(false) 25 @AllowNull(false)
25 @Column 26 @Column
@@ -97,4 +98,8 @@ export class ActorImageModel extends Model {
97 const imagePath = join(CONFIG.STORAGE.ACTOR_IMAGES, this.filename) 98 const imagePath = join(CONFIG.STORAGE.ACTOR_IMAGES, this.filename)
98 return remove(imagePath) 99 return remove(imagePath)
99 } 100 }
101
102 isOwned () {
103 return !this.fileUrl
104 }
100} 105}
diff --git a/server/models/activitypub/actor.ts b/server/models/actor/actor.ts
index 1af9efac2..8df49951d 100644
--- a/server/models/activitypub/actor.ts
+++ b/server/models/actor/actor.ts
@@ -1,5 +1,4 @@
1import { values } from 'lodash' 1import { values } from 'lodash'
2import { extname } from 'path'
3import { literal, Op, Transaction } from 'sequelize' 2import { literal, Op, Transaction } from 'sequelize'
4import { 3import {
5 AllowNull, 4 AllowNull,
@@ -17,7 +16,9 @@ import {
17 Table, 16 Table,
18 UpdatedAt 17 UpdatedAt
19} from 'sequelize-typescript' 18} from 'sequelize-typescript'
19import { getLowercaseExtension } from '@server/helpers/core-utils'
20import { ModelCache } from '@server/models/model-cache' 20import { ModelCache } from '@server/models/model-cache'
21import { AttributesOnly } from '@shared/core-utils'
21import { ActivityIconObject, ActivityPubActorType } from '../../../shared/models/activitypub' 22import { ActivityIconObject, ActivityPubActorType } from '../../../shared/models/activitypub'
22import { ActorImage } from '../../../shared/models/actors/actor-image.model' 23import { ActorImage } from '../../../shared/models/actors/actor-image.model'
23import { activityPubContextify } from '../../helpers/activitypub' 24import { activityPubContextify } from '../../helpers/activitypub'
@@ -51,12 +52,12 @@ import {
51 MActorWithInboxes 52 MActorWithInboxes
52} from '../../types/models' 53} from '../../types/models'
53import { AccountModel } from '../account/account' 54import { AccountModel } from '../account/account'
54import { ActorImageModel } from '../account/actor-image'
55import { ServerModel } from '../server/server' 55import { ServerModel } from '../server/server'
56import { isOutdated, throwIfNotValid } from '../utils' 56import { isOutdated, throwIfNotValid } from '../utils'
57import { VideoModel } from '../video/video' 57import { VideoModel } from '../video/video'
58import { VideoChannelModel } from '../video/video-channel' 58import { VideoChannelModel } from '../video/video-channel'
59import { ActorFollowModel } from './actor-follow' 59import { ActorFollowModel } from './actor-follow'
60import { ActorImageModel } from './actor-image'
60 61
61enum ScopeNames { 62enum ScopeNames {
62 FULL = 'FULL' 63 FULL = 'FULL'
@@ -159,7 +160,7 @@ export const unusedActorAttributesForAPI = [
159 } 160 }
160 ] 161 ]
161}) 162})
162export class ActorModel extends Model { 163export class ActorModel extends Model<Partial<AttributesOnly<ActorModel>>> {
163 164
164 @AllowNull(false) 165 @AllowNull(false)
165 @Column(DataType.ENUM(...values(ACTIVITY_PUB_ACTOR_TYPES))) 166 @Column(DataType.ENUM(...values(ACTIVITY_PUB_ACTOR_TYPES)))
@@ -495,7 +496,7 @@ export class ActorModel extends Model {
495 }, { where, transaction }) 496 }, { where, transaction })
496 } 497 }
497 498
498 static loadAccountActorByVideoId (videoId: number): Promise<MActor> { 499 static loadAccountActorByVideoId (videoId: number, transaction: Transaction): Promise<MActor> {
499 const query = { 500 const query = {
500 include: [ 501 include: [
501 { 502 {
@@ -519,7 +520,8 @@ export class ActorModel extends Model {
519 } 520 }
520 ] 521 ]
521 } 522 }
522 ] 523 ],
524 transaction
523 } 525 }
524 526
525 return ActorModel.unscoped().findOne(query) 527 return ActorModel.unscoped().findOne(query)
@@ -566,7 +568,7 @@ export class ActorModel extends Model {
566 let image: ActivityIconObject 568 let image: ActivityIconObject
567 569
568 if (this.avatarId) { 570 if (this.avatarId) {
569 const extension = extname(this.Avatar.filename) 571 const extension = getLowercaseExtension(this.Avatar.filename)
570 572
571 icon = { 573 icon = {
572 type: 'Image', 574 type: 'Image',
@@ -579,7 +581,7 @@ export class ActorModel extends Model {
579 581
580 if (this.bannerId) { 582 if (this.bannerId) {
581 const banner = (this as MActorAPChannel).Banner 583 const banner = (this as MActorAPChannel).Banner
582 const extension = extname(banner.filename) 584 const extension = getLowercaseExtension(banner.filename)
583 585
584 image = { 586 image = {
585 type: 'Image', 587 type: 'Image',
diff --git a/server/models/application/application.ts b/server/models/application/application.ts
index 21f8b1cbc..5531d134a 100644
--- a/server/models/application/application.ts
+++ b/server/models/application/application.ts
@@ -1,6 +1,7 @@
1import * as memoizee from 'memoizee'
1import { AllowNull, Column, Default, DefaultScope, HasOne, IsInt, Model, Table } from 'sequelize-typescript' 2import { AllowNull, Column, Default, DefaultScope, HasOne, IsInt, Model, Table } from 'sequelize-typescript'
3import { AttributesOnly } from '@shared/core-utils'
2import { AccountModel } from '../account/account' 4import { AccountModel } from '../account/account'
3import * as memoizee from 'memoizee'
4 5
5export const getServerActor = memoizee(async function () { 6export const getServerActor = memoizee(async function () {
6 const application = await ApplicationModel.load() 7 const application = await ApplicationModel.load()
@@ -24,7 +25,7 @@ export const getServerActor = memoizee(async function () {
24 tableName: 'application', 25 tableName: 'application',
25 timestamps: false 26 timestamps: false
26}) 27})
27export class ApplicationModel extends Model { 28export class ApplicationModel extends Model<Partial<AttributesOnly<ApplicationModel>>> {
28 29
29 @AllowNull(false) 30 @AllowNull(false)
30 @Default(0) 31 @Default(0)
diff --git a/server/models/oauth/oauth-client.ts b/server/models/oauth/oauth-client.ts
index 8dbc1c2f5..890954bdb 100644
--- a/server/models/oauth/oauth-client.ts
+++ b/server/models/oauth/oauth-client.ts
@@ -1,4 +1,5 @@
1import { AllowNull, Column, CreatedAt, DataType, HasMany, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, Column, CreatedAt, DataType, HasMany, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { AttributesOnly } from '@shared/core-utils'
2import { OAuthTokenModel } from './oauth-token' 3import { OAuthTokenModel } from './oauth-token'
3 4
4@Table({ 5@Table({
@@ -14,7 +15,7 @@ import { OAuthTokenModel } from './oauth-token'
14 } 15 }
15 ] 16 ]
16}) 17})
17export class OAuthClientModel extends Model { 18export class OAuthClientModel extends Model<Partial<AttributesOnly<OAuthClientModel>>> {
18 19
19 @AllowNull(false) 20 @AllowNull(false)
20 @Column 21 @Column
diff --git a/server/models/oauth/oauth-token.ts b/server/models/oauth/oauth-token.ts
index 27e643aa7..af4b0ec42 100644
--- a/server/models/oauth/oauth-token.ts
+++ b/server/models/oauth/oauth-token.ts
@@ -15,10 +15,11 @@ import {
15import { TokensCache } from '@server/lib/auth/tokens-cache' 15import { TokensCache } from '@server/lib/auth/tokens-cache'
16import { MUserAccountId } from '@server/types/models' 16import { MUserAccountId } from '@server/types/models'
17import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' 17import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
18import { AttributesOnly } from '@shared/core-utils'
18import { logger } from '../../helpers/logger' 19import { logger } from '../../helpers/logger'
19import { AccountModel } from '../account/account' 20import { AccountModel } from '../account/account'
20import { UserModel } from '../account/user' 21import { ActorModel } from '../actor/actor'
21import { ActorModel } from '../activitypub/actor' 22import { UserModel } from '../user/user'
22import { OAuthClientModel } from './oauth-client' 23import { OAuthClientModel } from './oauth-client'
23 24
24export type OAuthTokenInfo = { 25export type OAuthTokenInfo = {
@@ -78,7 +79,7 @@ enum ScopeNames {
78 } 79 }
79 ] 80 ]
80}) 81})
81export class OAuthTokenModel extends Model { 82export class OAuthTokenModel extends Model<Partial<AttributesOnly<OAuthTokenModel>>> {
82 83
83 @AllowNull(false) 84 @AllowNull(false)
84 @Column 85 @Column
diff --git a/server/models/redundancy/video-redundancy.ts b/server/models/redundancy/video-redundancy.ts
index 349dba513..ccda023e0 100644
--- a/server/models/redundancy/video-redundancy.ts
+++ b/server/models/redundancy/video-redundancy.ts
@@ -16,6 +16,7 @@ import {
16} from 'sequelize-typescript' 16} from 'sequelize-typescript'
17import { getServerActor } from '@server/models/application/application' 17import { getServerActor } from '@server/models/application/application'
18import { MActor, MVideoForRedundancyAPI, MVideoRedundancy, MVideoRedundancyAP, MVideoRedundancyVideo } from '@server/types/models' 18import { MActor, MVideoForRedundancyAPI, MVideoRedundancy, MVideoRedundancyAP, MVideoRedundancyVideo } from '@server/types/models'
19import { AttributesOnly } from '@shared/core-utils'
19import { VideoRedundanciesTarget } from '@shared/models/redundancy/video-redundancies-filters.model' 20import { VideoRedundanciesTarget } from '@shared/models/redundancy/video-redundancies-filters.model'
20import { 21import {
21 FileRedundancyInformation, 22 FileRedundancyInformation,
@@ -29,7 +30,7 @@ import { isActivityPubUrlValid, isUrlValid } from '../../helpers/custom-validato
29import { logger } from '../../helpers/logger' 30import { logger } from '../../helpers/logger'
30import { CONFIG } from '../../initializers/config' 31import { CONFIG } from '../../initializers/config'
31import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants' 32import { CONSTRAINTS_FIELDS, MIMETYPES } from '../../initializers/constants'
32import { ActorModel } from '../activitypub/actor' 33import { ActorModel } from '../actor/actor'
33import { ServerModel } from '../server/server' 34import { ServerModel } from '../server/server'
34import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils' 35import { getSort, getVideoSort, parseAggregateResult, throwIfNotValid } from '../utils'
35import { ScheduleVideoUpdateModel } from '../video/schedule-video-update' 36import { ScheduleVideoUpdateModel } from '../video/schedule-video-update'
@@ -79,12 +80,15 @@ export enum ScopeNames {
79 fields: [ 'actorId' ] 80 fields: [ 'actorId' ]
80 }, 81 },
81 { 82 {
83 fields: [ 'expiresOn' ]
84 },
85 {
82 fields: [ 'url' ], 86 fields: [ 'url' ],
83 unique: true 87 unique: true
84 } 88 }
85 ] 89 ]
86}) 90})
87export class VideoRedundancyModel extends Model { 91export class VideoRedundancyModel extends Model<Partial<AttributesOnly<VideoRedundancyModel>>> {
88 92
89 @CreatedAt 93 @CreatedAt
90 createdAt: Date 94 createdAt: Date
diff --git a/server/models/server/plugin.ts b/server/models/server/plugin.ts
index 80c8a6be5..a8de64dd4 100644
--- a/server/models/server/plugin.ts
+++ b/server/models/server/plugin.ts
@@ -1,9 +1,8 @@
1import { FindAndCountOptions, json, QueryTypes } from 'sequelize' 1import { FindAndCountOptions, json, QueryTypes } from 'sequelize'
2import { AllowNull, Column, CreatedAt, DataType, DefaultScope, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, Column, CreatedAt, DataType, DefaultScope, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MPlugin, MPluginFormattable } from '@server/types/models' 3import { MPlugin, MPluginFormattable } from '@server/types/models'
4import { PeerTubePlugin } from '../../../shared/models/plugins/peertube-plugin.model' 4import { AttributesOnly } from '@shared/core-utils'
5import { PluginType } from '../../../shared/models/plugins/plugin.type' 5import { PeerTubePlugin, PluginType, RegisterServerSettingOptions } from '../../../shared/models'
6import { RegisterServerSettingOptions } from '../../../shared/models/plugins/register-server-setting.model'
7import { 6import {
8 isPluginDescriptionValid, 7 isPluginDescriptionValid,
9 isPluginHomepage, 8 isPluginHomepage,
@@ -28,7 +27,7 @@ import { getSort, throwIfNotValid } from '../utils'
28 } 27 }
29 ] 28 ]
30}) 29})
31export class PluginModel extends Model { 30export class PluginModel extends Model<Partial<AttributesOnly<PluginModel>>> {
32 31
33 @AllowNull(false) 32 @AllowNull(false)
34 @Is('PluginName', value => throwIfNotValid(value, isPluginNameValid, 'name')) 33 @Is('PluginName', value => throwIfNotValid(value, isPluginNameValid, 'name'))
diff --git a/server/models/server/server-blocklist.ts b/server/models/server/server-blocklist.ts
index 4dc236537..b3579d589 100644
--- a/server/models/server/server-blocklist.ts
+++ b/server/models/server/server-blocklist.ts
@@ -1,6 +1,7 @@
1import { Op } from 'sequelize' 1import { Op } from 'sequelize'
2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 2import { BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models' 3import { MServerBlocklist, MServerBlocklistAccountServer, MServerBlocklistFormattable } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils'
4import { ServerBlock } from '@shared/models' 5import { ServerBlock } from '@shared/models'
5import { AccountModel } from '../account/account' 6import { AccountModel } from '../account/account'
6import { getSort, searchAttribute } from '../utils' 7import { getSort, searchAttribute } from '../utils'
@@ -42,7 +43,7 @@ enum ScopeNames {
42 } 43 }
43 ] 44 ]
44}) 45})
45export class ServerBlocklistModel extends Model { 46export class ServerBlocklistModel extends Model<Partial<AttributesOnly<ServerBlocklistModel>>> {
46 47
47 @CreatedAt 48 @CreatedAt
48 createdAt: Date 49 createdAt: Date
diff --git a/server/models/server/server.ts b/server/models/server/server.ts
index 0e58beeaf..0d3c092e0 100644
--- a/server/models/server/server.ts
+++ b/server/models/server/server.ts
@@ -1,7 +1,9 @@
1import { Transaction } from 'sequelize'
1import { AllowNull, Column, CreatedAt, Default, HasMany, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, Column, CreatedAt, Default, HasMany, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { MServer, MServerFormattable } from '@server/types/models/server' 3import { MServer, MServerFormattable } from '@server/types/models/server'
4import { AttributesOnly } from '@shared/core-utils'
3import { isHostValid } from '../../helpers/custom-validators/servers' 5import { isHostValid } from '../../helpers/custom-validators/servers'
4import { ActorModel } from '../activitypub/actor' 6import { ActorModel } from '../actor/actor'
5import { throwIfNotValid } from '../utils' 7import { throwIfNotValid } from '../utils'
6import { ServerBlocklistModel } from './server-blocklist' 8import { ServerBlocklistModel } from './server-blocklist'
7 9
@@ -14,7 +16,7 @@ import { ServerBlocklistModel } from './server-blocklist'
14 } 16 }
15 ] 17 ]
16}) 18})
17export class ServerModel extends Model { 19export class ServerModel extends Model<Partial<AttributesOnly<ServerModel>>> {
18 20
19 @AllowNull(false) 21 @AllowNull(false)
20 @Is('Host', value => throwIfNotValid(value, isHostValid, 'valid host')) 22 @Is('Host', value => throwIfNotValid(value, isHostValid, 'valid host'))
@@ -50,11 +52,12 @@ export class ServerModel extends Model {
50 }) 52 })
51 BlockedByAccounts: ServerBlocklistModel[] 53 BlockedByAccounts: ServerBlocklistModel[]
52 54
53 static load (id: number): Promise<MServer> { 55 static load (id: number, transaction?: Transaction): Promise<MServer> {
54 const query = { 56 const query = {
55 where: { 57 where: {
56 id 58 id
57 } 59 },
60 transaction
58 } 61 }
59 62
60 return ServerModel.findOne(query) 63 return ServerModel.findOne(query)
diff --git a/server/models/server/tracker.ts b/server/models/server/tracker.ts
index 97520f92d..c09fdd64b 100644
--- a/server/models/server/tracker.ts
+++ b/server/models/server/tracker.ts
@@ -1,6 +1,7 @@
1import { AllowNull, BelongsToMany, Column, CreatedAt, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsToMany, Column, CreatedAt, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { Transaction } from 'sequelize/types' 2import { Transaction } from 'sequelize/types'
3import { MTracker } from '@server/types/models/server/tracker' 3import { MTracker } from '@server/types/models/server/tracker'
4import { AttributesOnly } from '@shared/core-utils'
4import { VideoModel } from '../video/video' 5import { VideoModel } from '../video/video'
5import { VideoTrackerModel } from './video-tracker' 6import { VideoTrackerModel } from './video-tracker'
6 7
@@ -13,7 +14,7 @@ import { VideoTrackerModel } from './video-tracker'
13 } 14 }
14 ] 15 ]
15}) 16})
16export class TrackerModel extends Model { 17export class TrackerModel extends Model<Partial<AttributesOnly<TrackerModel>>> {
17 18
18 @AllowNull(false) 19 @AllowNull(false)
19 @Column 20 @Column
diff --git a/server/models/server/video-tracker.ts b/server/models/server/video-tracker.ts
index 367bf0117..c49fbd1c6 100644
--- a/server/models/server/video-tracker.ts
+++ b/server/models/server/video-tracker.ts
@@ -1,4 +1,5 @@
1import { Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { AttributesOnly } from '@shared/core-utils'
2import { VideoModel } from '../video/video' 3import { VideoModel } from '../video/video'
3import { TrackerModel } from './tracker' 4import { TrackerModel } from './tracker'
4 5
@@ -13,7 +14,7 @@ import { TrackerModel } from './tracker'
13 } 14 }
14 ] 15 ]
15}) 16})
16export class VideoTrackerModel extends Model { 17export class VideoTrackerModel extends Model<Partial<AttributesOnly<VideoTrackerModel>>> {
17 @CreatedAt 18 @CreatedAt
18 createdAt: Date 19 createdAt: Date
19 20
diff --git a/server/models/account/user-notification-setting.ts b/server/models/user/user-notification-setting.ts
index 138051528..bee7d7851 100644
--- a/server/models/account/user-notification-setting.ts
+++ b/server/models/user/user-notification-setting.ts
@@ -14,6 +14,7 @@ import {
14} from 'sequelize-typescript' 14} from 'sequelize-typescript'
15import { TokensCache } from '@server/lib/auth/tokens-cache' 15import { TokensCache } from '@server/lib/auth/tokens-cache'
16import { MNotificationSettingFormattable } from '@server/types/models' 16import { MNotificationSettingFormattable } from '@server/types/models'
17import { AttributesOnly } from '@shared/core-utils'
17import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model' 18import { UserNotificationSetting, UserNotificationSettingValue } from '../../../shared/models/users/user-notification-setting.model'
18import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications' 19import { isUserNotificationSettingValid } from '../../helpers/custom-validators/user-notifications'
19import { throwIfNotValid } from '../utils' 20import { throwIfNotValid } from '../utils'
@@ -28,7 +29,7 @@ import { UserModel } from './user'
28 } 29 }
29 ] 30 ]
30}) 31})
31export class UserNotificationSettingModel extends Model { 32export class UserNotificationSettingModel extends Model<Partial<AttributesOnly<UserNotificationSettingModel>>> {
32 33
33 @AllowNull(false) 34 @AllowNull(false)
34 @Default(null) 35 @Default(null)
diff --git a/server/models/account/user-notification.ts b/server/models/user/user-notification.ts
index 805095002..a7f84e9ca 100644
--- a/server/models/account/user-notification.ts
+++ b/server/models/user/user-notification.ts
@@ -1,14 +1,17 @@
1import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize' 1import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user' 3import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user'
4import { AttributesOnly } from '@shared/core-utils'
4import { UserNotification, UserNotificationType } from '../../../shared' 5import { UserNotification, UserNotificationType } from '../../../shared'
5import { isBooleanValid } from '../../helpers/custom-validators/misc' 6import { isBooleanValid } from '../../helpers/custom-validators/misc'
6import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications' 7import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
7import { AbuseModel } from '../abuse/abuse' 8import { AbuseModel } from '../abuse/abuse'
8import { VideoAbuseModel } from '../abuse/video-abuse' 9import { VideoAbuseModel } from '../abuse/video-abuse'
9import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse' 10import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
10import { ActorModel } from '../activitypub/actor' 11import { AccountModel } from '../account/account'
11import { ActorFollowModel } from '../activitypub/actor-follow' 12import { ActorModel } from '../actor/actor'
13import { ActorFollowModel } from '../actor/actor-follow'
14import { ActorImageModel } from '../actor/actor-image'
12import { ApplicationModel } from '../application/application' 15import { ApplicationModel } from '../application/application'
13import { PluginModel } from '../server/plugin' 16import { PluginModel } from '../server/plugin'
14import { ServerModel } from '../server/server' 17import { ServerModel } from '../server/server'
@@ -18,8 +21,6 @@ import { VideoBlacklistModel } from '../video/video-blacklist'
18import { VideoChannelModel } from '../video/video-channel' 21import { VideoChannelModel } from '../video/video-channel'
19import { VideoCommentModel } from '../video/video-comment' 22import { VideoCommentModel } from '../video/video-comment'
20import { VideoImportModel } from '../video/video-import' 23import { VideoImportModel } from '../video/video-import'
21import { AccountModel } from './account'
22import { ActorImageModel } from './actor-image'
23import { UserModel } from './user' 24import { UserModel } from './user'
24 25
25enum ScopeNames { 26enum ScopeNames {
@@ -286,7 +287,7 @@ function buildAccountInclude (required: boolean, withActor = false) {
286 } 287 }
287 ] as (ModelIndexesOptions & { where?: WhereOptions })[] 288 ] as (ModelIndexesOptions & { where?: WhereOptions })[]
288}) 289})
289export class UserNotificationModel extends Model { 290export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNotificationModel>>> {
290 291
291 @AllowNull(false) 292 @AllowNull(false)
292 @Default(null) 293 @Default(null)
diff --git a/server/models/account/user-video-history.ts b/server/models/user/user-video-history.ts
index 6be1d65ea..e3dc4a062 100644
--- a/server/models/account/user-video-history.ts
+++ b/server/models/user/user-video-history.ts
@@ -1,8 +1,9 @@
1import { DestroyOptions, Op, Transaction } from 'sequelize'
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Model, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, IsInt, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MUserAccountId, MUserId } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils'
2import { VideoModel } from '../video/video' 5import { VideoModel } from '../video/video'
3import { UserModel } from './user' 6import { UserModel } from './user'
4import { DestroyOptions, Op, Transaction } from 'sequelize'
5import { MUserAccountId, MUserId } from '@server/types/models'
6 7
7@Table({ 8@Table({
8 tableName: 'userVideoHistory', 9 tableName: 'userVideoHistory',
@@ -19,7 +20,7 @@ import { MUserAccountId, MUserId } from '@server/types/models'
19 } 20 }
20 ] 21 ]
21}) 22})
22export class UserVideoHistoryModel extends Model { 23export class UserVideoHistoryModel extends Model<Partial<AttributesOnly<UserVideoHistoryModel>>> {
23 @CreatedAt 24 @CreatedAt
24 createdAt: Date 25 createdAt: Date
25 26
diff --git a/server/models/account/user.ts b/server/models/user/user.ts
index 513455773..20696b1f4 100644
--- a/server/models/account/user.ts
+++ b/server/models/user/user.ts
@@ -31,6 +31,7 @@ import {
31 MUserWithNotificationSetting, 31 MUserWithNotificationSetting,
32 MVideoWithRights 32 MVideoWithRights
33} from '@server/types/models' 33} from '@server/types/models'
34import { AttributesOnly } from '@shared/core-utils'
34import { hasUserRight, USER_ROLE_LABELS } from '../../../shared/core-utils/users' 35import { hasUserRight, USER_ROLE_LABELS } from '../../../shared/core-utils/users'
35import { AbuseState, MyUser, UserRight, VideoPlaylistType, VideoPrivacy } from '../../../shared/models' 36import { AbuseState, MyUser, UserRight, VideoPlaylistType, VideoPrivacy } from '../../../shared/models'
36import { User, UserRole } from '../../../shared/models/users' 37import { User, UserRole } from '../../../shared/models/users'
@@ -60,8 +61,10 @@ import {
60import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto' 61import { comparePassword, cryptPassword } from '../../helpers/peertube-crypto'
61import { DEFAULT_USER_THEME_NAME, NSFW_POLICY_TYPES } from '../../initializers/constants' 62import { DEFAULT_USER_THEME_NAME, NSFW_POLICY_TYPES } from '../../initializers/constants'
62import { getThemeOrDefault } from '../../lib/plugins/theme-utils' 63import { getThemeOrDefault } from '../../lib/plugins/theme-utils'
63import { ActorModel } from '../activitypub/actor' 64import { AccountModel } from '../account/account'
64import { ActorFollowModel } from '../activitypub/actor-follow' 65import { ActorModel } from '../actor/actor'
66import { ActorFollowModel } from '../actor/actor-follow'
67import { ActorImageModel } from '../actor/actor-image'
65import { OAuthTokenModel } from '../oauth/oauth-token' 68import { OAuthTokenModel } from '../oauth/oauth-token'
66import { getSort, throwIfNotValid } from '../utils' 69import { getSort, throwIfNotValid } from '../utils'
67import { VideoModel } from '../video/video' 70import { VideoModel } from '../video/video'
@@ -69,9 +72,7 @@ import { VideoChannelModel } from '../video/video-channel'
69import { VideoImportModel } from '../video/video-import' 72import { VideoImportModel } from '../video/video-import'
70import { VideoLiveModel } from '../video/video-live' 73import { VideoLiveModel } from '../video/video-live'
71import { VideoPlaylistModel } from '../video/video-playlist' 74import { VideoPlaylistModel } from '../video/video-playlist'
72import { AccountModel } from './account'
73import { UserNotificationSettingModel } from './user-notification-setting' 75import { UserNotificationSettingModel } from './user-notification-setting'
74import { ActorImageModel } from './actor-image'
75 76
76enum ScopeNames { 77enum ScopeNames {
77 FOR_ME_API = 'FOR_ME_API', 78 FOR_ME_API = 'FOR_ME_API',
@@ -233,7 +234,7 @@ enum ScopeNames {
233 } 234 }
234 ] 235 ]
235}) 236})
236export class UserModel extends Model { 237export class UserModel extends Model<Partial<AttributesOnly<UserModel>>> {
237 238
238 @AllowNull(true) 239 @AllowNull(true)
239 @Is('UserPassword', value => throwIfNotValid(value, isUserPasswordValid, 'user password', true)) 240 @Is('UserPassword', value => throwIfNotValid(value, isUserPasswordValid, 'user password', true))
diff --git a/server/models/utils.ts b/server/models/utils.ts
index ec51c66bf..83b2b8f03 100644
--- a/server/models/utils.ts
+++ b/server/models/utils.ts
@@ -1,5 +1,4 @@
1import { literal, Op, OrderItem } from 'sequelize' 1import { literal, Op, OrderItem, Sequelize } from 'sequelize'
2import { Model, Sequelize } from 'sequelize-typescript'
3import { Col } from 'sequelize/types/lib/utils' 2import { Col } from 'sequelize/types/lib/utils'
4import validator from 'validator' 3import validator from 'validator'
5 4
@@ -103,6 +102,10 @@ function getFollowsSort (value: string, lastSort: OrderItem = [ 'id', 'ASC' ]):
103} 102}
104 103
105function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) { 104function isOutdated (model: { createdAt: Date, updatedAt: Date }, refreshInterval: number) {
105 if (!model.createdAt || !model.updatedAt) {
106 throw new Error('Miss createdAt & updatedAt attribuets to model')
107 }
108
106 const now = Date.now() 109 const now = Date.now()
107 const createdAtTime = model.createdAt.getTime() 110 const createdAtTime = model.createdAt.getTime()
108 const updatedAtTime = model.updatedAt.getTime() 111 const updatedAtTime = model.updatedAt.getTime()
@@ -195,11 +198,11 @@ function parseAggregateResult (result: any) {
195 return total 198 return total
196} 199}
197 200
198const createSafeIn = (model: typeof Model, stringArr: (string | number)[]) => { 201function createSafeIn (sequelize: Sequelize, stringArr: (string | number)[]) {
199 return stringArr.map(t => { 202 return stringArr.map(t => {
200 return t === null 203 return t === null
201 ? null 204 ? null
202 : model.sequelize.escape('' + t) 205 : sequelize.escape('' + t)
203 }).join(', ') 206 }).join(', ')
204} 207}
205 208
diff --git a/server/models/video/video-format-utils.ts b/server/models/video/formatter/video-format-utils.ts
index 551cb2842..6b1e59063 100644
--- a/server/models/video/video-format-utils.ts
+++ b/server/models/video/formatter/video-format-utils.ts
@@ -1,17 +1,26 @@
1import { uuidToShort } from '@server/helpers/uuid'
1import { generateMagnetUri } from '@server/helpers/webtorrent' 2import { generateMagnetUri } from '@server/helpers/webtorrent'
2import { getLocalVideoFileMetadataUrl } from '@server/lib/video-paths' 3import { getLocalVideoFileMetadataUrl } from '@server/lib/video-paths'
3import { VideoFile } from '@shared/models/videos/video-file.model' 4import { VideoFile } from '@shared/models/videos/video-file.model'
4import { ActivityTagObject, ActivityUrlObject, VideoObject } from '../../../shared/models/activitypub/objects' 5import { ActivityTagObject, ActivityUrlObject, VideoObject } from '../../../../shared/models/activitypub/objects'
5import { Video, VideoDetails } from '../../../shared/models/videos' 6import { Video, VideoDetails } from '../../../../shared/models/videos'
6import { VideoStreamingPlaylist } from '../../../shared/models/videos/video-streaming-playlist.model' 7import { VideoStreamingPlaylist } from '../../../../shared/models/videos/video-streaming-playlist.model'
7import { isArray } from '../../helpers/custom-validators/misc' 8import { isArray } from '../../../helpers/custom-validators/misc'
8import { MIMETYPES, WEBSERVER } from '../../initializers/constants' 9import {
10 MIMETYPES,
11 VIDEO_CATEGORIES,
12 VIDEO_LANGUAGES,
13 VIDEO_LICENCES,
14 VIDEO_PRIVACIES,
15 VIDEO_STATES,
16 WEBSERVER
17} from '../../../initializers/constants'
9import { 18import {
10 getLocalVideoCommentsActivityPubUrl, 19 getLocalVideoCommentsActivityPubUrl,
11 getLocalVideoDislikesActivityPubUrl, 20 getLocalVideoDislikesActivityPubUrl,
12 getLocalVideoLikesActivityPubUrl, 21 getLocalVideoLikesActivityPubUrl,
13 getLocalVideoSharesActivityPubUrl 22 getLocalVideoSharesActivityPubUrl
14} from '../../lib/activitypub/url' 23} from '../../../lib/activitypub/url'
15import { 24import {
16 MStreamingPlaylistRedundanciesOpt, 25 MStreamingPlaylistRedundanciesOpt,
17 MVideo, 26 MVideo,
@@ -19,10 +28,9 @@ import {
19 MVideoFile, 28 MVideoFile,
20 MVideoFormattable, 29 MVideoFormattable,
21 MVideoFormattableDetails 30 MVideoFormattableDetails
22} from '../../types/models' 31} from '../../../types/models'
23import { MVideoFileRedundanciesOpt } from '../../types/models/video/video-file' 32import { MVideoFileRedundanciesOpt } from '../../../types/models/video/video-file'
24import { VideoModel } from './video' 33import { VideoCaptionModel } from '../video-caption'
25import { VideoCaptionModel } from './video-caption'
26 34
27export type VideoFormattingJSONOptions = { 35export type VideoFormattingJSONOptions = {
28 completeDescription?: boolean 36 completeDescription?: boolean
@@ -40,22 +48,24 @@ function videoModelToFormattedJSON (video: MVideoFormattable, options?: VideoFor
40 const videoObject: Video = { 48 const videoObject: Video = {
41 id: video.id, 49 id: video.id,
42 uuid: video.uuid, 50 uuid: video.uuid,
51 shortUUID: uuidToShort(video.uuid),
52
43 name: video.name, 53 name: video.name,
44 category: { 54 category: {
45 id: video.category, 55 id: video.category,
46 label: VideoModel.getCategoryLabel(video.category) 56 label: getCategoryLabel(video.category)
47 }, 57 },
48 licence: { 58 licence: {
49 id: video.licence, 59 id: video.licence,
50 label: VideoModel.getLicenceLabel(video.licence) 60 label: getLicenceLabel(video.licence)
51 }, 61 },
52 language: { 62 language: {
53 id: video.language, 63 id: video.language,
54 label: VideoModel.getLanguageLabel(video.language) 64 label: getLanguageLabel(video.language)
55 }, 65 },
56 privacy: { 66 privacy: {
57 id: video.privacy, 67 id: video.privacy,
58 label: VideoModel.getPrivacyLabel(video.privacy) 68 label: getPrivacyLabel(video.privacy)
59 }, 69 },
60 nsfw: video.nsfw, 70 nsfw: video.nsfw,
61 71
@@ -93,7 +103,7 @@ function videoModelToFormattedJSON (video: MVideoFormattable, options?: VideoFor
93 if (options.additionalAttributes.state === true) { 103 if (options.additionalAttributes.state === true) {
94 videoObject.state = { 104 videoObject.state = {
95 id: video.state, 105 id: video.state,
96 label: VideoModel.getStateLabel(video.state) 106 label: getStateLabel(video.state)
97 } 107 }
98 } 108 }
99 109
@@ -140,7 +150,7 @@ function videoModelToFormattedDetailsJSON (video: MVideoFormattableDetails): Vid
140 waitTranscoding: video.waitTranscoding, 150 waitTranscoding: video.waitTranscoding,
141 state: { 151 state: {
142 id: video.state, 152 id: video.state,
143 label: VideoModel.getStateLabel(video.state) 153 label: getStateLabel(video.state)
144 }, 154 },
145 155
146 trackerUrls: video.getTrackerUrls(), 156 trackerUrls: video.getTrackerUrls(),
@@ -202,7 +212,7 @@ function videoFilesModelToFormattedJSON (
202 return { 212 return {
203 resolution: { 213 resolution: {
204 id: videoFile.resolution, 214 id: videoFile.resolution,
205 label: videoFile.resolution + 'p' 215 label: videoFile.resolution === 0 ? 'Audio' : `${videoFile.resolution}p`
206 }, 216 },
207 217
208 magnetUri: includeMagnet && videoFile.hasTorrent() 218 magnetUri: includeMagnet && videoFile.hasTorrent()
@@ -283,7 +293,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
283 if (video.language) { 293 if (video.language) {
284 language = { 294 language = {
285 identifier: video.language, 295 identifier: video.language,
286 name: VideoModel.getLanguageLabel(video.language) 296 name: getLanguageLabel(video.language)
287 } 297 }
288 } 298 }
289 299
@@ -291,7 +301,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
291 if (video.category) { 301 if (video.category) {
292 category = { 302 category = {
293 identifier: video.category + '', 303 identifier: video.category + '',
294 name: VideoModel.getCategoryLabel(video.category) 304 name: getCategoryLabel(video.category)
295 } 305 }
296 } 306 }
297 307
@@ -299,7 +309,7 @@ function videoModelToActivityPubObject (video: MVideoAP): VideoObject {
299 if (video.licence) { 309 if (video.licence) {
300 licence = { 310 licence = {
301 identifier: video.licence + '', 311 identifier: video.licence + '',
302 name: VideoModel.getLicenceLabel(video.licence) 312 name: getLicenceLabel(video.licence)
303 } 313 }
304 } 314 }
305 315
@@ -425,10 +435,36 @@ function getActivityStreamDuration (duration: number) {
425 return 'PT' + duration + 'S' 435 return 'PT' + duration + 'S'
426} 436}
427 437
438function getCategoryLabel (id: number) {
439 return VIDEO_CATEGORIES[id] || 'Misc'
440}
441
442function getLicenceLabel (id: number) {
443 return VIDEO_LICENCES[id] || 'Unknown'
444}
445
446function getLanguageLabel (id: string) {
447 return VIDEO_LANGUAGES[id] || 'Unknown'
448}
449
450function getPrivacyLabel (id: number) {
451 return VIDEO_PRIVACIES[id] || 'Unknown'
452}
453
454function getStateLabel (id: number) {
455 return VIDEO_STATES[id] || 'Unknown'
456}
457
428export { 458export {
429 videoModelToFormattedJSON, 459 videoModelToFormattedJSON,
430 videoModelToFormattedDetailsJSON, 460 videoModelToFormattedDetailsJSON,
431 videoFilesModelToFormattedJSON, 461 videoFilesModelToFormattedJSON,
432 videoModelToActivityPubObject, 462 videoModelToActivityPubObject,
433 getActivityStreamDuration 463 getActivityStreamDuration,
464
465 getCategoryLabel,
466 getLicenceLabel,
467 getLanguageLabel,
468 getPrivacyLabel,
469 getStateLabel
434} 470}
diff --git a/server/models/video/schedule-video-update.ts b/server/models/video/schedule-video-update.ts
index 22b08e91a..d462c20c7 100644
--- a/server/models/video/schedule-video-update.ts
+++ b/server/models/video/schedule-video-update.ts
@@ -1,8 +1,9 @@
1import { Op, Transaction } from 'sequelize'
1import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { ScopeNames as VideoScopeNames, VideoModel } from './video' 3import { MScheduleVideoUpdateFormattable, MScheduleVideoUpdate } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils'
3import { VideoPrivacy } from '../../../shared/models/videos' 5import { VideoPrivacy } from '../../../shared/models/videos'
4import { Op, Transaction } from 'sequelize' 6import { VideoModel } from './video'
5import { MScheduleVideoUpdateFormattable, MScheduleVideoUpdateVideoAll } from '@server/types/models'
6 7
7@Table({ 8@Table({
8 tableName: 'scheduleVideoUpdate', 9 tableName: 'scheduleVideoUpdate',
@@ -16,7 +17,7 @@ import { MScheduleVideoUpdateFormattable, MScheduleVideoUpdateVideoAll } from '@
16 } 17 }
17 ] 18 ]
18}) 19})
19export class ScheduleVideoUpdateModel extends Model { 20export class ScheduleVideoUpdateModel extends Model<Partial<AttributesOnly<ScheduleVideoUpdateModel>>> {
20 21
21 @AllowNull(false) 22 @AllowNull(false)
22 @Default(null) 23 @Default(null)
@@ -61,31 +62,17 @@ export class ScheduleVideoUpdateModel extends Model {
61 .then(res => !!res) 62 .then(res => !!res)
62 } 63 }
63 64
64 static listVideosToUpdate (t: Transaction) { 65 static listVideosToUpdate (transaction?: Transaction) {
65 const query = { 66 const query = {
66 where: { 67 where: {
67 updateAt: { 68 updateAt: {
68 [Op.lte]: new Date() 69 [Op.lte]: new Date()
69 } 70 }
70 }, 71 },
71 include: [ 72 transaction
72 {
73 model: VideoModel.scope(
74 [
75 VideoScopeNames.WITH_WEBTORRENT_FILES,
76 VideoScopeNames.WITH_STREAMING_PLAYLISTS,
77 VideoScopeNames.WITH_ACCOUNT_DETAILS,
78 VideoScopeNames.WITH_BLACKLISTED,
79 VideoScopeNames.WITH_THUMBNAILS,
80 VideoScopeNames.WITH_TAGS
81 ]
82 )
83 }
84 ],
85 transaction: t
86 } 73 }
87 74
88 return ScheduleVideoUpdateModel.findAll<MScheduleVideoUpdateVideoAll>(query) 75 return ScheduleVideoUpdateModel.findAll<MScheduleVideoUpdate>(query)
89 } 76 }
90 77
91 static deleteByVideoId (videoId: number, t: Transaction) { 78 static deleteByVideoId (videoId: number, t: Transaction) {
diff --git a/server/models/video/sql/shared/abstract-videos-model-query-builder.ts b/server/models/video/sql/shared/abstract-videos-model-query-builder.ts
new file mode 100644
index 000000000..0d7e64574
--- /dev/null
+++ b/server/models/video/sql/shared/abstract-videos-model-query-builder.ts
@@ -0,0 +1,300 @@
1import validator from 'validator'
2import { AbstractVideosQueryBuilder } from './abstract-videos-query-builder'
3import { VideoTables } from './video-tables'
4
5/**
6 *
7 * Abstract builder to create SQL query and fetch video models
8 *
9 */
10
11export class AbstractVideosModelQueryBuilder extends AbstractVideosQueryBuilder {
12 protected attributes: { [key: string]: string } = {}
13
14 protected joins = ''
15 protected where: string
16
17 protected tables: VideoTables
18
19 constructor (protected readonly mode: 'list' | 'get') {
20 super()
21
22 this.tables = new VideoTables(this.mode)
23 }
24
25 protected buildSelect () {
26 return 'SELECT ' + Object.keys(this.attributes).map(key => {
27 const value = this.attributes[key]
28 if (value) return `${key} AS ${value}`
29
30 return key
31 }).join(', ')
32 }
33
34 protected includeChannels () {
35 this.addJoin('INNER JOIN "videoChannel" AS "VideoChannel" ON "video"."channelId" = "VideoChannel"."id"')
36 this.addJoin('INNER JOIN "actor" AS "VideoChannel->Actor" ON "VideoChannel"."actorId" = "VideoChannel->Actor"."id"')
37
38 this.addJoin(
39 'LEFT OUTER JOIN "server" AS "VideoChannel->Actor->Server" ON "VideoChannel->Actor"."serverId" = "VideoChannel->Actor->Server"."id"'
40 )
41
42 this.addJoin(
43 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Actor->Avatar" ' +
44 'ON "VideoChannel->Actor"."avatarId" = "VideoChannel->Actor->Avatar"."id"'
45 )
46
47 this.attributes = {
48 ...this.attributes,
49
50 ...this.buildAttributesObject('VideoChannel', this.tables.getChannelAttributes()),
51 ...this.buildActorInclude('VideoChannel->Actor'),
52 ...this.buildAvatarInclude('VideoChannel->Actor->Avatar'),
53 ...this.buildServerInclude('VideoChannel->Actor->Server')
54 }
55 }
56
57 protected includeAccounts () {
58 this.addJoin('INNER JOIN "account" AS "VideoChannel->Account" ON "VideoChannel"."accountId" = "VideoChannel->Account"."id"')
59 this.addJoin(
60 'INNER JOIN "actor" AS "VideoChannel->Account->Actor" ON "VideoChannel->Account"."actorId" = "VideoChannel->Account->Actor"."id"'
61 )
62
63 this.addJoin(
64 'LEFT OUTER JOIN "server" AS "VideoChannel->Account->Actor->Server" ' +
65 'ON "VideoChannel->Account->Actor"."serverId" = "VideoChannel->Account->Actor->Server"."id"'
66 )
67
68 this.addJoin(
69 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Account->Actor->Avatar" ' +
70 'ON "VideoChannel->Account->Actor"."avatarId" = "VideoChannel->Account->Actor->Avatar"."id"'
71 )
72
73 this.attributes = {
74 ...this.attributes,
75
76 ...this.buildAttributesObject('VideoChannel->Account', this.tables.getAccountAttributes()),
77 ...this.buildActorInclude('VideoChannel->Account->Actor'),
78 ...this.buildAvatarInclude('VideoChannel->Account->Actor->Avatar'),
79 ...this.buildServerInclude('VideoChannel->Account->Actor->Server')
80 }
81 }
82
83 protected includeOwnerUser () {
84 this.addJoin('INNER JOIN "videoChannel" AS "VideoChannel" ON "video"."channelId" = "VideoChannel"."id"')
85 this.addJoin('INNER JOIN "account" AS "VideoChannel->Account" ON "VideoChannel"."accountId" = "VideoChannel->Account"."id"')
86
87 this.attributes = {
88 ...this.attributes,
89
90 ...this.buildAttributesObject('VideoChannel', this.tables.getChannelAttributes()),
91 ...this.buildAttributesObject('VideoChannel->Account', this.tables.getUserAccountAttributes())
92 }
93 }
94
95 protected includeThumbnails () {
96 this.addJoin('LEFT OUTER JOIN "thumbnail" AS "Thumbnails" ON "video"."id" = "Thumbnails"."videoId"')
97
98 this.attributes = {
99 ...this.attributes,
100
101 ...this.buildAttributesObject('Thumbnails', this.tables.getThumbnailAttributes())
102 }
103 }
104
105 protected includeWebtorrentFiles () {
106 this.addJoin('LEFT JOIN "videoFile" AS "VideoFiles" ON "VideoFiles"."videoId" = "video"."id"')
107
108 this.attributes = {
109 ...this.attributes,
110
111 ...this.buildAttributesObject('VideoFiles', this.tables.getFileAttributes())
112 }
113 }
114
115 protected includeStreamingPlaylistFiles () {
116 this.addJoin(
117 'LEFT JOIN "videoStreamingPlaylist" AS "VideoStreamingPlaylists" ON "VideoStreamingPlaylists"."videoId" = "video"."id"'
118 )
119
120 this.addJoin(
121 'LEFT JOIN "videoFile" AS "VideoStreamingPlaylists->VideoFiles" ' +
122 'ON "VideoStreamingPlaylists->VideoFiles"."videoStreamingPlaylistId" = "VideoStreamingPlaylists"."id"'
123 )
124
125 this.attributes = {
126 ...this.attributes,
127
128 ...this.buildAttributesObject('VideoStreamingPlaylists', this.tables.getStreamingPlaylistAttributes()),
129 ...this.buildAttributesObject('VideoStreamingPlaylists->VideoFiles', this.tables.getFileAttributes())
130 }
131 }
132
133 protected includeUserHistory (userId: number) {
134 this.addJoin(
135 'LEFT OUTER JOIN "userVideoHistory" ' +
136 'ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :userVideoHistoryId'
137 )
138
139 this.replacements.userVideoHistoryId = userId
140
141 this.attributes = {
142 ...this.attributes,
143
144 ...this.buildAttributesObject('userVideoHistory', this.tables.getUserHistoryAttributes())
145 }
146 }
147
148 protected includePlaylist (playlistId: number) {
149 this.addJoin(
150 'INNER JOIN "videoPlaylistElement" as "VideoPlaylistElement" ON "videoPlaylistElement"."videoId" = "video"."id" ' +
151 'AND "VideoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
152 )
153
154 this.replacements.videoPlaylistId = playlistId
155
156 this.attributes = {
157 ...this.attributes,
158
159 ...this.buildAttributesObject('VideoPlaylistElement', this.tables.getPlaylistAttributes())
160 }
161 }
162
163 protected includeTags () {
164 this.addJoin(
165 'LEFT OUTER JOIN (' +
166 '"videoTag" AS "Tags->VideoTagModel" INNER JOIN "tag" AS "Tags" ON "Tags"."id" = "Tags->VideoTagModel"."tagId"' +
167 ') ' +
168 'ON "video"."id" = "Tags->VideoTagModel"."videoId"'
169 )
170
171 this.attributes = {
172 ...this.attributes,
173
174 ...this.buildAttributesObject('Tags', this.tables.getTagAttributes()),
175 ...this.buildAttributesObject('Tags->VideoTagModel', this.tables.getVideoTagAttributes())
176 }
177 }
178
179 protected includeBlacklisted () {
180 this.addJoin(
181 'LEFT OUTER JOIN "videoBlacklist" AS "VideoBlacklist" ON "video"."id" = "VideoBlacklist"."videoId"'
182 )
183
184 this.attributes = {
185 ...this.attributes,
186
187 ...this.buildAttributesObject('VideoBlacklist', this.tables.getBlacklistedAttributes())
188 }
189 }
190
191 protected includeScheduleUpdate () {
192 this.addJoin(
193 'LEFT OUTER JOIN "scheduleVideoUpdate" AS "ScheduleVideoUpdate" ON "video"."id" = "ScheduleVideoUpdate"."videoId"'
194 )
195
196 this.attributes = {
197 ...this.attributes,
198
199 ...this.buildAttributesObject('ScheduleVideoUpdate', this.tables.getScheduleUpdateAttributes())
200 }
201 }
202
203 protected includeLive () {
204 this.addJoin(
205 'LEFT OUTER JOIN "videoLive" AS "VideoLive" ON "video"."id" = "VideoLive"."videoId"'
206 )
207
208 this.attributes = {
209 ...this.attributes,
210
211 ...this.buildAttributesObject('VideoLive', this.tables.getLiveAttributes())
212 }
213 }
214
215 protected includeTrackers () {
216 this.addJoin(
217 'LEFT OUTER JOIN (' +
218 '"videoTracker" AS "Trackers->VideoTrackerModel" ' +
219 'INNER JOIN "tracker" AS "Trackers" ON "Trackers"."id" = "Trackers->VideoTrackerModel"."trackerId"' +
220 ') ON "video"."id" = "Trackers->VideoTrackerModel"."videoId"'
221 )
222
223 this.attributes = {
224 ...this.attributes,
225
226 ...this.buildAttributesObject('Trackers', this.tables.getTrackerAttributes()),
227 ...this.buildAttributesObject('Trackers->VideoTrackerModel', this.tables.getVideoTrackerAttributes())
228 }
229 }
230
231 protected includeWebTorrentRedundancies () {
232 this.addJoin(
233 'LEFT OUTER JOIN "videoRedundancy" AS "VideoFiles->RedundancyVideos" ON ' +
234 '"VideoFiles"."id" = "VideoFiles->RedundancyVideos"."videoFileId"'
235 )
236
237 this.attributes = {
238 ...this.attributes,
239
240 ...this.buildAttributesObject('VideoFiles->RedundancyVideos', this.tables.getRedundancyAttributes())
241 }
242 }
243
244 protected includeStreamingPlaylistRedundancies () {
245 this.addJoin(
246 'LEFT OUTER JOIN "videoRedundancy" AS "VideoStreamingPlaylists->RedundancyVideos" ' +
247 'ON "VideoStreamingPlaylists"."id" = "VideoStreamingPlaylists->RedundancyVideos"."videoStreamingPlaylistId"'
248 )
249
250 this.attributes = {
251 ...this.attributes,
252
253 ...this.buildAttributesObject('VideoStreamingPlaylists->RedundancyVideos', this.tables.getRedundancyAttributes())
254 }
255 }
256
257 protected buildActorInclude (prefixKey: string) {
258 return this.buildAttributesObject(prefixKey, this.tables.getActorAttributes())
259 }
260
261 protected buildAvatarInclude (prefixKey: string) {
262 return this.buildAttributesObject(prefixKey, this.tables.getAvatarAttributes())
263 }
264
265 protected buildServerInclude (prefixKey: string) {
266 return this.buildAttributesObject(prefixKey, this.tables.getServerAttributes())
267 }
268
269 protected buildAttributesObject (prefixKey: string, attributeKeys: string[]) {
270 const result: { [id: string]: string} = {}
271
272 const prefixValue = prefixKey.replace(/->/g, '.')
273
274 for (const attribute of attributeKeys) {
275 result[`"${prefixKey}"."${attribute}"`] = `"${prefixValue}.${attribute}"`
276 }
277
278 return result
279 }
280
281 protected whereId (options: { id?: string | number, url?: string }) {
282 if (options.url) {
283 this.where = 'WHERE "video"."url" = :videoUrl'
284 this.replacements.videoUrl = options.url
285 return
286 }
287
288 if (validator.isInt('' + options.id)) {
289 this.where = 'WHERE "video".id = :videoId'
290 } else {
291 this.where = 'WHERE uuid = :videoId'
292 }
293
294 this.replacements.videoId = options.id
295 }
296
297 protected addJoin (join: string) {
298 this.joins += join + ' '
299 }
300}
diff --git a/server/models/video/sql/shared/abstract-videos-query-builder.ts b/server/models/video/sql/shared/abstract-videos-query-builder.ts
new file mode 100644
index 000000000..09776bcb0
--- /dev/null
+++ b/server/models/video/sql/shared/abstract-videos-query-builder.ts
@@ -0,0 +1,26 @@
1import { QueryTypes, Sequelize, Transaction } from 'sequelize'
2
3/**
4 *
5 * Abstact builder to run video SQL queries
6 *
7 */
8
9export class AbstractVideosQueryBuilder {
10 protected sequelize: Sequelize
11
12 protected query: string
13 protected replacements: any = {}
14
15 protected runQuery (options: { transaction?: Transaction, logging?: boolean } = {}) {
16 const queryOptions = {
17 transaction: options.transaction,
18 logging: options.logging,
19 replacements: this.replacements,
20 type: QueryTypes.SELECT as QueryTypes.SELECT,
21 nest: false
22 }
23
24 return this.sequelize.query<any>(this.query, queryOptions)
25 }
26}
diff --git a/server/models/video/sql/shared/video-file-query-builder.ts b/server/models/video/sql/shared/video-file-query-builder.ts
new file mode 100644
index 000000000..6b15c3b69
--- /dev/null
+++ b/server/models/video/sql/shared/video-file-query-builder.ts
@@ -0,0 +1,69 @@
1import { Sequelize } from 'sequelize'
2import { BuildVideoGetQueryOptions } from '../video-model-get-query-builder'
3import { AbstractVideosModelQueryBuilder } from './abstract-videos-model-query-builder'
4
5/**
6 *
7 * Fetch files (webtorrent and streaming playlist) according to a video
8 *
9 */
10
11export class VideoFileQueryBuilder extends AbstractVideosModelQueryBuilder {
12 protected attributes: { [key: string]: string }
13
14 constructor (protected readonly sequelize: Sequelize) {
15 super('get')
16 }
17
18 queryWebTorrentVideos (options: BuildVideoGetQueryOptions) {
19 this.buildWebtorrentFilesQuery(options)
20
21 return this.runQuery(options)
22 }
23
24 queryStreamingPlaylistVideos (options: BuildVideoGetQueryOptions) {
25 this.buildVideoStreamingPlaylistFilesQuery(options)
26
27 return this.runQuery(options)
28 }
29
30 private buildWebtorrentFilesQuery (options: BuildVideoGetQueryOptions) {
31 this.attributes = {
32 '"video"."id"': ''
33 }
34
35 this.includeWebtorrentFiles()
36
37 if (this.shouldIncludeRedundancies(options)) {
38 this.includeWebTorrentRedundancies()
39 }
40
41 this.whereId(options)
42
43 this.query = this.buildQuery()
44 }
45
46 private buildVideoStreamingPlaylistFilesQuery (options: BuildVideoGetQueryOptions) {
47 this.attributes = {
48 '"video"."id"': ''
49 }
50
51 this.includeStreamingPlaylistFiles()
52
53 if (this.shouldIncludeRedundancies(options)) {
54 this.includeStreamingPlaylistRedundancies()
55 }
56
57 this.whereId(options)
58
59 this.query = this.buildQuery()
60 }
61
62 private buildQuery () {
63 return `${this.buildSelect()} FROM "video" ${this.joins} ${this.where}`
64 }
65
66 private shouldIncludeRedundancies (options: BuildVideoGetQueryOptions) {
67 return options.type === 'api'
68 }
69}
diff --git a/server/models/video/sql/shared/video-model-builder.ts b/server/models/video/sql/shared/video-model-builder.ts
new file mode 100644
index 000000000..e7e2aa1ca
--- /dev/null
+++ b/server/models/video/sql/shared/video-model-builder.ts
@@ -0,0 +1,333 @@
1
2import { AccountModel } from '@server/models/account/account'
3import { ActorModel } from '@server/models/actor/actor'
4import { ActorImageModel } from '@server/models/actor/actor-image'
5import { VideoRedundancyModel } from '@server/models/redundancy/video-redundancy'
6import { ServerModel } from '@server/models/server/server'
7import { TrackerModel } from '@server/models/server/tracker'
8import { UserVideoHistoryModel } from '@server/models/user/user-video-history'
9import { ScheduleVideoUpdateModel } from '../../schedule-video-update'
10import { TagModel } from '../../tag'
11import { ThumbnailModel } from '../../thumbnail'
12import { VideoModel } from '../../video'
13import { VideoBlacklistModel } from '../../video-blacklist'
14import { VideoChannelModel } from '../../video-channel'
15import { VideoFileModel } from '../../video-file'
16import { VideoLiveModel } from '../../video-live'
17import { VideoStreamingPlaylistModel } from '../../video-streaming-playlist'
18import { VideoTables } from './video-tables'
19
20type SQLRow = { [id: string]: string | number }
21
22/**
23 *
24 * Build video models from SQL rows
25 *
26 */
27
28export class VideoModelBuilder {
29 private videosMemo: { [ id: number ]: VideoModel }
30 private videoStreamingPlaylistMemo: { [ id: number ]: VideoStreamingPlaylistModel }
31 private videoFileMemo: { [ id: number ]: VideoFileModel }
32
33 private thumbnailsDone: Set<any>
34 private historyDone: Set<any>
35 private blacklistDone: Set<any>
36 private liveDone: Set<any>
37 private redundancyDone: Set<any>
38 private scheduleVideoUpdateDone: Set<any>
39
40 private trackersDone: Set<string>
41 private tagsDone: Set<string>
42
43 private videos: VideoModel[]
44
45 private readonly buildOpts = { raw: true, isNewRecord: false }
46
47 constructor (
48 readonly mode: 'get' | 'list',
49 readonly tables: VideoTables
50 ) {
51
52 }
53
54 buildVideosFromRows (rows: SQLRow[], rowsWebTorrentFiles?: SQLRow[], rowsStreamingPlaylist?: SQLRow[]) {
55 this.reinit()
56
57 for (const row of rows) {
58 this.buildVideoAndAccount(row)
59
60 const videoModel = this.videosMemo[row.id]
61
62 this.setUserHistory(row, videoModel)
63 this.addThumbnail(row, videoModel)
64
65 if (!rowsWebTorrentFiles) {
66 this.addWebTorrentFile(row, videoModel)
67 }
68
69 if (!rowsStreamingPlaylist) {
70 this.addStreamingPlaylist(row, videoModel)
71 this.addStreamingPlaylistFile(row)
72 }
73
74 if (this.mode === 'get') {
75 this.addTag(row, videoModel)
76 this.addTracker(row, videoModel)
77 this.setBlacklisted(row, videoModel)
78 this.setScheduleVideoUpdate(row, videoModel)
79 this.setLive(row, videoModel)
80 }
81 }
82
83 this.grabSeparateWebTorrentFiles(rowsWebTorrentFiles)
84 this.grabSeparateStreamingPlaylistFiles(rowsStreamingPlaylist)
85
86 return this.videos
87 }
88
89 private reinit () {
90 this.videosMemo = {}
91 this.videoStreamingPlaylistMemo = {}
92 this.videoFileMemo = {}
93
94 this.thumbnailsDone = new Set<number>()
95 this.historyDone = new Set<number>()
96 this.blacklistDone = new Set<number>()
97 this.liveDone = new Set<number>()
98 this.redundancyDone = new Set<number>()
99 this.scheduleVideoUpdateDone = new Set<number>()
100
101 this.trackersDone = new Set<string>()
102 this.tagsDone = new Set<string>()
103
104 this.videos = []
105 }
106
107 private grabSeparateWebTorrentFiles (rowsWebTorrentFiles?: SQLRow[]) {
108 if (!rowsWebTorrentFiles) return
109
110 for (const row of rowsWebTorrentFiles) {
111 const id = row['VideoFiles.id']
112 if (!id) continue
113
114 const videoModel = this.videosMemo[row.id]
115 this.addWebTorrentFile(row, videoModel)
116 this.addRedundancy(row, 'VideoFiles', this.videoFileMemo[id])
117 }
118 }
119
120 private grabSeparateStreamingPlaylistFiles (rowsStreamingPlaylist?: SQLRow[]) {
121 if (!rowsStreamingPlaylist) return
122
123 for (const row of rowsStreamingPlaylist || []) {
124 const id = row['VideoStreamingPlaylists.id']
125 if (!id) continue
126
127 const videoModel = this.videosMemo[row.id]
128
129 this.addStreamingPlaylist(row, videoModel)
130 this.addStreamingPlaylistFile(row)
131 this.addRedundancy(row, 'VideoStreamingPlaylists', this.videoStreamingPlaylistMemo[id])
132 }
133 }
134
135 private buildVideoAndAccount (row: SQLRow) {
136 if (this.videosMemo[row.id]) return
137
138 const videoModel = new VideoModel(this.grab(row, this.tables.getVideoAttributes(), ''), this.buildOpts)
139
140 videoModel.UserVideoHistories = []
141 videoModel.Thumbnails = []
142 videoModel.VideoFiles = []
143 videoModel.VideoStreamingPlaylists = []
144 videoModel.Tags = []
145 videoModel.Trackers = []
146
147 this.buildAccount(row, videoModel)
148
149 this.videosMemo[row.id] = videoModel
150
151 // Keep rows order
152 this.videos.push(videoModel)
153 }
154
155 private buildAccount (row: SQLRow, videoModel: VideoModel) {
156 const id = row['VideoChannel.Account.id']
157 if (!id) return
158
159 const channelModel = new VideoChannelModel(this.grab(row, this.tables.getChannelAttributes(), 'VideoChannel'), this.buildOpts)
160 channelModel.Actor = this.buildActor(row, 'VideoChannel')
161
162 const accountModel = new AccountModel(this.grab(row, this.tables.getAccountAttributes(), 'VideoChannel.Account'), this.buildOpts)
163 accountModel.Actor = this.buildActor(row, 'VideoChannel.Account')
164
165 channelModel.Account = accountModel
166
167 videoModel.VideoChannel = channelModel
168 }
169
170 private buildActor (row: SQLRow, prefix: string) {
171 const actorPrefix = `${prefix}.Actor`
172 const avatarPrefix = `${actorPrefix}.Avatar`
173 const serverPrefix = `${actorPrefix}.Server`
174
175 const avatarModel = row[`${avatarPrefix}.id`] !== null
176 ? new ActorImageModel(this.grab(row, this.tables.getAvatarAttributes(), avatarPrefix), this.buildOpts)
177 : null
178
179 const serverModel = row[`${serverPrefix}.id`] !== null
180 ? new ServerModel(this.grab(row, this.tables.getServerAttributes(), serverPrefix), this.buildOpts)
181 : null
182
183 const actorModel = new ActorModel(this.grab(row, this.tables.getActorAttributes(), actorPrefix), this.buildOpts)
184 actorModel.Avatar = avatarModel
185 actorModel.Server = serverModel
186
187 return actorModel
188 }
189
190 private setUserHistory (row: SQLRow, videoModel: VideoModel) {
191 const id = row['userVideoHistory.id']
192 if (!id || this.historyDone.has(id)) return
193
194 const attributes = this.grab(row, this.tables.getUserHistoryAttributes(), 'userVideoHistory')
195 const historyModel = new UserVideoHistoryModel(attributes, this.buildOpts)
196 videoModel.UserVideoHistories.push(historyModel)
197
198 this.historyDone.add(id)
199 }
200
201 private addThumbnail (row: SQLRow, videoModel: VideoModel) {
202 const id = row['Thumbnails.id']
203 if (!id || this.thumbnailsDone.has(id)) return
204
205 const attributes = this.grab(row, this.tables.getThumbnailAttributes(), 'Thumbnails')
206 const thumbnailModel = new ThumbnailModel(attributes, this.buildOpts)
207 videoModel.Thumbnails.push(thumbnailModel)
208
209 this.thumbnailsDone.add(id)
210 }
211
212 private addWebTorrentFile (row: SQLRow, videoModel: VideoModel) {
213 const id = row['VideoFiles.id']
214 if (!id || this.videoFileMemo[id]) return
215
216 const attributes = this.grab(row, this.tables.getFileAttributes(), 'VideoFiles')
217 const videoFileModel = new VideoFileModel(attributes, this.buildOpts)
218 videoModel.VideoFiles.push(videoFileModel)
219
220 this.videoFileMemo[id] = videoFileModel
221 }
222
223 private addStreamingPlaylist (row: SQLRow, videoModel: VideoModel) {
224 const id = row['VideoStreamingPlaylists.id']
225 if (!id || this.videoStreamingPlaylistMemo[id]) return
226
227 const attributes = this.grab(row, this.tables.getStreamingPlaylistAttributes(), 'VideoStreamingPlaylists')
228 const streamingPlaylist = new VideoStreamingPlaylistModel(attributes, this.buildOpts)
229 streamingPlaylist.VideoFiles = []
230
231 videoModel.VideoStreamingPlaylists.push(streamingPlaylist)
232
233 this.videoStreamingPlaylistMemo[id] = streamingPlaylist
234 }
235
236 private addStreamingPlaylistFile (row: SQLRow) {
237 const id = row['VideoStreamingPlaylists.VideoFiles.id']
238 if (!id || this.videoFileMemo[id]) return
239
240 const streamingPlaylist = this.videoStreamingPlaylistMemo[row['VideoStreamingPlaylists.id']]
241
242 const attributes = this.grab(row, this.tables.getFileAttributes(), 'VideoStreamingPlaylists.VideoFiles')
243 const videoFileModel = new VideoFileModel(attributes, this.buildOpts)
244 streamingPlaylist.VideoFiles.push(videoFileModel)
245
246 this.videoFileMemo[id] = videoFileModel
247 }
248
249 private addRedundancy (row: SQLRow, prefix: string, to: VideoFileModel | VideoStreamingPlaylistModel) {
250 if (!to.RedundancyVideos) to.RedundancyVideos = []
251
252 const redundancyPrefix = `${prefix}.RedundancyVideos`
253 const id = row[`${redundancyPrefix}.id`]
254
255 if (!id || this.redundancyDone.has(id)) return
256
257 const attributes = this.grab(row, this.tables.getRedundancyAttributes(), redundancyPrefix)
258 const redundancyModel = new VideoRedundancyModel(attributes, this.buildOpts)
259 to.RedundancyVideos.push(redundancyModel)
260
261 this.redundancyDone.add(id)
262 }
263
264 private addTag (row: SQLRow, videoModel: VideoModel) {
265 if (!row['Tags.name']) return
266
267 const key = `${row['Tags.VideoTagModel.videoId']}-${row['Tags.VideoTagModel.tagId']}`
268 if (this.tagsDone.has(key)) return
269
270 const attributes = this.grab(row, this.tables.getTagAttributes(), 'Tags')
271 const tagModel = new TagModel(attributes, this.buildOpts)
272 videoModel.Tags.push(tagModel)
273
274 this.tagsDone.add(key)
275 }
276
277 private addTracker (row: SQLRow, videoModel: VideoModel) {
278 if (!row['Trackers.id']) return
279
280 const key = `${row['Trackers.VideoTrackerModel.videoId']}-${row['Trackers.VideoTrackerModel.trackerId']}`
281 if (this.trackersDone.has(key)) return
282
283 const attributes = this.grab(row, this.tables.getTrackerAttributes(), 'Trackers')
284 const trackerModel = new TrackerModel(attributes, this.buildOpts)
285 videoModel.Trackers.push(trackerModel)
286
287 this.trackersDone.add(key)
288 }
289
290 private setBlacklisted (row: SQLRow, videoModel: VideoModel) {
291 const id = row['VideoBlacklist.id']
292 if (!id || this.blacklistDone.has(id)) return
293
294 const attributes = this.grab(row, this.tables.getBlacklistedAttributes(), 'VideoBlacklist')
295 videoModel.VideoBlacklist = new VideoBlacklistModel(attributes, this.buildOpts)
296
297 this.blacklistDone.add(id)
298 }
299
300 private setScheduleVideoUpdate (row: SQLRow, videoModel: VideoModel) {
301 const id = row['ScheduleVideoUpdate.id']
302 if (!id || this.scheduleVideoUpdateDone.has(id)) return
303
304 const attributes = this.grab(row, this.tables.getScheduleUpdateAttributes(), 'ScheduleVideoUpdate')
305 videoModel.ScheduleVideoUpdate = new ScheduleVideoUpdateModel(attributes, this.buildOpts)
306
307 this.scheduleVideoUpdateDone.add(id)
308 }
309
310 private setLive (row: SQLRow, videoModel: VideoModel) {
311 const id = row['VideoLive.id']
312 if (!id || this.liveDone.has(id)) return
313
314 const attributes = this.grab(row, this.tables.getLiveAttributes(), 'VideoLive')
315 videoModel.VideoLive = new VideoLiveModel(attributes, this.buildOpts)
316
317 this.liveDone.add(id)
318 }
319
320 private grab (row: SQLRow, attributes: string[], prefix: string) {
321 const result: { [ id: string ]: string | number } = {}
322
323 for (const a of attributes) {
324 const key = prefix
325 ? prefix + '.' + a
326 : a
327
328 result[a] = row[key]
329 }
330
331 return result
332 }
333}
diff --git a/server/models/video/sql/shared/video-tables.ts b/server/models/video/sql/shared/video-tables.ts
new file mode 100644
index 000000000..abdd22188
--- /dev/null
+++ b/server/models/video/sql/shared/video-tables.ts
@@ -0,0 +1,263 @@
1
2/**
3 *
4 * Class to build video attributes/join names we want to fetch from the database
5 *
6 */
7export class VideoTables {
8
9 constructor (readonly mode: 'get' | 'list') {
10
11 }
12
13 getChannelAttributesForUser () {
14 return [ 'id', 'accountId' ]
15 }
16
17 getChannelAttributes () {
18 let attributeKeys = [
19 'id',
20 'name',
21 'description',
22 'actorId'
23 ]
24
25 if (this.mode === 'get') {
26 attributeKeys = attributeKeys.concat([
27 'support',
28 'createdAt',
29 'updatedAt'
30 ])
31 }
32
33 return attributeKeys
34 }
35
36 getUserAccountAttributes () {
37 return [ 'id', 'userId' ]
38 }
39
40 getAccountAttributes () {
41 let attributeKeys = [ 'id', 'name', 'actorId' ]
42
43 if (this.mode === 'get') {
44 attributeKeys = attributeKeys.concat([
45 'description',
46 'userId',
47 'createdAt',
48 'updatedAt'
49 ])
50 }
51
52 return attributeKeys
53 }
54
55 getThumbnailAttributes () {
56 let attributeKeys = [ 'id', 'type', 'filename' ]
57
58 if (this.mode === 'get') {
59 attributeKeys = attributeKeys.concat([
60 'height',
61 'width',
62 'fileUrl',
63 'automaticallyGenerated',
64 'videoId',
65 'videoPlaylistId',
66 'createdAt',
67 'updatedAt'
68 ])
69 }
70
71 return attributeKeys
72 }
73
74 getFileAttributes () {
75 return [
76 'id',
77 'createdAt',
78 'updatedAt',
79 'resolution',
80 'size',
81 'extname',
82 'filename',
83 'fileUrl',
84 'torrentFilename',
85 'torrentUrl',
86 'infoHash',
87 'fps',
88 'metadataUrl',
89 'videoStreamingPlaylistId',
90 'videoId'
91 ]
92 }
93
94 getStreamingPlaylistAttributes () {
95 let playlistKeys = [ 'id', 'playlistUrl', 'type' ]
96
97 if (this.mode === 'get') {
98 playlistKeys = playlistKeys.concat([
99 'p2pMediaLoaderInfohashes',
100 'p2pMediaLoaderPeerVersion',
101 'segmentsSha256Url',
102 'videoId',
103 'createdAt',
104 'updatedAt'
105 ])
106 }
107
108 return playlistKeys
109 }
110
111 getUserHistoryAttributes () {
112 return [ 'id', 'currentTime' ]
113 }
114
115 getPlaylistAttributes () {
116 return [
117 'createdAt',
118 'updatedAt',
119 'url',
120 'position',
121 'startTimestamp',
122 'stopTimestamp',
123 'videoPlaylistId'
124 ]
125 }
126
127 getTagAttributes () {
128 return [ 'id', 'name' ]
129 }
130
131 getVideoTagAttributes () {
132 return [ 'videoId', 'tagId', 'createdAt', 'updatedAt' ]
133 }
134
135 getBlacklistedAttributes () {
136 return [ 'id', 'reason', 'unfederated' ]
137 }
138
139 getScheduleUpdateAttributes () {
140 return [
141 'id',
142 'updateAt',
143 'privacy',
144 'videoId',
145 'createdAt',
146 'updatedAt'
147 ]
148 }
149
150 getLiveAttributes () {
151 return [
152 'id',
153 'streamKey',
154 'saveReplay',
155 'permanentLive',
156 'videoId',
157 'createdAt',
158 'updatedAt'
159 ]
160 }
161
162 getTrackerAttributes () {
163 return [ 'id', 'url' ]
164 }
165
166 getVideoTrackerAttributes () {
167 return [
168 'videoId',
169 'trackerId',
170 'createdAt',
171 'updatedAt'
172 ]
173 }
174
175 getRedundancyAttributes () {
176 return [ 'id', 'fileUrl' ]
177 }
178
179 getActorAttributes () {
180 let attributeKeys = [
181 'id',
182 'preferredUsername',
183 'url',
184 'serverId',
185 'avatarId'
186 ]
187
188 if (this.mode === 'get') {
189 attributeKeys = attributeKeys.concat([
190 'type',
191 'followersCount',
192 'followingCount',
193 'inboxUrl',
194 'outboxUrl',
195 'sharedInboxUrl',
196 'followersUrl',
197 'followingUrl',
198 'remoteCreatedAt',
199 'createdAt',
200 'updatedAt'
201 ])
202 }
203
204 return attributeKeys
205 }
206
207 getAvatarAttributes () {
208 let attributeKeys = [
209 'id',
210 'filename',
211 'type',
212 'fileUrl',
213 'onDisk',
214 'createdAt',
215 'updatedAt'
216 ]
217
218 if (this.mode === 'get') {
219 attributeKeys = attributeKeys.concat([
220 'height',
221 'width',
222 'type'
223 ])
224 }
225
226 return attributeKeys
227 }
228
229 getServerAttributes () {
230 return [ 'id', 'host' ]
231 }
232
233 getVideoAttributes () {
234 return [
235 'id',
236 'uuid',
237 'name',
238 'category',
239 'licence',
240 'language',
241 'privacy',
242 'nsfw',
243 'description',
244 'support',
245 'duration',
246 'views',
247 'likes',
248 'dislikes',
249 'remote',
250 'isLive',
251 'url',
252 'commentsEnabled',
253 'downloadEnabled',
254 'waitTranscoding',
255 'state',
256 'publishedAt',
257 'originallyPublishedAt',
258 'channelId',
259 'createdAt',
260 'updatedAt'
261 ]
262 }
263}
diff --git a/server/models/video/sql/video-model-get-query-builder.ts b/server/models/video/sql/video-model-get-query-builder.ts
new file mode 100644
index 000000000..f234e8778
--- /dev/null
+++ b/server/models/video/sql/video-model-get-query-builder.ts
@@ -0,0 +1,173 @@
1import { Sequelize, Transaction } from 'sequelize'
2import { AbstractVideosModelQueryBuilder } from './shared/abstract-videos-model-query-builder'
3import { VideoFileQueryBuilder } from './shared/video-file-query-builder'
4import { VideoModelBuilder } from './shared/video-model-builder'
5import { VideoTables } from './shared/video-tables'
6
7/**
8 *
9 * Build a GET SQL query, fetch rows and create the video model
10 *
11 */
12
13export type GetType =
14 'api' |
15 'full-light' |
16 'account-blacklist-files' |
17 'all-files' |
18 'thumbnails' |
19 'thumbnails-blacklist' |
20 'id' |
21 'blacklist-rights'
22
23export type BuildVideoGetQueryOptions = {
24 id?: number | string
25 url?: string
26
27 type: GetType
28
29 userId?: number
30 transaction?: Transaction
31
32 logging?: boolean
33}
34
35export class VideosModelGetQueryBuilder {
36 videoQueryBuilder: VideosModelGetQuerySubBuilder
37 webtorrentFilesQueryBuilder: VideoFileQueryBuilder
38 streamingPlaylistFilesQueryBuilder: VideoFileQueryBuilder
39
40 private readonly videoModelBuilder: VideoModelBuilder
41
42 private static readonly videoFilesInclude = new Set<GetType>([ 'api', 'full-light', 'account-blacklist-files', 'all-files' ])
43
44 constructor (protected readonly sequelize: Sequelize) {
45 this.videoQueryBuilder = new VideosModelGetQuerySubBuilder(sequelize)
46 this.webtorrentFilesQueryBuilder = new VideoFileQueryBuilder(sequelize)
47 this.streamingPlaylistFilesQueryBuilder = new VideoFileQueryBuilder(sequelize)
48
49 this.videoModelBuilder = new VideoModelBuilder('get', new VideoTables('get'))
50 }
51
52 async queryVideo (options: BuildVideoGetQueryOptions) {
53 const [ videoRows, webtorrentFilesRows, streamingPlaylistFilesRows ] = await Promise.all([
54 this.videoQueryBuilder.queryVideos(options),
55
56 VideosModelGetQueryBuilder.videoFilesInclude.has(options.type)
57 ? this.webtorrentFilesQueryBuilder.queryWebTorrentVideos(options)
58 : Promise.resolve(undefined),
59
60 VideosModelGetQueryBuilder.videoFilesInclude.has(options.type)
61 ? this.streamingPlaylistFilesQueryBuilder.queryStreamingPlaylistVideos(options)
62 : Promise.resolve(undefined)
63 ])
64
65 const videos = this.videoModelBuilder.buildVideosFromRows(videoRows, webtorrentFilesRows, streamingPlaylistFilesRows)
66
67 if (videos.length > 1) {
68 throw new Error('Video results is more than ')
69 }
70
71 if (videos.length === 0) return null
72 return videos[0]
73 }
74}
75
76export class VideosModelGetQuerySubBuilder extends AbstractVideosModelQueryBuilder {
77 protected attributes: { [key: string]: string }
78
79 protected webtorrentFilesQuery: string
80 protected streamingPlaylistFilesQuery: string
81
82 private static readonly trackersInclude = new Set<GetType>([ 'api' ])
83 private static readonly liveInclude = new Set<GetType>([ 'api', 'full-light' ])
84 private static readonly scheduleUpdateInclude = new Set<GetType>([ 'api', 'full-light' ])
85 private static readonly tagsInclude = new Set<GetType>([ 'api', 'full-light' ])
86 private static readonly userHistoryInclude = new Set<GetType>([ 'api', 'full-light' ])
87 private static readonly accountInclude = new Set<GetType>([ 'api', 'full-light', 'account-blacklist-files' ])
88 private static readonly ownerUserInclude = new Set<GetType>([ 'blacklist-rights' ])
89
90 private static readonly blacklistedInclude = new Set<GetType>([
91 'api',
92 'full-light',
93 'account-blacklist-files',
94 'thumbnails-blacklist',
95 'blacklist-rights'
96 ])
97
98 private static readonly thumbnailsInclude = new Set<GetType>([
99 'api',
100 'full-light',
101 'account-blacklist-files',
102 'all-files',
103 'thumbnails',
104 'thumbnails-blacklist'
105 ])
106
107 constructor (protected readonly sequelize: Sequelize) {
108 super('get')
109 }
110
111 queryVideos (options: BuildVideoGetQueryOptions) {
112 this.buildMainGetQuery(options)
113
114 return this.runQuery(options)
115 }
116
117 private buildMainGetQuery (options: BuildVideoGetQueryOptions) {
118 this.attributes = {
119 '"video".*': ''
120 }
121
122 if (VideosModelGetQuerySubBuilder.thumbnailsInclude.has(options.type)) {
123 this.includeThumbnails()
124 }
125
126 if (VideosModelGetQuerySubBuilder.blacklistedInclude.has(options.type)) {
127 this.includeBlacklisted()
128 }
129
130 if (VideosModelGetQuerySubBuilder.accountInclude.has(options.type)) {
131 this.includeChannels()
132 this.includeAccounts()
133 }
134
135 if (VideosModelGetQuerySubBuilder.tagsInclude.has(options.type)) {
136 this.includeTags()
137 }
138
139 if (VideosModelGetQuerySubBuilder.scheduleUpdateInclude.has(options.type)) {
140 this.includeScheduleUpdate()
141 }
142
143 if (VideosModelGetQuerySubBuilder.liveInclude.has(options.type)) {
144 this.includeLive()
145 }
146
147 if (options.userId && VideosModelGetQuerySubBuilder.userHistoryInclude.has(options.type)) {
148 this.includeUserHistory(options.userId)
149 }
150
151 if (VideosModelGetQuerySubBuilder.ownerUserInclude.has(options.type)) {
152 this.includeOwnerUser()
153 }
154
155 if (VideosModelGetQuerySubBuilder.trackersInclude.has(options.type)) {
156 this.includeTrackers()
157 }
158
159 this.whereId(options)
160
161 this.query = this.buildQuery(options)
162 }
163
164 private buildQuery (options: BuildVideoGetQueryOptions) {
165 const order = VideosModelGetQuerySubBuilder.tagsInclude.has(options.type)
166 ? 'ORDER BY "Tags"."name" ASC'
167 : ''
168
169 const from = `SELECT * FROM "video" ${this.where} LIMIT 1`
170
171 return `${this.buildSelect()} FROM (${from}) AS "video" ${this.joins} ${order}`
172 }
173}
diff --git a/server/models/video/sql/videos-id-list-query-builder.ts b/server/models/video/sql/videos-id-list-query-builder.ts
new file mode 100644
index 000000000..30b251f0f
--- /dev/null
+++ b/server/models/video/sql/videos-id-list-query-builder.ts
@@ -0,0 +1,616 @@
1import { Sequelize } from 'sequelize'
2import validator from 'validator'
3import { exists } from '@server/helpers/custom-validators/misc'
4import { buildDirectionAndField, createSafeIn } from '@server/models/utils'
5import { MUserAccountId, MUserId } from '@server/types/models'
6import { VideoFilter, VideoPrivacy, VideoState } from '@shared/models'
7import { AbstractVideosQueryBuilder } from './shared/abstract-videos-query-builder'
8
9/**
10 *
11 * Build videos list SQL query to fetch rows
12 *
13 */
14
15export type BuildVideosListQueryOptions = {
16 attributes?: string[]
17
18 serverAccountId: number
19 followerActorId: number
20 includeLocalVideos: boolean
21
22 count: number
23 start: number
24 sort: string
25
26 nsfw?: boolean
27 filter?: VideoFilter
28 isLive?: boolean
29
30 categoryOneOf?: number[]
31 licenceOneOf?: number[]
32 languageOneOf?: string[]
33 tagsOneOf?: string[]
34 tagsAllOf?: string[]
35
36 withFiles?: boolean
37
38 accountId?: number
39 videoChannelId?: number
40
41 videoPlaylistId?: number
42
43 trendingAlgorithm?: string // best, hot, or any other algorithm implemented
44 trendingDays?: number
45
46 user?: MUserAccountId
47 historyOfUser?: MUserId
48
49 startDate?: string // ISO 8601
50 endDate?: string // ISO 8601
51 originallyPublishedStartDate?: string
52 originallyPublishedEndDate?: string
53
54 durationMin?: number // seconds
55 durationMax?: number // seconds
56
57 search?: string
58
59 isCount?: boolean
60
61 group?: string
62 having?: string
63}
64
65export class VideosIdListQueryBuilder extends AbstractVideosQueryBuilder {
66 protected replacements: any = {}
67
68 private attributes: string[]
69 private joins: string[] = []
70
71 private readonly and: string[] = []
72
73 private readonly cte: string[] = []
74
75 private group = ''
76 private having = ''
77
78 private sort = ''
79 private limit = ''
80 private offset = ''
81
82 constructor (protected readonly sequelize: Sequelize) {
83 super()
84 }
85
86 queryVideoIds (options: BuildVideosListQueryOptions) {
87 this.buildIdsListQuery(options)
88
89 return this.runQuery()
90 }
91
92 countVideoIds (countOptions: BuildVideosListQueryOptions): Promise<number> {
93 this.buildIdsListQuery(countOptions)
94
95 return this.runQuery().then(rows => rows.length !== 0 ? rows[0].total : 0)
96 }
97
98 getIdsListQueryAndSort (options: BuildVideosListQueryOptions) {
99 this.buildIdsListQuery(options)
100 return { query: this.query, sort: this.sort, replacements: this.replacements }
101 }
102
103 private buildIdsListQuery (options: BuildVideosListQueryOptions) {
104 this.attributes = options.attributes || [ '"video"."id"' ]
105
106 if (options.group) this.group = options.group
107 if (options.having) this.having = options.having
108
109 this.joins = this.joins.concat([
110 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId"',
111 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId"',
112 'INNER JOIN "actor" "accountActor" ON "account"."actorId" = "accountActor"."id"'
113 ])
114
115 this.whereNotBlacklisted()
116
117 if (options.serverAccountId) {
118 this.whereNotBlocked(options.serverAccountId, options.user)
119 }
120
121 // Only list public/published videos
122 if (!options.filter || (options.filter !== 'all-local' && options.filter !== 'all')) {
123 this.whereStateAndPrivacyAvailable(options.user)
124 }
125
126 if (options.videoPlaylistId) {
127 this.joinPlaylist(options.videoPlaylistId)
128 }
129
130 if (options.filter && (options.filter === 'local' || options.filter === 'all-local')) {
131 this.whereOnlyLocal()
132 }
133
134 if (options.accountId) {
135 this.whereAccountId(options.accountId)
136 }
137
138 if (options.videoChannelId) {
139 this.whereChannelId(options.videoChannelId)
140 }
141
142 if (options.followerActorId) {
143 this.whereFollowerActorId(options.followerActorId, options.includeLocalVideos)
144 }
145
146 if (options.withFiles === true) {
147 this.whereFileExists()
148 }
149
150 if (options.tagsOneOf) {
151 this.whereTagsOneOf(options.tagsOneOf)
152 }
153
154 if (options.tagsAllOf) {
155 this.whereTagsAllOf(options.tagsAllOf)
156 }
157
158 if (options.nsfw === true) {
159 this.whereNSFW()
160 } else if (options.nsfw === false) {
161 this.whereSFW()
162 }
163
164 if (options.isLive === true) {
165 this.whereLive()
166 } else if (options.isLive === false) {
167 this.whereVOD()
168 }
169
170 if (options.categoryOneOf) {
171 this.whereCategoryOneOf(options.categoryOneOf)
172 }
173
174 if (options.licenceOneOf) {
175 this.whereLicenceOneOf(options.licenceOneOf)
176 }
177
178 if (options.languageOneOf) {
179 this.whereLanguageOneOf(options.languageOneOf)
180 }
181
182 // We don't exclude results in this so if we do a count we don't need to add this complex clause
183 if (options.isCount !== true) {
184 if (options.trendingDays) {
185 this.groupForTrending(options.trendingDays)
186 } else if ([ 'best', 'hot' ].includes(options.trendingAlgorithm)) {
187 this.groupForHotOrBest(options.trendingAlgorithm, options.user)
188 }
189 }
190
191 if (options.historyOfUser) {
192 this.joinHistory(options.historyOfUser.id)
193 }
194
195 if (options.startDate) {
196 this.whereStartDate(options.startDate)
197 }
198
199 if (options.endDate) {
200 this.whereEndDate(options.endDate)
201 }
202
203 if (options.originallyPublishedStartDate) {
204 this.whereOriginallyPublishedStartDate(options.originallyPublishedStartDate)
205 }
206
207 if (options.originallyPublishedEndDate) {
208 this.whereOriginallyPublishedEndDate(options.originallyPublishedEndDate)
209 }
210
211 if (options.durationMin) {
212 this.whereDurationMin(options.durationMin)
213 }
214
215 if (options.durationMax) {
216 this.whereDurationMax(options.durationMax)
217 }
218
219 this.whereSearch(options.search)
220
221 if (options.isCount === true) {
222 this.setCountAttribute()
223 } else {
224 if (exists(options.sort)) {
225 this.setSort(options.sort)
226 }
227
228 if (exists(options.count)) {
229 this.setLimit(options.count)
230 }
231
232 if (exists(options.start)) {
233 this.setOffset(options.start)
234 }
235 }
236
237 const cteString = this.cte.length !== 0
238 ? `WITH ${this.cte.join(', ')} `
239 : ''
240
241 this.query = cteString +
242 'SELECT ' + this.attributes.join(', ') + ' ' +
243 'FROM "video" ' + this.joins.join(' ') + ' ' +
244 'WHERE ' + this.and.join(' AND ') + ' ' +
245 this.group + ' ' +
246 this.having + ' ' +
247 this.sort + ' ' +
248 this.limit + ' ' +
249 this.offset
250 }
251
252 private setCountAttribute () {
253 this.attributes = [ 'COUNT(*) as "total"' ]
254 }
255
256 private joinHistory (userId: number) {
257 this.joins.push('INNER JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId"')
258
259 this.and.push('"userVideoHistory"."userId" = :historyOfUser')
260
261 this.replacements.historyOfUser = userId
262 }
263
264 private joinPlaylist (playlistId: number) {
265 this.joins.push(
266 'INNER JOIN "videoPlaylistElement" "video"."id" = "videoPlaylistElement"."videoId" ' +
267 'AND "videoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
268 )
269
270 this.replacements.videoPlaylistId = playlistId
271 }
272
273 private whereStateAndPrivacyAvailable (user?: MUserAccountId) {
274 this.and.push(
275 `("video"."state" = ${VideoState.PUBLISHED} OR ` +
276 `("video"."state" = ${VideoState.TO_TRANSCODE} AND "video"."waitTranscoding" IS false))`
277 )
278
279 if (user) {
280 this.and.push(
281 `("video"."privacy" = ${VideoPrivacy.PUBLIC} OR "video"."privacy" = ${VideoPrivacy.INTERNAL})`
282 )
283 } else { // Or only public videos
284 this.and.push(
285 `"video"."privacy" = ${VideoPrivacy.PUBLIC}`
286 )
287 }
288 }
289
290 private whereOnlyLocal () {
291 this.and.push('"video"."remote" IS FALSE')
292 }
293
294 private whereAccountId (accountId: number) {
295 this.and.push('"account"."id" = :accountId')
296 this.replacements.accountId = accountId
297 }
298
299 private whereChannelId (channelId: number) {
300 this.and.push('"videoChannel"."id" = :videoChannelId')
301 this.replacements.videoChannelId = channelId
302 }
303
304 private whereFollowerActorId (followerActorId: number, includeLocalVideos: boolean) {
305 let query =
306 '(' +
307 ' EXISTS (' +
308 ' SELECT 1 FROM "videoShare" ' +
309 ' INNER JOIN "actorFollow" "actorFollowShare" ON "actorFollowShare"."targetActorId" = "videoShare"."actorId" ' +
310 ' AND "actorFollowShare"."actorId" = :followerActorId AND "actorFollowShare"."state" = \'accepted\' ' +
311 ' WHERE "videoShare"."videoId" = "video"."id"' +
312 ' )' +
313 ' OR' +
314 ' EXISTS (' +
315 ' SELECT 1 from "actorFollow" ' +
316 ' WHERE "actorFollow"."targetActorId" = "videoChannel"."actorId" AND "actorFollow"."actorId" = :followerActorId ' +
317 ' AND "actorFollow"."state" = \'accepted\'' +
318 ' )'
319
320 if (includeLocalVideos) {
321 query += ' OR "video"."remote" IS FALSE'
322 }
323
324 query += ')'
325
326 this.and.push(query)
327 this.replacements.followerActorId = followerActorId
328 }
329
330 private whereFileExists () {
331 this.and.push(
332 '(' +
333 ' EXISTS (SELECT 1 FROM "videoFile" WHERE "videoFile"."videoId" = "video"."id") ' +
334 ' OR EXISTS (' +
335 ' SELECT 1 FROM "videoStreamingPlaylist" ' +
336 ' INNER JOIN "videoFile" ON "videoFile"."videoStreamingPlaylistId" = "videoStreamingPlaylist"."id" ' +
337 ' WHERE "videoStreamingPlaylist"."videoId" = "video"."id"' +
338 ' )' +
339 ')'
340 )
341 }
342
343 private whereTagsOneOf (tagsOneOf: string[]) {
344 const tagsOneOfLower = tagsOneOf.map(t => t.toLowerCase())
345
346 this.and.push(
347 'EXISTS (' +
348 ' SELECT 1 FROM "videoTag" ' +
349 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
350 ' WHERE lower("tag"."name") IN (' + createSafeIn(this.sequelize, tagsOneOfLower) + ') ' +
351 ' AND "video"."id" = "videoTag"."videoId"' +
352 ')'
353 )
354 }
355
356 private whereTagsAllOf (tagsAllOf: string[]) {
357 const tagsAllOfLower = tagsAllOf.map(t => t.toLowerCase())
358
359 this.and.push(
360 'EXISTS (' +
361 ' SELECT 1 FROM "videoTag" ' +
362 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
363 ' WHERE lower("tag"."name") IN (' + createSafeIn(this.sequelize, tagsAllOfLower) + ') ' +
364 ' AND "video"."id" = "videoTag"."videoId" ' +
365 ' GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + tagsAllOfLower.length +
366 ')'
367 )
368 }
369
370 private whereCategoryOneOf (categoryOneOf: number[]) {
371 this.and.push('"video"."category" IN (:categoryOneOf)')
372 this.replacements.categoryOneOf = categoryOneOf
373 }
374
375 private whereLicenceOneOf (licenceOneOf: number[]) {
376 this.and.push('"video"."licence" IN (:licenceOneOf)')
377 this.replacements.licenceOneOf = licenceOneOf
378 }
379
380 private whereLanguageOneOf (languageOneOf: string[]) {
381 const languages = languageOneOf.filter(l => l && l !== '_unknown')
382 const languagesQueryParts: string[] = []
383
384 if (languages.length !== 0) {
385 languagesQueryParts.push('"video"."language" IN (:languageOneOf)')
386 this.replacements.languageOneOf = languages
387
388 languagesQueryParts.push(
389 'EXISTS (' +
390 ' SELECT 1 FROM "videoCaption" WHERE "videoCaption"."language" ' +
391 ' IN (' + createSafeIn(this.sequelize, languages) + ') AND ' +
392 ' "videoCaption"."videoId" = "video"."id"' +
393 ')'
394 )
395 }
396
397 if (languageOneOf.includes('_unknown')) {
398 languagesQueryParts.push('"video"."language" IS NULL')
399 }
400
401 if (languagesQueryParts.length !== 0) {
402 this.and.push('(' + languagesQueryParts.join(' OR ') + ')')
403 }
404 }
405
406 private whereNSFW () {
407 this.and.push('"video"."nsfw" IS TRUE')
408 }
409
410 private whereSFW () {
411 this.and.push('"video"."nsfw" IS FALSE')
412 }
413
414 private whereLive () {
415 this.and.push('"video"."isLive" IS TRUE')
416 }
417
418 private whereVOD () {
419 this.and.push('"video"."isLive" IS FALSE')
420 }
421
422 private whereNotBlocked (serverAccountId: number, user?: MUserAccountId) {
423 const blockerIds = [ serverAccountId ]
424 if (user) blockerIds.push(user.Account.id)
425
426 const inClause = createSafeIn(this.sequelize, blockerIds)
427
428 this.and.push(
429 'NOT EXISTS (' +
430 ' SELECT 1 FROM "accountBlocklist" ' +
431 ' WHERE "accountBlocklist"."accountId" IN (' + inClause + ') ' +
432 ' AND "accountBlocklist"."targetAccountId" = "account"."id" ' +
433 ')' +
434 'AND NOT EXISTS (' +
435 ' SELECT 1 FROM "serverBlocklist" WHERE "serverBlocklist"."accountId" IN (' + inClause + ') ' +
436 ' AND "serverBlocklist"."targetServerId" = "accountActor"."serverId"' +
437 ')'
438 )
439 }
440
441 private whereSearch (search?: string) {
442 if (!search) {
443 this.attributes.push('0 as similarity')
444 return
445 }
446
447 const escapedSearch = this.sequelize.escape(search)
448 const escapedLikeSearch = this.sequelize.escape('%' + search + '%')
449
450 this.cte.push(
451 '"trigramSearch" AS (' +
452 ' SELECT "video"."id", ' +
453 ` similarity(lower(immutable_unaccent("video"."name")), lower(immutable_unaccent(${escapedSearch}))) as similarity ` +
454 ' FROM "video" ' +
455 ' WHERE lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
456 ' lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
457 ')'
458 )
459
460 this.joins.push('LEFT JOIN "trigramSearch" ON "video"."id" = "trigramSearch"."id"')
461
462 let base = '(' +
463 ' "trigramSearch"."id" IS NOT NULL OR ' +
464 ' EXISTS (' +
465 ' SELECT 1 FROM "videoTag" ' +
466 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
467 ` WHERE lower("tag"."name") = ${escapedSearch} ` +
468 ' AND "video"."id" = "videoTag"."videoId"' +
469 ' )'
470
471 if (validator.isUUID(search)) {
472 base += ` OR "video"."uuid" = ${escapedSearch}`
473 }
474
475 base += ')'
476
477 this.and.push(base)
478 this.attributes.push(`COALESCE("trigramSearch"."similarity", 0) as similarity`)
479 }
480
481 private whereNotBlacklisted () {
482 this.and.push('"video"."id" NOT IN (SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")')
483 }
484
485 private whereStartDate (startDate: string) {
486 this.and.push('"video"."publishedAt" >= :startDate')
487 this.replacements.startDate = startDate
488 }
489
490 private whereEndDate (endDate: string) {
491 this.and.push('"video"."publishedAt" <= :endDate')
492 this.replacements.endDate = endDate
493 }
494
495 private whereOriginallyPublishedStartDate (startDate: string) {
496 this.and.push('"video"."originallyPublishedAt" >= :originallyPublishedStartDate')
497 this.replacements.originallyPublishedStartDate = startDate
498 }
499
500 private whereOriginallyPublishedEndDate (endDate: string) {
501 this.and.push('"video"."originallyPublishedAt" <= :originallyPublishedEndDate')
502 this.replacements.originallyPublishedEndDate = endDate
503 }
504
505 private whereDurationMin (durationMin: number) {
506 this.and.push('"video"."duration" >= :durationMin')
507 this.replacements.durationMin = durationMin
508 }
509
510 private whereDurationMax (durationMax: number) {
511 this.and.push('"video"."duration" <= :durationMax')
512 this.replacements.durationMax = durationMax
513 }
514
515 private groupForTrending (trendingDays: number) {
516 const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * trendingDays)
517
518 this.joins.push('LEFT JOIN "videoView" ON "video"."id" = "videoView"."videoId" AND "videoView"."startDate" >= :viewsGteDate')
519 this.replacements.viewsGteDate = viewsGteDate
520
521 this.attributes.push('COALESCE(SUM("videoView"."views"), 0) AS "score"')
522
523 this.group = 'GROUP BY "video"."id"'
524 }
525
526 private groupForHotOrBest (trendingAlgorithm: string, user?: MUserAccountId) {
527 /**
528 * "Hotness" is a measure based on absolute view/comment/like/dislike numbers,
529 * with fixed weights only applied to their log values.
530 *
531 * This algorithm gives little chance for an old video to have a good score,
532 * for which recent spikes in interactions could be a sign of "hotness" and
533 * justify a better score. However there are multiple ways to achieve that
534 * goal, which is left for later. Yes, this is a TODO :)
535 *
536 * notes:
537 * - weights and base score are in number of half-days.
538 * - all comments are counted, regardless of being written by the video author or not
539 * see https://github.com/reddit-archive/reddit/blob/master/r2/r2/lib/db/_sorts.pyx#L47-L58
540 * - we have less interactions than on reddit, so multiply weights by an arbitrary factor
541 */
542 const weights = {
543 like: 3 * 50,
544 dislike: -3 * 50,
545 view: Math.floor((1 / 3) * 50),
546 comment: 2 * 50, // a comment takes more time than a like to do, but can be done multiple times
547 history: -2 * 50
548 }
549
550 this.joins.push('LEFT JOIN "videoComment" ON "video"."id" = "videoComment"."videoId"')
551
552 let attribute =
553 `LOG(GREATEST(1, "video"."likes" - 1)) * ${weights.like} ` + // likes (+)
554 `+ LOG(GREATEST(1, "video"."dislikes" - 1)) * ${weights.dislike} ` + // dislikes (-)
555 `+ LOG("video"."views" + 1) * ${weights.view} ` + // views (+)
556 `+ LOG(GREATEST(1, COUNT(DISTINCT "videoComment"."id"))) * ${weights.comment} ` + // comments (+)
557 '+ (SELECT (EXTRACT(epoch FROM "video"."publishedAt") - 1446156582) / 47000) ' // base score (in number of half-days)
558
559 if (trendingAlgorithm === 'best' && user) {
560 this.joins.push(
561 'LEFT JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :bestUser'
562 )
563 this.replacements.bestUser = user.id
564
565 attribute += `+ POWER(COUNT(DISTINCT "userVideoHistory"."id"), 2.0) * ${weights.history} `
566 }
567
568 attribute += 'AS "score"'
569 this.attributes.push(attribute)
570
571 this.group = 'GROUP BY "video"."id"'
572 }
573
574 private setSort (sort: string) {
575 if (sort === '-originallyPublishedAt' || sort === 'originallyPublishedAt') {
576 this.attributes.push('COALESCE("video"."originallyPublishedAt", "video"."publishedAt") AS "publishedAtForOrder"')
577 }
578
579 this.sort = this.buildOrder(sort)
580 }
581
582 private buildOrder (value: string) {
583 const { direction, field } = buildDirectionAndField(value)
584 if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
585
586 if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
587
588 if ([ 'trending', 'hot', 'best' ].includes(field.toLowerCase())) { // Sort by aggregation
589 return `ORDER BY "score" ${direction}, "video"."views" ${direction}`
590 }
591
592 let firstSort: string
593
594 if (field.toLowerCase() === 'match') { // Search
595 firstSort = '"similarity"'
596 } else if (field === 'originallyPublishedAt') {
597 firstSort = '"publishedAtForOrder"'
598 } else if (field.includes('.')) {
599 firstSort = field
600 } else {
601 firstSort = `"video"."${field}"`
602 }
603
604 return `ORDER BY ${firstSort} ${direction}, "video"."id" ASC`
605 }
606
607 private setLimit (countArg: number) {
608 const count = parseInt(countArg + '', 10)
609 this.limit = `LIMIT ${count}`
610 }
611
612 private setOffset (startArg: number) {
613 const start = parseInt(startArg + '', 10)
614 this.offset = `OFFSET ${start}`
615 }
616}
diff --git a/server/models/video/sql/videos-model-list-query-builder.ts b/server/models/video/sql/videos-model-list-query-builder.ts
new file mode 100644
index 000000000..e61c51de8
--- /dev/null
+++ b/server/models/video/sql/videos-model-list-query-builder.ts
@@ -0,0 +1,71 @@
1import { Sequelize } from 'sequelize'
2import { AbstractVideosModelQueryBuilder } from './shared/abstract-videos-model-query-builder'
3import { VideoModelBuilder } from './shared/video-model-builder'
4import { BuildVideosListQueryOptions, VideosIdListQueryBuilder } from './videos-id-list-query-builder'
5
6/**
7 *
8 * Build videos list SQL query and create video models
9 *
10 */
11
12export class VideosModelListQueryBuilder extends AbstractVideosModelQueryBuilder {
13 protected attributes: { [key: string]: string }
14
15 private innerQuery: string
16 private innerSort: string
17
18 private readonly videoModelBuilder: VideoModelBuilder
19
20 constructor (protected readonly sequelize: Sequelize) {
21 super('list')
22
23 this.videoModelBuilder = new VideoModelBuilder(this.mode, this.tables)
24 }
25
26 queryVideos (options: BuildVideosListQueryOptions) {
27 this.buildInnerQuery(options)
28 this.buildListQueryFromIdsQuery(options)
29
30 return this.runQuery()
31 .then(rows => this.videoModelBuilder.buildVideosFromRows(rows))
32 }
33
34 private buildInnerQuery (options: BuildVideosListQueryOptions) {
35 const idsQueryBuilder = new VideosIdListQueryBuilder(this.sequelize)
36 const { query, sort, replacements } = idsQueryBuilder.getIdsListQueryAndSort(options)
37
38 this.replacements = replacements
39 this.innerQuery = query
40 this.innerSort = sort
41 }
42
43 private buildListQueryFromIdsQuery (options: BuildVideosListQueryOptions) {
44 this.attributes = {
45 '"video".*': ''
46 }
47
48 this.addJoin('INNER JOIN "video" ON "tmp"."id" = "video"."id"')
49
50 this.includeChannels()
51 this.includeAccounts()
52 this.includeThumbnails()
53
54 if (options.withFiles) {
55 this.includeWebtorrentFiles()
56 this.includeStreamingPlaylistFiles()
57 }
58
59 if (options.user) {
60 this.includeUserHistory(options.user.id)
61 }
62
63 if (options.videoPlaylistId) {
64 this.includePlaylist(options.videoPlaylistId)
65 }
66
67 const select = this.buildSelect()
68
69 this.query = `${select} FROM (${this.innerQuery}) AS "tmp" ${this.joins} ${this.innerSort}`
70 }
71}
diff --git a/server/models/video/tag.ts b/server/models/video/tag.ts
index d04205703..c1eebe27f 100644
--- a/server/models/video/tag.ts
+++ b/server/models/video/tag.ts
@@ -1,6 +1,7 @@
1import { col, fn, QueryTypes, Transaction } from 'sequelize' 1import { col, fn, QueryTypes, Transaction } from 'sequelize'
2import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, BelongsToMany, Column, CreatedAt, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MTag } from '@server/types/models' 3import { MTag } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils'
4import { VideoPrivacy, VideoState } from '../../../shared/models/videos' 5import { VideoPrivacy, VideoState } from '../../../shared/models/videos'
5import { isVideoTagValid } from '../../helpers/custom-validators/videos' 6import { isVideoTagValid } from '../../helpers/custom-validators/videos'
6import { throwIfNotValid } from '../utils' 7import { throwIfNotValid } from '../utils'
@@ -21,7 +22,7 @@ import { VideoTagModel } from './video-tag'
21 } 22 }
22 ] 23 ]
23}) 24})
24export class TagModel extends Model { 25export class TagModel extends Model<Partial<AttributesOnly<TagModel>>> {
25 26
26 @AllowNull(false) 27 @AllowNull(false)
27 @Is('VideoTag', value => throwIfNotValid(value, isVideoTagValid, 'tag')) 28 @Is('VideoTag', value => throwIfNotValid(value, isVideoTagValid, 'tag'))
diff --git a/server/models/video/thumbnail.ts b/server/models/video/thumbnail.ts
index f1187c8d6..3388478d9 100644
--- a/server/models/video/thumbnail.ts
+++ b/server/models/video/thumbnail.ts
@@ -17,6 +17,7 @@ import {
17} from 'sequelize-typescript' 17} from 'sequelize-typescript'
18import { afterCommitIfTransaction } from '@server/helpers/database-utils' 18import { afterCommitIfTransaction } from '@server/helpers/database-utils'
19import { MThumbnail, MThumbnailVideo, MVideo } from '@server/types/models' 19import { MThumbnail, MThumbnailVideo, MVideo } from '@server/types/models'
20import { AttributesOnly } from '@shared/core-utils'
20import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type' 21import { ThumbnailType } from '../../../shared/models/videos/thumbnail.type'
21import { logger } from '../../helpers/logger' 22import { logger } from '../../helpers/logger'
22import { CONFIG } from '../../initializers/config' 23import { CONFIG } from '../../initializers/config'
@@ -40,7 +41,7 @@ import { VideoPlaylistModel } from './video-playlist'
40 } 41 }
41 ] 42 ]
42}) 43})
43export class ThumbnailModel extends Model { 44export class ThumbnailModel extends Model<Partial<AttributesOnly<ThumbnailModel>>> {
44 45
45 @AllowNull(false) 46 @AllowNull(false)
46 @Column 47 @Column
diff --git a/server/models/video/video-blacklist.ts b/server/models/video/video-blacklist.ts
index aa18896da..98f4ec9c5 100644
--- a/server/models/video/video-blacklist.ts
+++ b/server/models/video/video-blacklist.ts
@@ -1,6 +1,7 @@
1import { FindOptions } from 'sequelize' 1import { FindOptions } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, Default, ForeignKey, Is, Model, Table, UpdatedAt } from 'sequelize-typescript'
3import { MVideoBlacklist, MVideoBlacklistFormattable } from '@server/types/models' 3import { MVideoBlacklist, MVideoBlacklistFormattable } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils'
4import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos' 5import { VideoBlacklist, VideoBlacklistType } from '../../../shared/models/videos'
5import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist' 6import { isVideoBlacklistReasonValid, isVideoBlacklistTypeValid } from '../../helpers/custom-validators/video-blacklist'
6import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 7import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
@@ -18,7 +19,7 @@ import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel
18 } 19 }
19 ] 20 ]
20}) 21})
21export class VideoBlacklistModel extends Model { 22export class VideoBlacklistModel extends Model<Partial<AttributesOnly<VideoBlacklistModel>>> {
22 23
23 @AllowNull(true) 24 @AllowNull(true)
24 @Is('VideoBlacklistReason', value => throwIfNotValid(value, isVideoBlacklistReasonValid, 'reason', true)) 25 @Is('VideoBlacklistReason', value => throwIfNotValid(value, isVideoBlacklistReasonValid, 'reason', true))
diff --git a/server/models/video/video-caption.ts b/server/models/video/video-caption.ts
index bfdec73e9..d24be56c3 100644
--- a/server/models/video/video-caption.ts
+++ b/server/models/video/video-caption.ts
@@ -15,8 +15,9 @@ import {
15 Table, 15 Table,
16 UpdatedAt 16 UpdatedAt
17} from 'sequelize-typescript' 17} from 'sequelize-typescript'
18import { v4 as uuidv4 } from 'uuid' 18import { buildUUID } from '@server/helpers/uuid'
19import { MVideo, MVideoCaption, MVideoCaptionFormattable, MVideoCaptionVideo } from '@server/types/models' 19import { MVideo, MVideoCaption, MVideoCaptionFormattable, MVideoCaptionVideo } from '@server/types/models'
20import { AttributesOnly } from '@shared/core-utils'
20import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model' 21import { VideoCaption } from '../../../shared/models/videos/caption/video-caption.model'
21import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions' 22import { isVideoCaptionLanguageValid } from '../../helpers/custom-validators/video-captions'
22import { logger } from '../../helpers/logger' 23import { logger } from '../../helpers/logger'
@@ -57,7 +58,7 @@ export enum ScopeNames {
57 } 58 }
58 ] 59 ]
59}) 60})
60export class VideoCaptionModel extends Model { 61export class VideoCaptionModel extends Model<Partial<AttributesOnly<VideoCaptionModel>>> {
61 @CreatedAt 62 @CreatedAt
62 createdAt: Date 63 createdAt: Date
63 64
@@ -90,9 +91,9 @@ export class VideoCaptionModel extends Model {
90 Video: VideoModel 91 Video: VideoModel
91 92
92 @BeforeDestroy 93 @BeforeDestroy
93 static async removeFiles (instance: VideoCaptionModel) { 94 static async removeFiles (instance: VideoCaptionModel, options) {
94 if (!instance.Video) { 95 if (!instance.Video) {
95 instance.Video = await instance.$get('Video') 96 instance.Video = await instance.$get('Video', { transaction: options.transaction })
96 } 97 }
97 98
98 if (instance.isOwned()) { 99 if (instance.isOwned()) {
@@ -108,7 +109,7 @@ export class VideoCaptionModel extends Model {
108 return undefined 109 return undefined
109 } 110 }
110 111
111 static loadByVideoIdAndLanguage (videoId: string | number, language: string): Promise<MVideoCaptionVideo> { 112 static loadByVideoIdAndLanguage (videoId: string | number, language: string, transaction?: Transaction): Promise<MVideoCaptionVideo> {
112 const videoInclude = { 113 const videoInclude = {
113 model: VideoModel.unscoped(), 114 model: VideoModel.unscoped(),
114 attributes: [ 'id', 'remote', 'uuid' ], 115 attributes: [ 'id', 'remote', 'uuid' ],
@@ -121,7 +122,8 @@ export class VideoCaptionModel extends Model {
121 }, 122 },
122 include: [ 123 include: [
123 videoInclude 124 videoInclude
124 ] 125 ],
126 transaction
125 } 127 }
126 128
127 return VideoCaptionModel.findOne(query) 129 return VideoCaptionModel.findOne(query)
@@ -144,19 +146,21 @@ export class VideoCaptionModel extends Model {
144 } 146 }
145 147
146 static async insertOrReplaceLanguage (caption: MVideoCaption, transaction: Transaction) { 148 static async insertOrReplaceLanguage (caption: MVideoCaption, transaction: Transaction) {
147 const existing = await VideoCaptionModel.loadByVideoIdAndLanguage(caption.videoId, caption.language) 149 const existing = await VideoCaptionModel.loadByVideoIdAndLanguage(caption.videoId, caption.language, transaction)
150
148 // Delete existing file 151 // Delete existing file
149 if (existing) await existing.destroy({ transaction }) 152 if (existing) await existing.destroy({ transaction })
150 153
151 return caption.save({ transaction }) 154 return caption.save({ transaction })
152 } 155 }
153 156
154 static listVideoCaptions (videoId: number): Promise<MVideoCaptionVideo[]> { 157 static listVideoCaptions (videoId: number, transaction?: Transaction): Promise<MVideoCaptionVideo[]> {
155 const query = { 158 const query = {
156 order: [ [ 'language', 'ASC' ] ] as OrderItem[], 159 order: [ [ 'language', 'ASC' ] ] as OrderItem[],
157 where: { 160 where: {
158 videoId 161 videoId
159 } 162 },
163 transaction
160 } 164 }
161 165
162 return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query) 166 return VideoCaptionModel.scope(ScopeNames.WITH_VIDEO_UUID_AND_REMOTE).findAll(query)
@@ -178,7 +182,7 @@ export class VideoCaptionModel extends Model {
178 } 182 }
179 183
180 static generateCaptionName (language: string) { 184 static generateCaptionName (language: string) {
181 return `${uuidv4()}-${language}.vtt` 185 return `${buildUUID()}-${language}.vtt`
182 } 186 }
183 187
184 isOwned () { 188 isOwned () {
@@ -210,4 +214,10 @@ export class VideoCaptionModel extends Model {
210 214
211 return this.fileUrl 215 return this.fileUrl
212 } 216 }
217
218 isEqual (this: MVideoCaption, other: MVideoCaption) {
219 if (this.fileUrl) return this.fileUrl === other.fileUrl
220
221 return this.filename === other.filename
222 }
213} 223}
diff --git a/server/models/video/video-change-ownership.ts b/server/models/video/video-change-ownership.ts
index 298e8bfe2..7d20a954d 100644
--- a/server/models/video/video-change-ownership.ts
+++ b/server/models/video/video-change-ownership.ts
@@ -1,5 +1,6 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
2import { MVideoChangeOwnershipFormattable, MVideoChangeOwnershipFull } from '@server/types/models/video/video-change-ownership' 2import { MVideoChangeOwnershipFormattable, MVideoChangeOwnershipFull } from '@server/types/models/video/video-change-ownership'
3import { AttributesOnly } from '@shared/core-utils'
3import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos' 4import { VideoChangeOwnership, VideoChangeOwnershipStatus } from '../../../shared/models/videos'
4import { AccountModel } from '../account/account' 5import { AccountModel } from '../account/account'
5import { getSort } from '../utils' 6import { getSort } from '../utils'
@@ -53,7 +54,7 @@ enum ScopeNames {
53 ] 54 ]
54 } 55 }
55})) 56}))
56export class VideoChangeOwnershipModel extends Model { 57export class VideoChangeOwnershipModel extends Model<Partial<AttributesOnly<VideoChangeOwnershipModel>>> {
57 @CreatedAt 58 @CreatedAt
58 createdAt: Date 59 createdAt: Date
59 60
diff --git a/server/models/video/video-channel.ts b/server/models/video/video-channel.ts
index 081b21f2d..183e7448c 100644
--- a/server/models/video/video-channel.ts
+++ b/server/models/video/video-channel.ts
@@ -19,6 +19,7 @@ import {
19} from 'sequelize-typescript' 19} from 'sequelize-typescript'
20import { setAsUpdated } from '@server/helpers/database-utils' 20import { setAsUpdated } from '@server/helpers/database-utils'
21import { MAccountActor } from '@server/types/models' 21import { MAccountActor } from '@server/types/models'
22import { AttributesOnly } from '@shared/core-utils'
22import { ActivityPubActor } from '../../../shared/models/activitypub' 23import { ActivityPubActor } from '../../../shared/models/activitypub'
23import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos' 24import { VideoChannel, VideoChannelSummary } from '../../../shared/models/videos'
24import { 25import {
@@ -36,9 +37,9 @@ import {
36 MChannelSummaryFormattable 37 MChannelSummaryFormattable
37} from '../../types/models/video' 38} from '../../types/models/video'
38import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account' 39import { AccountModel, ScopeNames as AccountModelScopeNames, SummaryOptions as AccountSummaryOptions } from '../account/account'
39import { ActorImageModel } from '../account/actor-image' 40import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
40import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor' 41import { ActorFollowModel } from '../actor/actor-follow'
41import { ActorFollowModel } from '../activitypub/actor-follow' 42import { ActorImageModel } from '../actor/actor-image'
42import { ServerModel } from '../server/server' 43import { ServerModel } from '../server/server'
43import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils' 44import { buildServerIdsFollowedBy, buildTrigramSearchIndex, createSimilarityAttribute, getSort, throwIfNotValid } from '../utils'
44import { VideoModel } from './video' 45import { VideoModel } from './video'
@@ -246,7 +247,7 @@ export type SummaryOptions = {
246 } 247 }
247 ] 248 ]
248}) 249})
249export class VideoChannelModel extends Model { 250export class VideoChannelModel extends Model<Partial<AttributesOnly<VideoChannelModel>>> {
250 251
251 @AllowNull(false) 252 @AllowNull(false)
252 @Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelNameValid, 'name')) 253 @Is('VideoChannelName', value => throwIfNotValid(value, isVideoChannelNameValid, 'name'))
@@ -290,8 +291,7 @@ export class VideoChannelModel extends Model {
290 @BelongsTo(() => AccountModel, { 291 @BelongsTo(() => AccountModel, {
291 foreignKey: { 292 foreignKey: {
292 allowNull: false 293 allowNull: false
293 }, 294 }
294 hooks: true
295 }) 295 })
296 Account: AccountModel 296 Account: AccountModel
297 297
@@ -433,8 +433,8 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
433 sort: string 433 sort: string
434 }) { 434 }) {
435 const attributesInclude = [] 435 const attributesInclude = []
436 const escapedSearch = VideoModel.sequelize.escape(options.search) 436 const escapedSearch = VideoChannelModel.sequelize.escape(options.search)
437 const escapedLikeSearch = VideoModel.sequelize.escape('%' + options.search + '%') 437 const escapedLikeSearch = VideoChannelModel.sequelize.escape('%' + options.search + '%')
438 attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search)) 438 attributesInclude.push(createSimilarityAttribute('VideoChannelModel.name', options.search))
439 439
440 const query = { 440 const query = {
@@ -521,10 +521,10 @@ ON "Account->Actor"."serverId" = "Account->Actor->Server"."id"`
521 }) 521 })
522 } 522 }
523 523
524 static loadAndPopulateAccount (id: number): Promise<MChannelBannerAccountDefault> { 524 static loadAndPopulateAccount (id: number, transaction?: Transaction): Promise<MChannelBannerAccountDefault> {
525 return VideoChannelModel.unscoped() 525 return VideoChannelModel.unscoped()
526 .scope([ ScopeNames.WITH_ACTOR_BANNER, ScopeNames.WITH_ACCOUNT ]) 526 .scope([ ScopeNames.WITH_ACTOR_BANNER, ScopeNames.WITH_ACCOUNT ])
527 .findByPk(id) 527 .findByPk(id, { transaction })
528 } 528 }
529 529
530 static loadByUrlAndPopulateAccount (url: string): Promise<MChannelBannerAccountDefault> { 530 static loadByUrlAndPopulateAccount (url: string): Promise<MChannelBannerAccountDefault> {
diff --git a/server/models/video/video-comment.ts b/server/models/video/video-comment.ts
index 151c2bc81..e933989ae 100644
--- a/server/models/video/video-comment.ts
+++ b/server/models/video/video-comment.ts
@@ -16,10 +16,11 @@ import {
16} from 'sequelize-typescript' 16} from 'sequelize-typescript'
17import { getServerActor } from '@server/models/application/application' 17import { getServerActor } from '@server/models/application/application'
18import { MAccount, MAccountId, MUserAccountId } from '@server/types/models' 18import { MAccount, MAccountId, MUserAccountId } from '@server/types/models'
19import { AttributesOnly } from '@shared/core-utils'
19import { VideoPrivacy } from '@shared/models' 20import { VideoPrivacy } from '@shared/models'
20import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects' 21import { ActivityTagObject, ActivityTombstoneObject } from '../../../shared/models/activitypub/objects/common-objects'
21import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object' 22import { VideoCommentObject } from '../../../shared/models/activitypub/objects/video-comment-object'
22import { VideoComment, VideoCommentAdmin } from '../../../shared/models/videos/video-comment.model' 23import { VideoComment, VideoCommentAdmin } from '../../../shared/models/videos/comment/video-comment.model'
23import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor' 24import { actorNameAlphabet } from '../../helpers/custom-validators/activitypub/actor'
24import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 25import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
25import { regexpCapture } from '../../helpers/regexp' 26import { regexpCapture } from '../../helpers/regexp'
@@ -39,7 +40,7 @@ import {
39} from '../../types/models/video' 40} from '../../types/models/video'
40import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse' 41import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
41import { AccountModel } from '../account/account' 42import { AccountModel } from '../account/account'
42import { ActorModel, unusedActorAttributesForAPI } from '../activitypub/actor' 43import { ActorModel, unusedActorAttributesForAPI } from '../actor/actor'
43import { 44import {
44 buildBlockedAccountSQL, 45 buildBlockedAccountSQL,
45 buildBlockedAccountSQLOptimized, 46 buildBlockedAccountSQLOptimized,
@@ -68,14 +69,10 @@ export enum ScopeNames {
68 Sequelize.literal( 69 Sequelize.literal(
69 '(' + 70 '(' +
70 'WITH "blocklist" AS (' + buildBlockedAccountSQL(blockerAccountIds) + ')' + 71 'WITH "blocklist" AS (' + buildBlockedAccountSQL(blockerAccountIds) + ')' +
71 'SELECT COUNT("replies"."id") - (' + 72 'SELECT COUNT("replies"."id") ' +
72 'SELECT COUNT("replies"."id") ' +
73 'FROM "videoComment" AS "replies" ' +
74 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
75 'AND "accountId" IN (SELECT "id" FROM "blocklist")' +
76 ')' +
77 'FROM "videoComment" AS "replies" ' + 73 'FROM "videoComment" AS "replies" ' +
78 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' + 74 'WHERE "replies"."originCommentId" = "VideoCommentModel"."id" ' +
75 'AND "deletedAt" IS NULL ' +
79 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' + 76 'AND "accountId" NOT IN (SELECT "id" FROM "blocklist")' +
80 ')' 77 ')'
81 ), 78 ),
@@ -173,7 +170,7 @@ export enum ScopeNames {
173 } 170 }
174 ] 171 ]
175}) 172})
176export class VideoCommentModel extends Model { 173export class VideoCommentModel extends Model<Partial<AttributesOnly<VideoCommentModel>>> {
177 @CreatedAt 174 @CreatedAt
178 createdAt: Date 175 createdAt: Date
179 176
@@ -742,6 +739,12 @@ export class VideoCommentModel extends Model {
742 return this.Account.isOwned() 739 return this.Account.isOwned()
743 } 740 }
744 741
742 markAsDeleted () {
743 this.text = ''
744 this.deletedAt = new Date()
745 this.accountId = null
746 }
747
745 isDeleted () { 748 isDeleted () {
746 return this.deletedAt !== null 749 return this.deletedAt !== null
747 } 750 }
diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts
index 0b5946149..22cf63804 100644
--- a/server/models/video/video-file.ts
+++ b/server/models/video/video-file.ts
@@ -25,6 +25,7 @@ import { logger } from '@server/helpers/logger'
25import { extractVideo } from '@server/helpers/video' 25import { extractVideo } from '@server/helpers/video'
26import { getTorrentFilePath } from '@server/lib/video-paths' 26import { getTorrentFilePath } from '@server/lib/video-paths'
27import { MStreamingPlaylistVideo, MVideo, MVideoWithHost } from '@server/types/models' 27import { MStreamingPlaylistVideo, MVideo, MVideoWithHost } from '@server/types/models'
28import { AttributesOnly } from '@shared/core-utils'
28import { 29import {
29 isVideoFileExtnameValid, 30 isVideoFileExtnameValid,
30 isVideoFileInfoHashValid, 31 isVideoFileInfoHashValid,
@@ -149,7 +150,7 @@ export enum ScopeNames {
149 } 150 }
150 ] 151 ]
151}) 152})
152export class VideoFileModel extends Model { 153export class VideoFileModel extends Model<Partial<AttributesOnly<VideoFileModel>>> {
153 @CreatedAt 154 @CreatedAt
154 createdAt: Date 155 createdAt: Date
155 156
diff --git a/server/models/video/video-import.ts b/server/models/video/video-import.ts
index 8324166cc..5c73fb07c 100644
--- a/server/models/video/video-import.ts
+++ b/server/models/video/video-import.ts
@@ -13,15 +13,16 @@ import {
13 Table, 13 Table,
14 UpdatedAt 14 UpdatedAt
15} from 'sequelize-typescript' 15} from 'sequelize-typescript'
16import { afterCommitIfTransaction } from '@server/helpers/database-utils'
16import { MVideoImportDefault, MVideoImportFormattable } from '@server/types/models/video/video-import' 17import { MVideoImportDefault, MVideoImportFormattable } from '@server/types/models/video/video-import'
18import { AttributesOnly } from '@shared/core-utils'
17import { VideoImport, VideoImportState } from '../../../shared' 19import { VideoImport, VideoImportState } from '../../../shared'
18import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports' 20import { isVideoImportStateValid, isVideoImportTargetUrlValid } from '../../helpers/custom-validators/video-imports'
19import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos' 21import { isVideoMagnetUriValid } from '../../helpers/custom-validators/videos'
20import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants' 22import { CONSTRAINTS_FIELDS, VIDEO_IMPORT_STATES } from '../../initializers/constants'
21import { UserModel } from '../account/user' 23import { UserModel } from '../user/user'
22import { getSort, throwIfNotValid } from '../utils' 24import { getSort, throwIfNotValid } from '../utils'
23import { ScopeNames as VideoModelScopeNames, VideoModel } from './video' 25import { ScopeNames as VideoModelScopeNames, VideoModel } from './video'
24import { afterCommitIfTransaction } from '@server/helpers/database-utils'
25 26
26@DefaultScope(() => ({ 27@DefaultScope(() => ({
27 include: [ 28 include: [
@@ -52,7 +53,7 @@ import { afterCommitIfTransaction } from '@server/helpers/database-utils'
52 } 53 }
53 ] 54 ]
54}) 55})
55export class VideoImportModel extends Model { 56export class VideoImportModel extends Model<Partial<AttributesOnly<VideoImportModel>>> {
56 @CreatedAt 57 @CreatedAt
57 createdAt: Date 58 createdAt: Date
58 59
diff --git a/server/models/video/video-live.ts b/server/models/video/video-live.ts
index cb4a9b896..014491d50 100644
--- a/server/models/video/video-live.ts
+++ b/server/models/video/video-live.ts
@@ -1,6 +1,7 @@
1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, DefaultScope, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { AllowNull, BelongsTo, Column, CreatedAt, DataType, DefaultScope, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { WEBSERVER } from '@server/initializers/constants' 2import { WEBSERVER } from '@server/initializers/constants'
3import { MVideoLive, MVideoLiveVideo } from '@server/types/models' 3import { MVideoLive, MVideoLiveVideo } from '@server/types/models'
4import { AttributesOnly } from '@shared/core-utils'
4import { LiveVideo, VideoState } from '@shared/models' 5import { LiveVideo, VideoState } from '@shared/models'
5import { VideoModel } from './video' 6import { VideoModel } from './video'
6import { VideoBlacklistModel } from './video-blacklist' 7import { VideoBlacklistModel } from './video-blacklist'
@@ -28,7 +29,7 @@ import { VideoBlacklistModel } from './video-blacklist'
28 } 29 }
29 ] 30 ]
30}) 31})
31export class VideoLiveModel extends Model { 32export class VideoLiveModel extends Model<Partial<AttributesOnly<VideoLiveModel>>> {
32 33
33 @AllowNull(true) 34 @AllowNull(true)
34 @Column(DataType.STRING) 35 @Column(DataType.STRING)
diff --git a/server/models/video/video-playlist-element.ts b/server/models/video/video-playlist-element.ts
index d2d7e2740..e6906cb19 100644
--- a/server/models/video/video-playlist-element.ts
+++ b/server/models/video/video-playlist-element.ts
@@ -32,6 +32,7 @@ import { AccountModel } from '../account/account'
32import { getSort, throwIfNotValid } from '../utils' 32import { getSort, throwIfNotValid } from '../utils'
33import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video' 33import { ForAPIOptions, ScopeNames as VideoScopeNames, VideoModel } from './video'
34import { VideoPlaylistModel } from './video-playlist' 34import { VideoPlaylistModel } from './video-playlist'
35import { AttributesOnly } from '@shared/core-utils'
35 36
36@Table({ 37@Table({
37 tableName: 'videoPlaylistElement', 38 tableName: 'videoPlaylistElement',
@@ -48,7 +49,7 @@ import { VideoPlaylistModel } from './video-playlist'
48 } 49 }
49 ] 50 ]
50}) 51})
51export class VideoPlaylistElementModel extends Model { 52export class VideoPlaylistElementModel extends Model<Partial<AttributesOnly<VideoPlaylistElementModel>>> {
52 @CreatedAt 53 @CreatedAt
53 createdAt: Date 54 createdAt: Date
54 55
@@ -274,7 +275,8 @@ export class VideoPlaylistElementModel extends Model {
274 validate: false // We use a literal to update the position 275 validate: false // We use a literal to update the position
275 } 276 }
276 277
277 return VideoPlaylistElementModel.update({ position: Sequelize.literal(`${newPosition} + "position" - ${firstPosition}`) }, query) 278 const positionQuery = Sequelize.literal(`${newPosition} + "position" - ${firstPosition}`)
279 return VideoPlaylistElementModel.update({ position: positionQuery as any }, query)
278 } 280 }
279 281
280 static increasePositionOf ( 282 static increasePositionOf (
diff --git a/server/models/video/video-playlist.ts b/server/models/video/video-playlist.ts
index efe5be36d..af81c9906 100644
--- a/server/models/video/video-playlist.ts
+++ b/server/models/video/video-playlist.ts
@@ -1,5 +1,5 @@
1import { join } from 'path' 1import { join } from 'path'
2import { FindOptions, literal, Op, ScopeOptions, Transaction, WhereOptions } from 'sequelize' 2import { FindOptions, literal, Op, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
3import { 3import {
4 AllowNull, 4 AllowNull,
5 BelongsTo, 5 BelongsTo,
@@ -17,8 +17,10 @@ import {
17 Table, 17 Table,
18 UpdatedAt 18 UpdatedAt
19} from 'sequelize-typescript' 19} from 'sequelize-typescript'
20import { v4 as uuidv4 } from 'uuid' 20import { setAsUpdated } from '@server/helpers/database-utils'
21import { buildUUID, uuidToShort } from '@server/helpers/uuid'
21import { MAccountId, MChannelId } from '@server/types/models' 22import { MAccountId, MChannelId } from '@server/types/models'
23import { AttributesOnly } from '@shared/core-utils'
22import { ActivityIconObject } from '../../../shared/models/activitypub/objects' 24import { ActivityIconObject } from '../../../shared/models/activitypub/objects'
23import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object' 25import { PlaylistObject } from '../../../shared/models/activitypub/objects/playlist-object'
24import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model' 26import { VideoPlaylistPrivacy } from '../../../shared/models/videos/playlist/video-playlist-privacy.model'
@@ -50,11 +52,19 @@ import {
50 MVideoPlaylistIdWithElements 52 MVideoPlaylistIdWithElements
51} from '../../types/models/video/video-playlist' 53} from '../../types/models/video/video-playlist'
52import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account' 54import { AccountModel, ScopeNames as AccountScopeNames, SummaryOptions } from '../account/account'
53import { buildServerIdsFollowedBy, buildWhereIdOrUUID, getPlaylistSort, isOutdated, throwIfNotValid } from '../utils' 55import { ActorModel } from '../actor/actor'
56import {
57 buildServerIdsFollowedBy,
58 buildTrigramSearchIndex,
59 buildWhereIdOrUUID,
60 createSimilarityAttribute,
61 getPlaylistSort,
62 isOutdated,
63 throwIfNotValid
64} from '../utils'
54import { ThumbnailModel } from './thumbnail' 65import { ThumbnailModel } from './thumbnail'
55import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel' 66import { ScopeNames as VideoChannelScopeNames, VideoChannelModel } from './video-channel'
56import { VideoPlaylistElementModel } from './video-playlist-element' 67import { VideoPlaylistElementModel } from './video-playlist-element'
57import { ActorModel } from '../activitypub/actor'
58 68
59enum ScopeNames { 69enum ScopeNames {
60 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST', 70 AVAILABLE_FOR_LIST = 'AVAILABLE_FOR_LIST',
@@ -72,6 +82,11 @@ type AvailableForListOptions = {
72 videoChannelId?: number 82 videoChannelId?: number
73 listMyPlaylists?: boolean 83 listMyPlaylists?: boolean
74 search?: string 84 search?: string
85 withVideos?: boolean
86}
87
88function getVideoLengthSelect () {
89 return 'SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id"'
75} 90}
76 91
77@Scopes(() => ({ 92@Scopes(() => ({
@@ -87,7 +102,7 @@ type AvailableForListOptions = {
87 attributes: { 102 attributes: {
88 include: [ 103 include: [
89 [ 104 [
90 literal('(SELECT COUNT("id") FROM "videoPlaylistElement" WHERE "videoPlaylistId" = "VideoPlaylistModel"."id")'), 105 literal(`(${getVideoLengthSelect()})`),
91 'videosLength' 106 'videosLength'
92 ] 107 ]
93 ] 108 ]
@@ -176,11 +191,28 @@ type AvailableForListOptions = {
176 }) 191 })
177 } 192 }
178 193
194 if (options.withVideos === true) {
195 whereAnd.push(
196 literal(`(${getVideoLengthSelect()}) != 0`)
197 )
198 }
199
200 const attributesInclude = []
201
179 if (options.search) { 202 if (options.search) {
203 const escapedSearch = VideoPlaylistModel.sequelize.escape(options.search)
204 const escapedLikeSearch = VideoPlaylistModel.sequelize.escape('%' + options.search + '%')
205 attributesInclude.push(createSimilarityAttribute('VideoPlaylistModel.name', options.search))
206
180 whereAnd.push({ 207 whereAnd.push({
181 name: { 208 [Op.or]: [
182 [Op.iLike]: '%' + options.search + '%' 209 Sequelize.literal(
183 } 210 'lower(immutable_unaccent("VideoPlaylistModel"."name")) % lower(immutable_unaccent(' + escapedSearch + '))'
211 ),
212 Sequelize.literal(
213 'lower(immutable_unaccent("VideoPlaylistModel"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))'
214 )
215 ]
184 }) 216 })
185 } 217 }
186 218
@@ -189,6 +221,9 @@ type AvailableForListOptions = {
189 } 221 }
190 222
191 return { 223 return {
224 attributes: {
225 include: attributesInclude
226 },
192 where, 227 where,
193 include: [ 228 include: [
194 { 229 {
@@ -209,6 +244,8 @@ type AvailableForListOptions = {
209@Table({ 244@Table({
210 tableName: 'videoPlaylist', 245 tableName: 'videoPlaylist',
211 indexes: [ 246 indexes: [
247 buildTrigramSearchIndex('video_playlist_name_trigram', 'name'),
248
212 { 249 {
213 fields: [ 'ownerAccountId' ] 250 fields: [ 'ownerAccountId' ]
214 }, 251 },
@@ -221,7 +258,7 @@ type AvailableForListOptions = {
221 } 258 }
222 ] 259 ]
223}) 260})
224export class VideoPlaylistModel extends Model { 261export class VideoPlaylistModel extends Model<Partial<AttributesOnly<VideoPlaylistModel>>> {
225 @CreatedAt 262 @CreatedAt
226 createdAt: Date 263 createdAt: Date
227 264
@@ -312,6 +349,7 @@ export class VideoPlaylistModel extends Model {
312 videoChannelId?: number 349 videoChannelId?: number
313 listMyPlaylists?: boolean 350 listMyPlaylists?: boolean
314 search?: string 351 search?: string
352 withVideos?: boolean // false by default
315 }) { 353 }) {
316 const query = { 354 const query = {
317 offset: options.start, 355 offset: options.start,
@@ -329,7 +367,8 @@ export class VideoPlaylistModel extends Model {
329 accountId: options.accountId, 367 accountId: options.accountId,
330 videoChannelId: options.videoChannelId, 368 videoChannelId: options.videoChannelId,
331 listMyPlaylists: options.listMyPlaylists, 369 listMyPlaylists: options.listMyPlaylists,
332 search: options.search 370 search: options.search,
371 withVideos: options.withVideos || false
333 } as AvailableForListOptions 372 } as AvailableForListOptions
334 ] 373 ]
335 }, 374 },
@@ -345,6 +384,21 @@ export class VideoPlaylistModel extends Model {
345 }) 384 })
346 } 385 }
347 386
387 static searchForApi (options: {
388 followerActorId: number
389 start: number
390 count: number
391 sort: string
392 search?: string
393 }) {
394 return VideoPlaylistModel.listForApi({
395 ...options,
396 type: VideoPlaylistType.REGULAR,
397 listMyPlaylists: false,
398 withVideos: true
399 })
400 }
401
348 static listPublicUrlsOfForAP (options: { account?: MAccountId, channel?: MChannelId }, start: number, count: number) { 402 static listPublicUrlsOfForAP (options: { account?: MAccountId, channel?: MChannelId }, start: number, count: number) {
349 const where = { 403 const where = {
350 privacy: VideoPlaylistPrivacy.PUBLIC 404 privacy: VideoPlaylistPrivacy.PUBLIC
@@ -443,6 +497,18 @@ export class VideoPlaylistModel extends Model {
443 return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query) 497 return VideoPlaylistModel.scope([ ScopeNames.WITH_ACCOUNT, ScopeNames.WITH_THUMBNAIL ]).findOne(query)
444 } 498 }
445 499
500 static loadByUrlWithAccountAndChannelSummary (url: string): Promise<MVideoPlaylistFullSummary> {
501 const query = {
502 where: {
503 url
504 }
505 }
506
507 return VideoPlaylistModel
508 .scope([ ScopeNames.WITH_ACCOUNT_AND_CHANNEL_SUMMARY, ScopeNames.WITH_VIDEOS_LENGTH, ScopeNames.WITH_THUMBNAIL ])
509 .findOne(query)
510 }
511
446 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) { 512 static getPrivacyLabel (privacy: VideoPlaylistPrivacy) {
447 return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown' 513 return VIDEO_PLAYLIST_PRIVACIES[privacy] || 'Unknown'
448 } 514 }
@@ -479,7 +545,7 @@ export class VideoPlaylistModel extends Model {
479 generateThumbnailName () { 545 generateThumbnailName () {
480 const extension = '.jpg' 546 const extension = '.jpg'
481 547
482 return 'playlist-' + uuidv4() + extension 548 return 'playlist-' + buildUUID() + extension
483 } 549 }
484 550
485 getThumbnailUrl () { 551 getThumbnailUrl () {
@@ -495,7 +561,7 @@ export class VideoPlaylistModel extends Model {
495 } 561 }
496 562
497 getWatchUrl () { 563 getWatchUrl () {
498 return WEBSERVER.URL + '/videos/watch/playlist/' + this.uuid 564 return WEBSERVER.URL + '/w/p/' + this.uuid
499 } 565 }
500 566
501 getEmbedStaticPath () { 567 getEmbedStaticPath () {
@@ -530,9 +596,11 @@ export class VideoPlaylistModel extends Model {
530 } 596 }
531 597
532 setAsRefreshed () { 598 setAsRefreshed () {
533 this.changed('updatedAt', true) 599 return setAsUpdated('videoPlaylist', this.id)
600 }
534 601
535 return this.save() 602 setVideosLength (videosLength: number) {
603 this.set('videosLength' as any, videosLength, { raw: true })
536 } 604 }
537 605
538 isOwned () { 606 isOwned () {
@@ -549,8 +617,12 @@ export class VideoPlaylistModel extends Model {
549 return { 617 return {
550 id: this.id, 618 id: this.id,
551 uuid: this.uuid, 619 uuid: this.uuid,
620 shortUUID: uuidToShort(this.uuid),
621
552 isLocal: this.isOwned(), 622 isLocal: this.isOwned(),
553 623
624 url: this.url,
625
554 displayName: this.name, 626 displayName: this.name,
555 description: this.description, 627 description: this.description,
556 privacy: { 628 privacy: {
diff --git a/server/models/video/video-query-builder.ts b/server/models/video/video-query-builder.ts
deleted file mode 100644
index 155afe64b..000000000
--- a/server/models/video/video-query-builder.ts
+++ /dev/null
@@ -1,599 +0,0 @@
1import { VideoFilter, VideoPrivacy, VideoState } from '@shared/models'
2import { buildDirectionAndField, createSafeIn } from '@server/models/utils'
3import { Model } from 'sequelize-typescript'
4import { MUserAccountId, MUserId } from '@server/types/models'
5import validator from 'validator'
6import { exists } from '@server/helpers/custom-validators/misc'
7
8export type BuildVideosQueryOptions = {
9 attributes?: string[]
10
11 serverAccountId: number
12 followerActorId: number
13 includeLocalVideos: boolean
14
15 count: number
16 start: number
17 sort: string
18
19 nsfw?: boolean
20 filter?: VideoFilter
21 isLive?: boolean
22
23 categoryOneOf?: number[]
24 licenceOneOf?: number[]
25 languageOneOf?: string[]
26 tagsOneOf?: string[]
27 tagsAllOf?: string[]
28
29 withFiles?: boolean
30
31 accountId?: number
32 videoChannelId?: number
33
34 videoPlaylistId?: number
35
36 trendingAlgorithm?: string // best, hot, or any other algorithm implemented
37 trendingDays?: number
38
39 user?: MUserAccountId
40 historyOfUser?: MUserId
41
42 startDate?: string // ISO 8601
43 endDate?: string // ISO 8601
44 originallyPublishedStartDate?: string
45 originallyPublishedEndDate?: string
46
47 durationMin?: number // seconds
48 durationMax?: number // seconds
49
50 search?: string
51
52 isCount?: boolean
53
54 group?: string
55 having?: string
56}
57
58function buildListQuery (model: typeof Model, options: BuildVideosQueryOptions) {
59 const and: string[] = []
60 const joins: string[] = []
61 const replacements: any = {}
62 const cte: string[] = []
63
64 let attributes: string[] = options.attributes || [ '"video"."id"' ]
65 let group = options.group || ''
66 const having = options.having || ''
67
68 joins.push(
69 'INNER JOIN "videoChannel" ON "videoChannel"."id" = "video"."channelId"' +
70 'INNER JOIN "account" ON "account"."id" = "videoChannel"."accountId"' +
71 'INNER JOIN "actor" "accountActor" ON "account"."actorId" = "accountActor"."id"'
72 )
73
74 and.push('"video"."id" NOT IN (SELECT "videoBlacklist"."videoId" FROM "videoBlacklist")')
75
76 if (options.serverAccountId) {
77 const blockerIds = [ options.serverAccountId ]
78 if (options.user) blockerIds.push(options.user.Account.id)
79
80 const inClause = createSafeIn(model, blockerIds)
81
82 and.push(
83 'NOT EXISTS (' +
84 ' SELECT 1 FROM "accountBlocklist" ' +
85 ' WHERE "accountBlocklist"."accountId" IN (' + inClause + ') ' +
86 ' AND "accountBlocklist"."targetAccountId" = "account"."id" ' +
87 ')' +
88 'AND NOT EXISTS (' +
89 ' SELECT 1 FROM "serverBlocklist" WHERE "serverBlocklist"."accountId" IN (' + inClause + ') ' +
90 ' AND "serverBlocklist"."targetServerId" = "accountActor"."serverId"' +
91 ')'
92 )
93 }
94
95 // Only list public/published videos
96 if (!options.filter || (options.filter !== 'all-local' && options.filter !== 'all')) {
97 and.push(
98 `("video"."state" = ${VideoState.PUBLISHED} OR ` +
99 `("video"."state" = ${VideoState.TO_TRANSCODE} AND "video"."waitTranscoding" IS false))`
100 )
101
102 if (options.user) {
103 and.push(
104 `("video"."privacy" = ${VideoPrivacy.PUBLIC} OR "video"."privacy" = ${VideoPrivacy.INTERNAL})`
105 )
106 } else { // Or only public videos
107 and.push(
108 `"video"."privacy" = ${VideoPrivacy.PUBLIC}`
109 )
110 }
111 }
112
113 if (options.videoPlaylistId) {
114 joins.push(
115 'INNER JOIN "videoPlaylistElement" "video"."id" = "videoPlaylistElement"."videoId" ' +
116 'AND "videoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
117 )
118
119 replacements.videoPlaylistId = options.videoPlaylistId
120 }
121
122 if (options.filter && (options.filter === 'local' || options.filter === 'all-local')) {
123 and.push('"video"."remote" IS FALSE')
124 }
125
126 if (options.accountId) {
127 and.push('"account"."id" = :accountId')
128 replacements.accountId = options.accountId
129 }
130
131 if (options.videoChannelId) {
132 and.push('"videoChannel"."id" = :videoChannelId')
133 replacements.videoChannelId = options.videoChannelId
134 }
135
136 if (options.followerActorId) {
137 let query =
138 '(' +
139 ' EXISTS (' +
140 ' SELECT 1 FROM "videoShare" ' +
141 ' INNER JOIN "actorFollow" "actorFollowShare" ON "actorFollowShare"."targetActorId" = "videoShare"."actorId" ' +
142 ' AND "actorFollowShare"."actorId" = :followerActorId AND "actorFollowShare"."state" = \'accepted\' ' +
143 ' WHERE "videoShare"."videoId" = "video"."id"' +
144 ' )' +
145 ' OR' +
146 ' EXISTS (' +
147 ' SELECT 1 from "actorFollow" ' +
148 ' WHERE "actorFollow"."targetActorId" = "videoChannel"."actorId" AND "actorFollow"."actorId" = :followerActorId ' +
149 ' AND "actorFollow"."state" = \'accepted\'' +
150 ' )'
151
152 if (options.includeLocalVideos) {
153 query += ' OR "video"."remote" IS FALSE'
154 }
155
156 query += ')'
157
158 and.push(query)
159 replacements.followerActorId = options.followerActorId
160 }
161
162 if (options.withFiles === true) {
163 and.push(
164 '(' +
165 ' EXISTS (SELECT 1 FROM "videoFile" WHERE "videoFile"."videoId" = "video"."id") ' +
166 ' OR EXISTS (' +
167 ' SELECT 1 FROM "videoStreamingPlaylist" ' +
168 ' INNER JOIN "videoFile" ON "videoFile"."videoStreamingPlaylistId" = "videoStreamingPlaylist"."id" ' +
169 ' WHERE "videoStreamingPlaylist"."videoId" = "video"."id"' +
170 ' )' +
171 ')'
172 )
173 }
174
175 if (options.tagsOneOf) {
176 const tagsOneOfLower = options.tagsOneOf.map(t => t.toLowerCase())
177
178 and.push(
179 'EXISTS (' +
180 ' SELECT 1 FROM "videoTag" ' +
181 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
182 ' WHERE lower("tag"."name") IN (' + createSafeIn(model, tagsOneOfLower) + ') ' +
183 ' AND "video"."id" = "videoTag"."videoId"' +
184 ')'
185 )
186 }
187
188 if (options.tagsAllOf) {
189 const tagsAllOfLower = options.tagsAllOf.map(t => t.toLowerCase())
190
191 and.push(
192 'EXISTS (' +
193 ' SELECT 1 FROM "videoTag" ' +
194 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
195 ' WHERE lower("tag"."name") IN (' + createSafeIn(model, tagsAllOfLower) + ') ' +
196 ' AND "video"."id" = "videoTag"."videoId" ' +
197 ' GROUP BY "videoTag"."videoId" HAVING COUNT(*) = ' + tagsAllOfLower.length +
198 ')'
199 )
200 }
201
202 if (options.nsfw === true) {
203 and.push('"video"."nsfw" IS TRUE')
204 } else if (options.nsfw === false) {
205 and.push('"video"."nsfw" IS FALSE')
206 }
207
208 if (options.isLive === true) {
209 and.push('"video"."isLive" IS TRUE')
210 } else if (options.isLive === false) {
211 and.push('"video"."isLive" IS FALSE')
212 }
213
214 if (options.categoryOneOf) {
215 and.push('"video"."category" IN (:categoryOneOf)')
216 replacements.categoryOneOf = options.categoryOneOf
217 }
218
219 if (options.licenceOneOf) {
220 and.push('"video"."licence" IN (:licenceOneOf)')
221 replacements.licenceOneOf = options.licenceOneOf
222 }
223
224 if (options.languageOneOf) {
225 const languages = options.languageOneOf.filter(l => l && l !== '_unknown')
226 const languagesQueryParts: string[] = []
227
228 if (languages.length !== 0) {
229 languagesQueryParts.push('"video"."language" IN (:languageOneOf)')
230 replacements.languageOneOf = languages
231
232 languagesQueryParts.push(
233 'EXISTS (' +
234 ' SELECT 1 FROM "videoCaption" WHERE "videoCaption"."language" ' +
235 ' IN (' + createSafeIn(model, languages) + ') AND ' +
236 ' "videoCaption"."videoId" = "video"."id"' +
237 ')'
238 )
239 }
240
241 if (options.languageOneOf.includes('_unknown')) {
242 languagesQueryParts.push('"video"."language" IS NULL')
243 }
244
245 if (languagesQueryParts.length !== 0) {
246 and.push('(' + languagesQueryParts.join(' OR ') + ')')
247 }
248 }
249
250 // We don't exclude results in this so if we do a count we don't need to add this complex clause
251 if (options.isCount !== true) {
252 if (options.trendingDays) {
253 const viewsGteDate = new Date(new Date().getTime() - (24 * 3600 * 1000) * options.trendingDays)
254
255 joins.push('LEFT JOIN "videoView" ON "video"."id" = "videoView"."videoId" AND "videoView"."startDate" >= :viewsGteDate')
256 replacements.viewsGteDate = viewsGteDate
257
258 attributes.push('COALESCE(SUM("videoView"."views"), 0) AS "score"')
259
260 group = 'GROUP BY "video"."id"'
261 } else if ([ 'best', 'hot' ].includes(options.trendingAlgorithm)) {
262 /**
263 * "Hotness" is a measure based on absolute view/comment/like/dislike numbers,
264 * with fixed weights only applied to their log values.
265 *
266 * This algorithm gives little chance for an old video to have a good score,
267 * for which recent spikes in interactions could be a sign of "hotness" and
268 * justify a better score. However there are multiple ways to achieve that
269 * goal, which is left for later. Yes, this is a TODO :)
270 *
271 * notes:
272 * - weights and base score are in number of half-days.
273 * - all comments are counted, regardless of being written by the video author or not
274 * see https://github.com/reddit-archive/reddit/blob/master/r2/r2/lib/db/_sorts.pyx#L47-L58
275 * - we have less interactions than on reddit, so multiply weights by an arbitrary factor
276 */
277 const weights = {
278 like: 3 * 50,
279 dislike: -3 * 50,
280 view: Math.floor((1 / 3) * 50),
281 comment: 2 * 50, // a comment takes more time than a like to do, but can be done multiple times
282 history: -2 * 50
283 }
284
285 joins.push('LEFT JOIN "videoComment" ON "video"."id" = "videoComment"."videoId"')
286
287 let attribute =
288 `LOG(GREATEST(1, "video"."likes" - 1)) * ${weights.like} ` + // likes (+)
289 `+ LOG(GREATEST(1, "video"."dislikes" - 1)) * ${weights.dislike} ` + // dislikes (-)
290 `+ LOG("video"."views" + 1) * ${weights.view} ` + // views (+)
291 `+ LOG(GREATEST(1, COUNT(DISTINCT "videoComment"."id"))) * ${weights.comment} ` + // comments (+)
292 '+ (SELECT (EXTRACT(epoch FROM "video"."publishedAt") - 1446156582) / 47000) ' // base score (in number of half-days)
293
294 if (options.trendingAlgorithm === 'best' && options.user) {
295 joins.push(
296 'LEFT JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :bestUser'
297 )
298 replacements.bestUser = options.user.id
299
300 attribute += `+ POWER(COUNT(DISTINCT "userVideoHistory"."id"), 2.0) * ${weights.history} `
301 }
302
303 attribute += 'AS "score"'
304 attributes.push(attribute)
305
306 group = 'GROUP BY "video"."id"'
307 }
308 }
309
310 if (options.historyOfUser) {
311 joins.push('INNER JOIN "userVideoHistory" ON "video"."id" = "userVideoHistory"."videoId"')
312
313 and.push('"userVideoHistory"."userId" = :historyOfUser')
314 replacements.historyOfUser = options.historyOfUser.id
315 }
316
317 if (options.startDate) {
318 and.push('"video"."publishedAt" >= :startDate')
319 replacements.startDate = options.startDate
320 }
321
322 if (options.endDate) {
323 and.push('"video"."publishedAt" <= :endDate')
324 replacements.endDate = options.endDate
325 }
326
327 if (options.originallyPublishedStartDate) {
328 and.push('"video"."originallyPublishedAt" >= :originallyPublishedStartDate')
329 replacements.originallyPublishedStartDate = options.originallyPublishedStartDate
330 }
331
332 if (options.originallyPublishedEndDate) {
333 and.push('"video"."originallyPublishedAt" <= :originallyPublishedEndDate')
334 replacements.originallyPublishedEndDate = options.originallyPublishedEndDate
335 }
336
337 if (options.durationMin) {
338 and.push('"video"."duration" >= :durationMin')
339 replacements.durationMin = options.durationMin
340 }
341
342 if (options.durationMax) {
343 and.push('"video"."duration" <= :durationMax')
344 replacements.durationMax = options.durationMax
345 }
346
347 if (options.search) {
348 const escapedSearch = model.sequelize.escape(options.search)
349 const escapedLikeSearch = model.sequelize.escape('%' + options.search + '%')
350
351 cte.push(
352 '"trigramSearch" AS (' +
353 ' SELECT "video"."id", ' +
354 ` similarity(lower(immutable_unaccent("video"."name")), lower(immutable_unaccent(${escapedSearch}))) as similarity ` +
355 ' FROM "video" ' +
356 ' WHERE lower(immutable_unaccent("video"."name")) % lower(immutable_unaccent(' + escapedSearch + ')) OR ' +
357 ' lower(immutable_unaccent("video"."name")) LIKE lower(immutable_unaccent(' + escapedLikeSearch + '))' +
358 ')'
359 )
360
361 joins.push('LEFT JOIN "trigramSearch" ON "video"."id" = "trigramSearch"."id"')
362
363 let base = '(' +
364 ' "trigramSearch"."id" IS NOT NULL OR ' +
365 ' EXISTS (' +
366 ' SELECT 1 FROM "videoTag" ' +
367 ' INNER JOIN "tag" ON "tag"."id" = "videoTag"."tagId" ' +
368 ` WHERE lower("tag"."name") = ${escapedSearch} ` +
369 ' AND "video"."id" = "videoTag"."videoId"' +
370 ' )'
371
372 if (validator.isUUID(options.search)) {
373 base += ` OR "video"."uuid" = ${escapedSearch}`
374 }
375
376 base += ')'
377 and.push(base)
378
379 attributes.push(`COALESCE("trigramSearch"."similarity", 0) as similarity`)
380 } else {
381 attributes.push('0 as similarity')
382 }
383
384 if (options.isCount === true) attributes = [ 'COUNT(*) as "total"' ]
385
386 let suffix = ''
387 let order = ''
388 if (options.isCount !== true) {
389
390 if (exists(options.sort)) {
391 if (options.sort === '-originallyPublishedAt' || options.sort === 'originallyPublishedAt') {
392 attributes.push('COALESCE("video"."originallyPublishedAt", "video"."publishedAt") AS "publishedAtForOrder"')
393 }
394
395 order = buildOrder(options.sort)
396 suffix += `${order} `
397 }
398
399 if (exists(options.count)) {
400 const count = parseInt(options.count + '', 10)
401 suffix += `LIMIT ${count} `
402 }
403
404 if (exists(options.start)) {
405 const start = parseInt(options.start + '', 10)
406 suffix += `OFFSET ${start} `
407 }
408 }
409
410 const cteString = cte.length !== 0
411 ? `WITH ${cte.join(', ')} `
412 : ''
413
414 const query = cteString +
415 'SELECT ' + attributes.join(', ') + ' ' +
416 'FROM "video" ' + joins.join(' ') + ' ' +
417 'WHERE ' + and.join(' AND ') + ' ' +
418 group + ' ' +
419 having + ' ' +
420 suffix
421
422 return { query, replacements, order }
423}
424
425function buildOrder (value: string) {
426 const { direction, field } = buildDirectionAndField(value)
427 if (field.match(/^[a-zA-Z."]+$/) === null) throw new Error('Invalid sort column ' + field)
428
429 if (field.toLowerCase() === 'random') return 'ORDER BY RANDOM()'
430
431 if ([ 'trending', 'hot', 'best' ].includes(field.toLowerCase())) { // Sort by aggregation
432 return `ORDER BY "score" ${direction}, "video"."views" ${direction}`
433 }
434
435 let firstSort: string
436
437 if (field.toLowerCase() === 'match') { // Search
438 firstSort = '"similarity"'
439 } else if (field === 'originallyPublishedAt') {
440 firstSort = '"publishedAtForOrder"'
441 } else if (field.includes('.')) {
442 firstSort = field
443 } else {
444 firstSort = `"video"."${field}"`
445 }
446
447 return `ORDER BY ${firstSort} ${direction}, "video"."id" ASC`
448}
449
450function wrapForAPIResults (baseQuery: string, replacements: any, options: BuildVideosQueryOptions, order: string) {
451 const attributes = {
452 '"video".*': '',
453 '"VideoChannel"."id"': '"VideoChannel.id"',
454 '"VideoChannel"."name"': '"VideoChannel.name"',
455 '"VideoChannel"."description"': '"VideoChannel.description"',
456 '"VideoChannel"."actorId"': '"VideoChannel.actorId"',
457 '"VideoChannel->Actor"."id"': '"VideoChannel.Actor.id"',
458 '"VideoChannel->Actor"."preferredUsername"': '"VideoChannel.Actor.preferredUsername"',
459 '"VideoChannel->Actor"."url"': '"VideoChannel.Actor.url"',
460 '"VideoChannel->Actor"."serverId"': '"VideoChannel.Actor.serverId"',
461 '"VideoChannel->Actor"."avatarId"': '"VideoChannel.Actor.avatarId"',
462 '"VideoChannel->Account"."id"': '"VideoChannel.Account.id"',
463 '"VideoChannel->Account"."name"': '"VideoChannel.Account.name"',
464 '"VideoChannel->Account->Actor"."id"': '"VideoChannel.Account.Actor.id"',
465 '"VideoChannel->Account->Actor"."preferredUsername"': '"VideoChannel.Account.Actor.preferredUsername"',
466 '"VideoChannel->Account->Actor"."url"': '"VideoChannel.Account.Actor.url"',
467 '"VideoChannel->Account->Actor"."serverId"': '"VideoChannel.Account.Actor.serverId"',
468 '"VideoChannel->Account->Actor"."avatarId"': '"VideoChannel.Account.Actor.avatarId"',
469 '"VideoChannel->Actor->Server"."id"': '"VideoChannel.Actor.Server.id"',
470 '"VideoChannel->Actor->Server"."host"': '"VideoChannel.Actor.Server.host"',
471 '"VideoChannel->Actor->Avatar"."id"': '"VideoChannel.Actor.Avatar.id"',
472 '"VideoChannel->Actor->Avatar"."filename"': '"VideoChannel.Actor.Avatar.filename"',
473 '"VideoChannel->Actor->Avatar"."fileUrl"': '"VideoChannel.Actor.Avatar.fileUrl"',
474 '"VideoChannel->Actor->Avatar"."onDisk"': '"VideoChannel.Actor.Avatar.onDisk"',
475 '"VideoChannel->Actor->Avatar"."createdAt"': '"VideoChannel.Actor.Avatar.createdAt"',
476 '"VideoChannel->Actor->Avatar"."updatedAt"': '"VideoChannel.Actor.Avatar.updatedAt"',
477 '"VideoChannel->Account->Actor->Server"."id"': '"VideoChannel.Account.Actor.Server.id"',
478 '"VideoChannel->Account->Actor->Server"."host"': '"VideoChannel.Account.Actor.Server.host"',
479 '"VideoChannel->Account->Actor->Avatar"."id"': '"VideoChannel.Account.Actor.Avatar.id"',
480 '"VideoChannel->Account->Actor->Avatar"."filename"': '"VideoChannel.Account.Actor.Avatar.filename"',
481 '"VideoChannel->Account->Actor->Avatar"."fileUrl"': '"VideoChannel.Account.Actor.Avatar.fileUrl"',
482 '"VideoChannel->Account->Actor->Avatar"."onDisk"': '"VideoChannel.Account.Actor.Avatar.onDisk"',
483 '"VideoChannel->Account->Actor->Avatar"."createdAt"': '"VideoChannel.Account.Actor.Avatar.createdAt"',
484 '"VideoChannel->Account->Actor->Avatar"."updatedAt"': '"VideoChannel.Account.Actor.Avatar.updatedAt"',
485 '"Thumbnails"."id"': '"Thumbnails.id"',
486 '"Thumbnails"."type"': '"Thumbnails.type"',
487 '"Thumbnails"."filename"': '"Thumbnails.filename"'
488 }
489
490 const joins = [
491 'INNER JOIN "video" ON "tmp"."id" = "video"."id"',
492
493 'INNER JOIN "videoChannel" AS "VideoChannel" ON "video"."channelId" = "VideoChannel"."id"',
494 'INNER JOIN "actor" AS "VideoChannel->Actor" ON "VideoChannel"."actorId" = "VideoChannel->Actor"."id"',
495 'INNER JOIN "account" AS "VideoChannel->Account" ON "VideoChannel"."accountId" = "VideoChannel->Account"."id"',
496 'INNER JOIN "actor" AS "VideoChannel->Account->Actor" ON "VideoChannel->Account"."actorId" = "VideoChannel->Account->Actor"."id"',
497
498 'LEFT OUTER JOIN "server" AS "VideoChannel->Actor->Server" ON "VideoChannel->Actor"."serverId" = "VideoChannel->Actor->Server"."id"',
499 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Actor->Avatar" ' +
500 'ON "VideoChannel->Actor"."avatarId" = "VideoChannel->Actor->Avatar"."id"',
501
502 'LEFT OUTER JOIN "server" AS "VideoChannel->Account->Actor->Server" ' +
503 'ON "VideoChannel->Account->Actor"."serverId" = "VideoChannel->Account->Actor->Server"."id"',
504
505 'LEFT OUTER JOIN "actorImage" AS "VideoChannel->Account->Actor->Avatar" ' +
506 'ON "VideoChannel->Account->Actor"."avatarId" = "VideoChannel->Account->Actor->Avatar"."id"',
507
508 'LEFT OUTER JOIN "thumbnail" AS "Thumbnails" ON "video"."id" = "Thumbnails"."videoId"'
509 ]
510
511 if (options.withFiles) {
512 joins.push('LEFT JOIN "videoFile" AS "VideoFiles" ON "VideoFiles"."videoId" = "video"."id"')
513
514 joins.push('LEFT JOIN "videoStreamingPlaylist" AS "VideoStreamingPlaylists" ON "VideoStreamingPlaylists"."videoId" = "video"."id"')
515 joins.push(
516 'LEFT JOIN "videoFile" AS "VideoStreamingPlaylists->VideoFiles" ' +
517 'ON "VideoStreamingPlaylists->VideoFiles"."videoStreamingPlaylistId" = "VideoStreamingPlaylists"."id"'
518 )
519
520 Object.assign(attributes, {
521 '"VideoFiles"."id"': '"VideoFiles.id"',
522 '"VideoFiles"."createdAt"': '"VideoFiles.createdAt"',
523 '"VideoFiles"."updatedAt"': '"VideoFiles.updatedAt"',
524 '"VideoFiles"."resolution"': '"VideoFiles.resolution"',
525 '"VideoFiles"."size"': '"VideoFiles.size"',
526 '"VideoFiles"."extname"': '"VideoFiles.extname"',
527 '"VideoFiles"."filename"': '"VideoFiles.filename"',
528 '"VideoFiles"."fileUrl"': '"VideoFiles.fileUrl"',
529 '"VideoFiles"."torrentFilename"': '"VideoFiles.torrentFilename"',
530 '"VideoFiles"."torrentUrl"': '"VideoFiles.torrentUrl"',
531 '"VideoFiles"."infoHash"': '"VideoFiles.infoHash"',
532 '"VideoFiles"."fps"': '"VideoFiles.fps"',
533 '"VideoFiles"."videoId"': '"VideoFiles.videoId"',
534
535 '"VideoStreamingPlaylists"."id"': '"VideoStreamingPlaylists.id"',
536 '"VideoStreamingPlaylists"."playlistUrl"': '"VideoStreamingPlaylists.playlistUrl"',
537 '"VideoStreamingPlaylists"."type"': '"VideoStreamingPlaylists.type"',
538 '"VideoStreamingPlaylists->VideoFiles"."id"': '"VideoStreamingPlaylists.VideoFiles.id"',
539 '"VideoStreamingPlaylists->VideoFiles"."createdAt"': '"VideoStreamingPlaylists.VideoFiles.createdAt"',
540 '"VideoStreamingPlaylists->VideoFiles"."updatedAt"': '"VideoStreamingPlaylists.VideoFiles.updatedAt"',
541 '"VideoStreamingPlaylists->VideoFiles"."resolution"': '"VideoStreamingPlaylists.VideoFiles.resolution"',
542 '"VideoStreamingPlaylists->VideoFiles"."size"': '"VideoStreamingPlaylists.VideoFiles.size"',
543 '"VideoStreamingPlaylists->VideoFiles"."extname"': '"VideoStreamingPlaylists.VideoFiles.extname"',
544 '"VideoStreamingPlaylists->VideoFiles"."filename"': '"VideoStreamingPlaylists.VideoFiles.filename"',
545 '"VideoStreamingPlaylists->VideoFiles"."fileUrl"': '"VideoStreamingPlaylists.VideoFiles.fileUrl"',
546 '"VideoStreamingPlaylists->VideoFiles"."torrentFilename"': '"VideoStreamingPlaylists.VideoFiles.torrentFilename"',
547 '"VideoStreamingPlaylists->VideoFiles"."torrentUrl"': '"VideoStreamingPlaylists.VideoFiles.torrentUrl"',
548 '"VideoStreamingPlaylists->VideoFiles"."infoHash"': '"VideoStreamingPlaylists.VideoFiles.infoHash"',
549 '"VideoStreamingPlaylists->VideoFiles"."fps"': '"VideoStreamingPlaylists.VideoFiles.fps"',
550 '"VideoStreamingPlaylists->VideoFiles"."videoStreamingPlaylistId"': '"VideoStreamingPlaylists.VideoFiles.videoStreamingPlaylistId"',
551 '"VideoStreamingPlaylists->VideoFiles"."videoId"': '"VideoStreamingPlaylists.VideoFiles.videoId"'
552 })
553 }
554
555 if (options.user) {
556 joins.push(
557 'LEFT OUTER JOIN "userVideoHistory" ' +
558 'ON "video"."id" = "userVideoHistory"."videoId" AND "userVideoHistory"."userId" = :userVideoHistoryId'
559 )
560 replacements.userVideoHistoryId = options.user.id
561
562 Object.assign(attributes, {
563 '"userVideoHistory"."id"': '"userVideoHistory.id"',
564 '"userVideoHistory"."currentTime"': '"userVideoHistory.currentTime"'
565 })
566 }
567
568 if (options.videoPlaylistId) {
569 joins.push(
570 'INNER JOIN "videoPlaylistElement" as "VideoPlaylistElement" ON "videoPlaylistElement"."videoId" = "video"."id" ' +
571 'AND "VideoPlaylistElement"."videoPlaylistId" = :videoPlaylistId'
572 )
573 replacements.videoPlaylistId = options.videoPlaylistId
574
575 Object.assign(attributes, {
576 '"VideoPlaylistElement"."createdAt"': '"VideoPlaylistElement.createdAt"',
577 '"VideoPlaylistElement"."updatedAt"': '"VideoPlaylistElement.updatedAt"',
578 '"VideoPlaylistElement"."url"': '"VideoPlaylistElement.url"',
579 '"VideoPlaylistElement"."position"': '"VideoPlaylistElement.position"',
580 '"VideoPlaylistElement"."startTimestamp"': '"VideoPlaylistElement.startTimestamp"',
581 '"VideoPlaylistElement"."stopTimestamp"': '"VideoPlaylistElement.stopTimestamp"',
582 '"VideoPlaylistElement"."videoPlaylistId"': '"VideoPlaylistElement.videoPlaylistId"'
583 })
584 }
585
586 const select = 'SELECT ' + Object.keys(attributes).map(key => {
587 const value = attributes[key]
588 if (value) return `${key} AS ${value}`
589
590 return key
591 }).join(', ')
592
593 return `${select} FROM (${baseQuery}) AS "tmp" ${joins.join(' ')} ${order}`
594}
595
596export {
597 buildListQuery,
598 wrapForAPIResults
599}
diff --git a/server/models/video/video-share.ts b/server/models/video/video-share.ts
index 5059c1fa6..505c305e2 100644
--- a/server/models/video/video-share.ts
+++ b/server/models/video/video-share.ts
@@ -1,10 +1,11 @@
1import { literal, Op, QueryTypes, Transaction } from 'sequelize' 1import { literal, Op, QueryTypes, Transaction } from 'sequelize'
2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript' 2import { AllowNull, BelongsTo, Column, CreatedAt, DataType, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3import { AttributesOnly } from '@shared/core-utils'
3import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 4import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
4import { CONSTRAINTS_FIELDS } from '../../initializers/constants' 5import { CONSTRAINTS_FIELDS } from '../../initializers/constants'
5import { MActorDefault } from '../../types/models' 6import { MActorDefault } from '../../types/models'
6import { MVideoShareActor, MVideoShareFull } from '../../types/models/video' 7import { MVideoShareActor, MVideoShareFull } from '../../types/models/video'
7import { ActorModel } from '../activitypub/actor' 8import { ActorModel } from '../actor/actor'
8import { buildLocalActorIdsIn, throwIfNotValid } from '../utils' 9import { buildLocalActorIdsIn, throwIfNotValid } from '../utils'
9import { VideoModel } from './video' 10import { VideoModel } from './video'
10 11
@@ -50,7 +51,7 @@ enum ScopeNames {
50 } 51 }
51 ] 52 ]
52}) 53})
53export class VideoShareModel extends Model { 54export class VideoShareModel extends Model<Partial<AttributesOnly<VideoShareModel>>> {
54 55
55 @AllowNull(false) 56 @AllowNull(false)
56 @Is('VideoShareUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url')) 57 @Is('VideoShareUrl', value => throwIfNotValid(value, isActivityPubUrlValid, 'url'))
diff --git a/server/models/video/video-streaming-playlist.ts b/server/models/video/video-streaming-playlist.ts
index c9375b433..d627e8c9d 100644
--- a/server/models/video/video-streaming-playlist.ts
+++ b/server/models/video/video-streaming-playlist.ts
@@ -13,6 +13,7 @@ import { CONSTRAINTS_FIELDS, MEMOIZE_LENGTH, MEMOIZE_TTL, P2P_MEDIA_LOADER_PEER_
13import { VideoRedundancyModel } from '../redundancy/video-redundancy' 13import { VideoRedundancyModel } from '../redundancy/video-redundancy'
14import { throwIfNotValid } from '../utils' 14import { throwIfNotValid } from '../utils'
15import { VideoModel } from './video' 15import { VideoModel } from './video'
16import { AttributesOnly } from '@shared/core-utils'
16 17
17@Table({ 18@Table({
18 tableName: 'videoStreamingPlaylist', 19 tableName: 'videoStreamingPlaylist',
@@ -30,7 +31,7 @@ import { VideoModel } from './video'
30 } 31 }
31 ] 32 ]
32}) 33})
33export class VideoStreamingPlaylistModel extends Model { 34export class VideoStreamingPlaylistModel extends Model<Partial<AttributesOnly<VideoStreamingPlaylistModel>>> {
34 @CreatedAt 35 @CreatedAt
35 createdAt: Date 36 createdAt: Date
36 37
diff --git a/server/models/video/video-tag.ts b/server/models/video/video-tag.ts
index 5052b8c4d..1285d375b 100644
--- a/server/models/video/video-tag.ts
+++ b/server/models/video/video-tag.ts
@@ -1,4 +1,5 @@
1import { Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript' 1import { Column, CreatedAt, ForeignKey, Model, Table, UpdatedAt } from 'sequelize-typescript'
2import { AttributesOnly } from '@shared/core-utils'
2import { TagModel } from './tag' 3import { TagModel } from './tag'
3import { VideoModel } from './video' 4import { VideoModel } from './video'
4 5
@@ -13,7 +14,7 @@ import { VideoModel } from './video'
13 } 14 }
14 ] 15 ]
15}) 16})
16export class VideoTagModel extends Model { 17export class VideoTagModel extends Model<Partial<AttributesOnly<VideoTagModel>>> {
17 @CreatedAt 18 @CreatedAt
18 createdAt: Date 19 createdAt: Date
19 20
diff --git a/server/models/video/video-view.ts b/server/models/video/video-view.ts
index 992cf258a..dfc6296ce 100644
--- a/server/models/video/video-view.ts
+++ b/server/models/video/video-view.ts
@@ -1,6 +1,7 @@
1import * as Sequelize from 'sequelize'
1import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table } from 'sequelize-typescript' 2import { AllowNull, BelongsTo, Column, CreatedAt, ForeignKey, Model, Table } from 'sequelize-typescript'
3import { AttributesOnly } from '@shared/core-utils'
2import { VideoModel } from './video' 4import { VideoModel } from './video'
3import * as Sequelize from 'sequelize'
4 5
5@Table({ 6@Table({
6 tableName: 'videoView', 7 tableName: 'videoView',
@@ -14,7 +15,7 @@ import * as Sequelize from 'sequelize'
14 } 15 }
15 ] 16 ]
16}) 17})
17export class VideoViewModel extends Model { 18export class VideoViewModel extends Model<Partial<AttributesOnly<VideoViewModel>>> {
18 @CreatedAt 19 @CreatedAt
19 createdAt: Date 20 createdAt: Date
20 21
diff --git a/server/models/video/video.ts b/server/models/video/video.ts
index 18afba1ba..1e5648a36 100644
--- a/server/models/video/video.ts
+++ b/server/models/video/video.ts
@@ -1,6 +1,6 @@
1import * as Bluebird from 'bluebird' 1import * as Bluebird from 'bluebird'
2import { remove } from 'fs-extra' 2import { remove } from 'fs-extra'
3import { maxBy, minBy, pick } from 'lodash' 3import { maxBy, minBy } from 'lodash'
4import { join } from 'path' 4import { join } from 'path'
5import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize' 5import { FindOptions, Includeable, IncludeOptions, Op, QueryTypes, ScopeOptions, Sequelize, Transaction, WhereOptions } from 'sequelize'
6import { 6import {
@@ -27,10 +27,11 @@ import {
27import { setAsUpdated } from '@server/helpers/database-utils' 27import { setAsUpdated } from '@server/helpers/database-utils'
28import { buildNSFWFilter } from '@server/helpers/express-utils' 28import { buildNSFWFilter } from '@server/helpers/express-utils'
29import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video' 29import { getPrivaciesForFederation, isPrivacyForFederation, isStateForFederation } from '@server/helpers/video'
30import { LiveManager } from '@server/lib/live-manager' 30import { LiveManager } from '@server/lib/live/live-manager'
31import { getHLSDirectory, getVideoFilePath } from '@server/lib/video-paths' 31import { getHLSDirectory, getVideoFilePath } from '@server/lib/video-paths'
32import { getServerActor } from '@server/models/application/application' 32import { getServerActor } from '@server/models/application/application'
33import { ModelCache } from '@server/models/model-cache' 33import { ModelCache } from '@server/models/model-cache'
34import { AttributesOnly } from '@shared/core-utils'
34import { VideoFile } from '@shared/models/videos/video-file.model' 35import { VideoFile } from '@shared/models/videos/video-file.model'
35import { ResultList, UserRight, VideoPrivacy, VideoState } from '../../../shared' 36import { ResultList, UserRight, VideoPrivacy, VideoState } from '../../../shared'
36import { VideoObject } from '../../../shared/models/activitypub/objects' 37import { VideoObject } from '../../../shared/models/activitypub/objects'
@@ -42,11 +43,8 @@ import { peertubeTruncate } from '../../helpers/core-utils'
42import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc' 43import { isActivityPubUrlValid } from '../../helpers/custom-validators/activitypub/misc'
43import { isBooleanValid } from '../../helpers/custom-validators/misc' 44import { isBooleanValid } from '../../helpers/custom-validators/misc'
44import { 45import {
45 isVideoCategoryValid,
46 isVideoDescriptionValid, 46 isVideoDescriptionValid,
47 isVideoDurationValid, 47 isVideoDurationValid,
48 isVideoLanguageValid,
49 isVideoLicenceValid,
50 isVideoNameValid, 48 isVideoNameValid,
51 isVideoPrivacyValid, 49 isVideoPrivacyValid,
52 isVideoStateValid, 50 isVideoStateValid,
@@ -55,19 +53,7 @@ import {
55import { getVideoFileResolution } from '../../helpers/ffprobe-utils' 53import { getVideoFileResolution } from '../../helpers/ffprobe-utils'
56import { logger } from '../../helpers/logger' 54import { logger } from '../../helpers/logger'
57import { CONFIG } from '../../initializers/config' 55import { CONFIG } from '../../initializers/config'
58import { 56import { ACTIVITY_PUB, API_VERSION, CONSTRAINTS_FIELDS, LAZY_STATIC_PATHS, STATIC_PATHS, WEBSERVER } from '../../initializers/constants'
59 ACTIVITY_PUB,
60 API_VERSION,
61 CONSTRAINTS_FIELDS,
62 LAZY_STATIC_PATHS,
63 STATIC_PATHS,
64 VIDEO_CATEGORIES,
65 VIDEO_LANGUAGES,
66 VIDEO_LICENCES,
67 VIDEO_PRIVACIES,
68 VIDEO_STATES,
69 WEBSERVER
70} from '../../initializers/constants'
71import { sendDeleteVideo } from '../../lib/activitypub/send' 57import { sendDeleteVideo } from '../../lib/activitypub/send'
72import { 58import {
73 MChannel, 59 MChannel,
@@ -87,29 +73,38 @@ import {
87 MVideoFormattableDetails, 73 MVideoFormattableDetails,
88 MVideoForUser, 74 MVideoForUser,
89 MVideoFullLight, 75 MVideoFullLight,
90 MVideoIdThumbnail, 76 MVideoId,
91 MVideoImmutable, 77 MVideoImmutable,
92 MVideoThumbnail, 78 MVideoThumbnail,
93 MVideoThumbnailBlacklist, 79 MVideoThumbnailBlacklist,
94 MVideoWithAllFiles, 80 MVideoWithAllFiles,
95 MVideoWithFile, 81 MVideoWithFile
96 MVideoWithRights
97} from '../../types/models' 82} from '../../types/models'
98import { MThumbnail } from '../../types/models/video/thumbnail' 83import { MThumbnail } from '../../types/models/video/thumbnail'
99import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file' 84import { MVideoFile, MVideoFileStreamingPlaylistVideo } from '../../types/models/video/video-file'
100import { VideoAbuseModel } from '../abuse/video-abuse' 85import { VideoAbuseModel } from '../abuse/video-abuse'
101import { AccountModel } from '../account/account' 86import { AccountModel } from '../account/account'
102import { AccountVideoRateModel } from '../account/account-video-rate' 87import { AccountVideoRateModel } from '../account/account-video-rate'
103import { ActorImageModel } from '../account/actor-image' 88import { ActorModel } from '../actor/actor'
104import { UserModel } from '../account/user' 89import { ActorImageModel } from '../actor/actor-image'
105import { UserVideoHistoryModel } from '../account/user-video-history'
106import { ActorModel } from '../activitypub/actor'
107import { VideoRedundancyModel } from '../redundancy/video-redundancy' 90import { VideoRedundancyModel } from '../redundancy/video-redundancy'
108import { ServerModel } from '../server/server' 91import { ServerModel } from '../server/server'
109import { TrackerModel } from '../server/tracker' 92import { TrackerModel } from '../server/tracker'
110import { VideoTrackerModel } from '../server/video-tracker' 93import { VideoTrackerModel } from '../server/video-tracker'
94import { UserModel } from '../user/user'
95import { UserVideoHistoryModel } from '../user/user-video-history'
111import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils' 96import { buildTrigramSearchIndex, buildWhereIdOrUUID, getVideoSort, isOutdated, throwIfNotValid } from '../utils'
97import {
98 videoFilesModelToFormattedJSON,
99 VideoFormattingJSONOptions,
100 videoModelToActivityPubObject,
101 videoModelToFormattedDetailsJSON,
102 videoModelToFormattedJSON
103} from './formatter/video-format-utils'
112import { ScheduleVideoUpdateModel } from './schedule-video-update' 104import { ScheduleVideoUpdateModel } from './schedule-video-update'
105import { VideosModelGetQueryBuilder } from './sql/video-model-get-query-builder'
106import { BuildVideosListQueryOptions, VideosIdListQueryBuilder } from './sql/videos-id-list-query-builder'
107import { VideosModelListQueryBuilder } from './sql/videos-model-list-query-builder'
113import { TagModel } from './tag' 108import { TagModel } from './tag'
114import { ThumbnailModel } from './thumbnail' 109import { ThumbnailModel } from './thumbnail'
115import { VideoBlacklistModel } from './video-blacklist' 110import { VideoBlacklistModel } from './video-blacklist'
@@ -117,37 +112,25 @@ import { VideoCaptionModel } from './video-caption'
117import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel' 112import { ScopeNames as VideoChannelScopeNames, SummaryOptions, VideoChannelModel } from './video-channel'
118import { VideoCommentModel } from './video-comment' 113import { VideoCommentModel } from './video-comment'
119import { VideoFileModel } from './video-file' 114import { VideoFileModel } from './video-file'
120import {
121 videoFilesModelToFormattedJSON,
122 VideoFormattingJSONOptions,
123 videoModelToActivityPubObject,
124 videoModelToFormattedDetailsJSON,
125 videoModelToFormattedJSON
126} from './video-format-utils'
127import { VideoImportModel } from './video-import' 115import { VideoImportModel } from './video-import'
128import { VideoLiveModel } from './video-live' 116import { VideoLiveModel } from './video-live'
129import { VideoPlaylistElementModel } from './video-playlist-element' 117import { VideoPlaylistElementModel } from './video-playlist-element'
130import { buildListQuery, BuildVideosQueryOptions, wrapForAPIResults } from './video-query-builder'
131import { VideoShareModel } from './video-share' 118import { VideoShareModel } from './video-share'
132import { VideoStreamingPlaylistModel } from './video-streaming-playlist' 119import { VideoStreamingPlaylistModel } from './video-streaming-playlist'
133import { VideoTagModel } from './video-tag' 120import { VideoTagModel } from './video-tag'
134import { VideoViewModel } from './video-view' 121import { VideoViewModel } from './video-view'
135 122
136export enum ScopeNames { 123export enum ScopeNames {
137 AVAILABLE_FOR_LIST_IDS = 'AVAILABLE_FOR_LIST_IDS',
138 FOR_API = 'FOR_API', 124 FOR_API = 'FOR_API',
139 WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS', 125 WITH_ACCOUNT_DETAILS = 'WITH_ACCOUNT_DETAILS',
140 WITH_TAGS = 'WITH_TAGS', 126 WITH_TAGS = 'WITH_TAGS',
141 WITH_TRACKERS = 'WITH_TRACKERS',
142 WITH_WEBTORRENT_FILES = 'WITH_WEBTORRENT_FILES', 127 WITH_WEBTORRENT_FILES = 'WITH_WEBTORRENT_FILES',
143 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE', 128 WITH_SCHEDULED_UPDATE = 'WITH_SCHEDULED_UPDATE',
144 WITH_BLACKLISTED = 'WITH_BLACKLISTED', 129 WITH_BLACKLISTED = 'WITH_BLACKLISTED',
145 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
146 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS', 130 WITH_STREAMING_PLAYLISTS = 'WITH_STREAMING_PLAYLISTS',
147 WITH_USER_ID = 'WITH_USER_ID',
148 WITH_IMMUTABLE_ATTRIBUTES = 'WITH_IMMUTABLE_ATTRIBUTES', 131 WITH_IMMUTABLE_ATTRIBUTES = 'WITH_IMMUTABLE_ATTRIBUTES',
149 WITH_THUMBNAILS = 'WITH_THUMBNAILS', 132 WITH_USER_HISTORY = 'WITH_USER_HISTORY',
150 WITH_LIVE = 'WITH_LIVE' 133 WITH_THUMBNAILS = 'WITH_THUMBNAILS'
151} 134}
152 135
153export type ForAPIOptions = { 136export type ForAPIOptions = {
@@ -243,30 +226,6 @@ export type AvailableForListIDsOptions = {
243 } 226 }
244 ] 227 ]
245 }, 228 },
246 [ScopeNames.WITH_LIVE]: {
247 include: [
248 {
249 model: VideoLiveModel.unscoped(),
250 required: false
251 }
252 ]
253 },
254 [ScopeNames.WITH_USER_ID]: {
255 include: [
256 {
257 attributes: [ 'accountId' ],
258 model: VideoChannelModel.unscoped(),
259 required: true,
260 include: [
261 {
262 attributes: [ 'userId' ],
263 model: AccountModel.unscoped(),
264 required: true
265 }
266 ]
267 }
268 ]
269 },
270 [ScopeNames.WITH_ACCOUNT_DETAILS]: { 229 [ScopeNames.WITH_ACCOUNT_DETAILS]: {
271 include: [ 230 include: [
272 { 231 {
@@ -324,14 +283,6 @@ export type AvailableForListIDsOptions = {
324 [ScopeNames.WITH_TAGS]: { 283 [ScopeNames.WITH_TAGS]: {
325 include: [ TagModel ] 284 include: [ TagModel ]
326 }, 285 },
327 [ScopeNames.WITH_TRACKERS]: {
328 include: [
329 {
330 attributes: [ 'id', 'url' ],
331 model: TrackerModel
332 }
333 ]
334 },
335 [ScopeNames.WITH_BLACKLISTED]: { 286 [ScopeNames.WITH_BLACKLISTED]: {
336 include: [ 287 include: [
337 { 288 {
@@ -489,7 +440,7 @@ export type AvailableForListIDsOptions = {
489 } 440 }
490 ] 441 ]
491}) 442})
492export class VideoModel extends Model { 443export class VideoModel extends Model<Partial<AttributesOnly<VideoModel>>> {
493 444
494 @AllowNull(false) 445 @AllowNull(false)
495 @Default(DataType.UUIDV4) 446 @Default(DataType.UUIDV4)
@@ -504,19 +455,16 @@ export class VideoModel extends Model {
504 455
505 @AllowNull(true) 456 @AllowNull(true)
506 @Default(null) 457 @Default(null)
507 @Is('VideoCategory', value => throwIfNotValid(value, isVideoCategoryValid, 'category', true))
508 @Column 458 @Column
509 category: number 459 category: number
510 460
511 @AllowNull(true) 461 @AllowNull(true)
512 @Default(null) 462 @Default(null)
513 @Is('VideoLicence', value => throwIfNotValid(value, isVideoLicenceValid, 'licence', true))
514 @Column 463 @Column
515 licence: number 464 licence: number
516 465
517 @AllowNull(true) 466 @AllowNull(true)
518 @Default(null) 467 @Default(null)
519 @Is('VideoLanguage', value => throwIfNotValid(value, isVideoLanguageValid, 'language', true))
520 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max)) 468 @Column(DataType.STRING(CONSTRAINTS_FIELDS.VIDEOS.LANGUAGE.max))
521 language: string 469 language: string
522 470
@@ -624,7 +572,7 @@ export class VideoModel extends Model {
624 foreignKey: { 572 foreignKey: {
625 allowNull: true 573 allowNull: true
626 }, 574 },
627 hooks: true 575 onDelete: 'cascade'
628 }) 576 })
629 VideoChannel: VideoChannelModel 577 VideoChannel: VideoChannelModel
630 578
@@ -802,14 +750,14 @@ export class VideoModel extends Model {
802 } 750 }
803 751
804 @BeforeDestroy 752 @BeforeDestroy
805 static async removeFiles (instance: VideoModel) { 753 static async removeFiles (instance: VideoModel, options) {
806 const tasks: Promise<any>[] = [] 754 const tasks: Promise<any>[] = []
807 755
808 logger.info('Removing files of video %s.', instance.url) 756 logger.info('Removing files of video %s.', instance.url)
809 757
810 if (instance.isOwned()) { 758 if (instance.isOwned()) {
811 if (!Array.isArray(instance.VideoFiles)) { 759 if (!Array.isArray(instance.VideoFiles)) {
812 instance.VideoFiles = await instance.$get('VideoFiles') 760 instance.VideoFiles = await instance.$get('VideoFiles', { transaction: options.transaction })
813 } 761 }
814 762
815 // Remove physical files and torrents 763 // Remove physical files and torrents
@@ -820,7 +768,7 @@ export class VideoModel extends Model {
820 768
821 // Remove playlists file 769 // Remove playlists file
822 if (!Array.isArray(instance.VideoStreamingPlaylists)) { 770 if (!Array.isArray(instance.VideoStreamingPlaylists)) {
823 instance.VideoStreamingPlaylists = await instance.$get('VideoStreamingPlaylists') 771 instance.VideoStreamingPlaylists = await instance.$get('VideoStreamingPlaylists', { transaction: options.transaction })
824 } 772 }
825 773
826 for (const p of instance.VideoStreamingPlaylists) { 774 for (const p of instance.VideoStreamingPlaylists) {
@@ -843,7 +791,7 @@ export class VideoModel extends Model {
843 791
844 logger.info('Stopping live of video %s after video deletion.', instance.uuid) 792 logger.info('Stopping live of video %s after video deletion.', instance.uuid)
845 793
846 return LiveManager.Instance.stopSessionOf(instance.id) 794 LiveManager.Instance.stopSessionOf(instance.id)
847 } 795 }
848 796
849 @BeforeDestroy 797 @BeforeDestroy
@@ -856,7 +804,7 @@ export class VideoModel extends Model {
856 const tasks: Promise<any>[] = [] 804 const tasks: Promise<any>[] = []
857 805
858 if (!Array.isArray(instance.VideoAbuses)) { 806 if (!Array.isArray(instance.VideoAbuses)) {
859 instance.VideoAbuses = await instance.$get('VideoAbuses') 807 instance.VideoAbuses = await instance.$get('VideoAbuses', { transaction: options.transaction })
860 808
861 if (instance.VideoAbuses.length === 0) return undefined 809 if (instance.VideoAbuses.length === 0) return undefined
862 } 810 }
@@ -871,12 +819,7 @@ export class VideoModel extends Model {
871 tasks.push(abuse.save({ transaction: options.transaction })) 819 tasks.push(abuse.save({ transaction: options.transaction }))
872 } 820 }
873 821
874 Promise.all(tasks) 822 await Promise.all(tasks)
875 .catch(err => {
876 logger.error('Some errors when saving details of video %s in its abuses before destroy hook.', instance.uuid, { err })
877 })
878
879 return undefined
880 } 823 }
881 824
882 static listLocal (): Promise<MVideo[]> { 825 static listLocal (): Promise<MVideo[]> {
@@ -1003,9 +946,9 @@ export class VideoModel extends Model {
1003 }) 946 })
1004 } 947 }
1005 948
1006 static async listPublishedLiveIds () { 949 static async listPublishedLiveUUIDs () {
1007 const options = { 950 const options = {
1008 attributes: [ 'id' ], 951 attributes: [ 'uuid' ],
1009 where: { 952 where: {
1010 isLive: true, 953 isLive: true,
1011 remote: false, 954 remote: false,
@@ -1015,7 +958,7 @@ export class VideoModel extends Model {
1015 958
1016 const result = await VideoModel.findAll(options) 959 const result = await VideoModel.findAll(options)
1017 960
1018 return result.map(v => v.id) 961 return result.map(v => v.uuid)
1019 } 962 }
1020 963
1021 static listUserVideosForApi (options: { 964 static listUserVideosForApi (options: {
@@ -1298,27 +1241,16 @@ export class VideoModel extends Model {
1298 return VideoModel.count(options) 1241 return VideoModel.count(options)
1299 } 1242 }
1300 1243
1301 static load (id: number | string, t?: Transaction): Promise<MVideoThumbnail> { 1244 static load (id: number | string, transaction?: Transaction): Promise<MVideoThumbnail> {
1302 const where = buildWhereIdOrUUID(id) 1245 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1303 const options = {
1304 where,
1305 transaction: t
1306 }
1307 1246
1308 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options) 1247 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails' })
1309 } 1248 }
1310 1249
1311 static loadWithBlacklist (id: number | string, t?: Transaction): Promise<MVideoThumbnailBlacklist> { 1250 static loadWithBlacklist (id: number | string, transaction?: Transaction): Promise<MVideoThumbnailBlacklist> {
1312 const where = buildWhereIdOrUUID(id) 1251 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1313 const options = {
1314 where,
1315 transaction: t
1316 }
1317 1252
1318 return VideoModel.scope([ 1253 return queryBuilder.queryVideo({ id, transaction, type: 'thumbnails-blacklist' })
1319 ScopeNames.WITH_THUMBNAILS,
1320 ScopeNames.WITH_BLACKLISTED
1321 ]).findOne(options)
1322 } 1254 }
1323 1255
1324 static loadImmutableAttributes (id: number | string, t?: Transaction): Promise<MVideoImmutable> { 1256 static loadImmutableAttributes (id: number | string, t?: Transaction): Promise<MVideoImmutable> {
@@ -1339,68 +1271,6 @@ export class VideoModel extends Model {
1339 }) 1271 })
1340 } 1272 }
1341 1273
1342 static loadWithRights (id: number | string, t?: Transaction): Promise<MVideoWithRights> {
1343 const where = buildWhereIdOrUUID(id)
1344 const options = {
1345 where,
1346 transaction: t
1347 }
1348
1349 return VideoModel.scope([
1350 ScopeNames.WITH_BLACKLISTED,
1351 ScopeNames.WITH_USER_ID
1352 ]).findOne(options)
1353 }
1354
1355 static loadOnlyId (id: number | string, t?: Transaction): Promise<MVideoIdThumbnail> {
1356 const where = buildWhereIdOrUUID(id)
1357
1358 const options = {
1359 attributes: [ 'id' ],
1360 where,
1361 transaction: t
1362 }
1363
1364 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1365 }
1366
1367 static loadWithFiles (id: number | string, t?: Transaction, logging?: boolean): Promise<MVideoWithAllFiles> {
1368 const where = buildWhereIdOrUUID(id)
1369
1370 const query = {
1371 where,
1372 transaction: t,
1373 logging
1374 }
1375
1376 return VideoModel.scope([
1377 ScopeNames.WITH_WEBTORRENT_FILES,
1378 ScopeNames.WITH_STREAMING_PLAYLISTS,
1379 ScopeNames.WITH_THUMBNAILS
1380 ]).findOne(query)
1381 }
1382
1383 static loadByUUID (uuid: string): Promise<MVideoThumbnail> {
1384 const options = {
1385 where: {
1386 uuid
1387 }
1388 }
1389
1390 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(options)
1391 }
1392
1393 static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoThumbnail> {
1394 const query: FindOptions = {
1395 where: {
1396 url
1397 },
1398 transaction
1399 }
1400
1401 return VideoModel.scope(ScopeNames.WITH_THUMBNAILS).findOne(query)
1402 }
1403
1404 static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise<MVideoImmutable> { 1274 static loadByUrlImmutableAttributes (url: string, transaction?: Transaction): Promise<MVideoImmutable> {
1405 const fun = () => { 1275 const fun = () => {
1406 const query: FindOptions = { 1276 const query: FindOptions = {
@@ -1421,85 +1291,45 @@ export class VideoModel extends Model {
1421 }) 1291 })
1422 } 1292 }
1423 1293
1424 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLightBlacklistAllFiles> { 1294 static loadOnlyId (id: number | string, transaction?: Transaction): Promise<MVideoId> {
1425 const query: FindOptions = { 1295 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1426 where: {
1427 url
1428 },
1429 transaction
1430 }
1431 1296
1432 return VideoModel.scope([ 1297 return queryBuilder.queryVideo({ id, transaction, type: 'id' })
1433 ScopeNames.WITH_ACCOUNT_DETAILS,
1434 ScopeNames.WITH_WEBTORRENT_FILES,
1435 ScopeNames.WITH_STREAMING_PLAYLISTS,
1436 ScopeNames.WITH_THUMBNAILS,
1437 ScopeNames.WITH_BLACKLISTED
1438 ]).findOne(query)
1439 } 1298 }
1440 1299
1441 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> { 1300 static loadWithFiles (id: number | string, transaction?: Transaction, logging?: boolean): Promise<MVideoWithAllFiles> {
1442 const where = buildWhereIdOrUUID(id) 1301 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1443 1302
1444 const options = { 1303 return queryBuilder.queryVideo({ id, transaction, type: 'all-files', logging })
1445 order: [ [ 'Tags', 'name', 'ASC' ] ] as any, 1304 }
1446 where,
1447 transaction: t
1448 }
1449 1305
1450 const scopes: (string | ScopeOptions)[] = [ 1306 static loadByUrl (url: string, transaction?: Transaction): Promise<MVideoThumbnail> {
1451 ScopeNames.WITH_TAGS, 1307 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1452 ScopeNames.WITH_BLACKLISTED,
1453 ScopeNames.WITH_ACCOUNT_DETAILS,
1454 ScopeNames.WITH_SCHEDULED_UPDATE,
1455 ScopeNames.WITH_WEBTORRENT_FILES,
1456 ScopeNames.WITH_STREAMING_PLAYLISTS,
1457 ScopeNames.WITH_THUMBNAILS,
1458 ScopeNames.WITH_LIVE
1459 ]
1460 1308
1461 if (userId) { 1309 return queryBuilder.queryVideo({ url, transaction, type: 'thumbnails' })
1462 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] }) 1310 }
1463 } 1311
1312 static loadByUrlAndPopulateAccount (url: string, transaction?: Transaction): Promise<MVideoAccountLightBlacklistAllFiles> {
1313 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1314
1315 return queryBuilder.queryVideo({ url, transaction, type: 'account-blacklist-files' })
1316 }
1464 1317
1465 return VideoModel 1318 static loadAndPopulateAccountAndServerAndTags (id: number | string, t?: Transaction, userId?: number): Promise<MVideoFullLight> {
1466 .scope(scopes) 1319 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1467 .findOne(options) 1320
1321 return queryBuilder.queryVideo({ id, transaction: t, type: 'full-light', userId })
1468 } 1322 }
1469 1323
1470 static loadForGetAPI (parameters: { 1324 static loadForGetAPI (parameters: {
1471 id: number | string 1325 id: number | string
1472 t?: Transaction 1326 transaction?: Transaction
1473 userId?: number 1327 userId?: number
1474 }): Promise<MVideoDetails> { 1328 }): Promise<MVideoDetails> {
1475 const { id, t, userId } = parameters 1329 const { id, transaction, userId } = parameters
1476 const where = buildWhereIdOrUUID(id) 1330 const queryBuilder = new VideosModelGetQueryBuilder(VideoModel.sequelize)
1477 1331
1478 const options = { 1332 return queryBuilder.queryVideo({ id, transaction, type: 'api', userId })
1479 order: [ [ 'Tags', 'name', 'ASC' ] ] as any, // FIXME: sequelize typings
1480 where,
1481 transaction: t
1482 }
1483
1484 const scopes: (string | ScopeOptions)[] = [
1485 ScopeNames.WITH_TAGS,
1486 ScopeNames.WITH_BLACKLISTED,
1487 ScopeNames.WITH_ACCOUNT_DETAILS,
1488 ScopeNames.WITH_SCHEDULED_UPDATE,
1489 ScopeNames.WITH_THUMBNAILS,
1490 ScopeNames.WITH_LIVE,
1491 ScopeNames.WITH_TRACKERS,
1492 { method: [ ScopeNames.WITH_WEBTORRENT_FILES, true ] },
1493 { method: [ ScopeNames.WITH_STREAMING_PLAYLISTS, true ] }
1494 ]
1495
1496 if (userId) {
1497 scopes.push({ method: [ ScopeNames.WITH_USER_HISTORY, userId ] })
1498 }
1499
1500 return VideoModel
1501 .scope(scopes)
1502 .findOne(options)
1503 } 1333 }
1504 1334
1505 static async getStats () { 1335 static async getStats () {
@@ -1550,7 +1380,7 @@ export class VideoModel extends Model {
1550 1380
1551 const rawQuery = `UPDATE "video" SET "${field}" = ` + 1381 const rawQuery = `UPDATE "video" SET "${field}" = ` +
1552 '(' + 1382 '(' +
1553 'SELECT COUNT(id) FROM "accountVideoRate" WHERE "accountVideoRate"."videoId" = "video"."id" AND type = :rateType' + 1383 'SELECT COUNT(id) FROM "accountVideoRate" WHERE "accountVideoRate"."videoId" = "video"."id" AND type = :rateType' +
1554 ') ' + 1384 ') ' +
1555 'WHERE "video"."id" = :videoId' 1385 'WHERE "video"."id" = :videoId'
1556 1386
@@ -1578,15 +1408,15 @@ export class VideoModel extends Model {
1578 .then(results => results.length === 1) 1408 .then(results => results.length === 1)
1579 } 1409 }
1580 1410
1581 static bulkUpdateSupportField (videoChannel: MChannel, t: Transaction) { 1411 static bulkUpdateSupportField (ofChannel: MChannel, t: Transaction) {
1582 const options = { 1412 const options = {
1583 where: { 1413 where: {
1584 channelId: videoChannel.id 1414 channelId: ofChannel.id
1585 }, 1415 },
1586 transaction: t 1416 transaction: t
1587 } 1417 }
1588 1418
1589 return VideoModel.update({ support: videoChannel.support }, options) 1419 return VideoModel.update({ support: ofChannel.support }, options)
1590 } 1420 }
1591 1421
1592 static getAllIdsFromChannel (videoChannel: MChannelId): Promise<number[]> { 1422 static getAllIdsFromChannel (videoChannel: MChannelId): Promise<number[]> {
@@ -1606,7 +1436,7 @@ export class VideoModel extends Model {
1606 const serverActor = await getServerActor() 1436 const serverActor = await getServerActor()
1607 const followerActorId = serverActor.id 1437 const followerActorId = serverActor.id
1608 1438
1609 const queryOptions: BuildVideosQueryOptions = { 1439 const queryOptions: BuildVideosListQueryOptions = {
1610 attributes: [ `"${field}"` ], 1440 attributes: [ `"${field}"` ],
1611 group: `GROUP BY "${field}"`, 1441 group: `GROUP BY "${field}"`,
1612 having: `HAVING COUNT("${field}") >= ${threshold}`, 1442 having: `HAVING COUNT("${field}") >= ${threshold}`,
@@ -1618,10 +1448,10 @@ export class VideoModel extends Model {
1618 includeLocalVideos: true 1448 includeLocalVideos: true
1619 } 1449 }
1620 1450
1621 const { query, replacements } = buildListQuery(VideoModel, queryOptions) 1451 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1622 1452
1623 return this.sequelize.query<any>(query, { replacements, type: QueryTypes.SELECT }) 1453 return queryBuilder.queryVideoIds(queryOptions)
1624 .then(rows => rows.map(r => r[field])) 1454 .then(rows => rows.map(r => r[field]))
1625 } 1455 }
1626 1456
1627 static buildTrendingQuery (trendingDays: number) { 1457 static buildTrendingQuery (trendingDays: number) {
@@ -1639,27 +1469,24 @@ export class VideoModel extends Model {
1639 } 1469 }
1640 1470
1641 private static async getAvailableForApi ( 1471 private static async getAvailableForApi (
1642 options: BuildVideosQueryOptions, 1472 options: BuildVideosListQueryOptions,
1643 countVideos = true 1473 countVideos = true
1644 ): Promise<ResultList<VideoModel>> { 1474 ): Promise<ResultList<VideoModel>> {
1645 function getCount () { 1475 function getCount () {
1646 if (countVideos !== true) return Promise.resolve(undefined) 1476 if (countVideos !== true) return Promise.resolve(undefined)
1647 1477
1648 const countOptions = Object.assign({}, options, { isCount: true }) 1478 const countOptions = Object.assign({}, options, { isCount: true })
1649 const { query: queryCount, replacements: replacementsCount } = buildListQuery(VideoModel, countOptions) 1479 const queryBuilder = new VideosIdListQueryBuilder(VideoModel.sequelize)
1650 1480
1651 return VideoModel.sequelize.query<any>(queryCount, { replacements: replacementsCount, type: QueryTypes.SELECT }) 1481 return queryBuilder.countVideoIds(countOptions)
1652 .then(rows => rows.length !== 0 ? rows[0].total : 0)
1653 } 1482 }
1654 1483
1655 function getModels () { 1484 function getModels () {
1656 if (options.count === 0) return Promise.resolve([]) 1485 if (options.count === 0) return Promise.resolve([])
1657 1486
1658 const { query, replacements, order } = buildListQuery(VideoModel, options) 1487 const queryBuilder = new VideosModelListQueryBuilder(VideoModel.sequelize)
1659 const queryModels = wrapForAPIResults(query, replacements, options, order)
1660 1488
1661 return VideoModel.sequelize.query<any>(queryModels, { replacements, type: QueryTypes.SELECT, nest: true }) 1489 return queryBuilder.queryVideos(options)
1662 .then(rows => VideoModel.buildAPIResult(rows))
1663 } 1490 }
1664 1491
1665 const [ count, rows ] = await Promise.all([ getCount(), getModels() ]) 1492 const [ count, rows ] = await Promise.all([ getCount(), getModels() ])
@@ -1670,173 +1497,6 @@ export class VideoModel extends Model {
1670 } 1497 }
1671 } 1498 }
1672 1499
1673 private static buildAPIResult (rows: any[]) {
1674 const videosMemo: { [ id: number ]: VideoModel } = {}
1675 const videoStreamingPlaylistMemo: { [ id: number ]: VideoStreamingPlaylistModel } = {}
1676
1677 const thumbnailsDone = new Set<number>()
1678 const historyDone = new Set<number>()
1679 const videoFilesDone = new Set<number>()
1680
1681 const videos: VideoModel[] = []
1682
1683 const avatarKeys = [ 'id', 'filename', 'fileUrl', 'onDisk', 'createdAt', 'updatedAt' ]
1684 const actorKeys = [ 'id', 'preferredUsername', 'url', 'serverId', 'avatarId' ]
1685 const serverKeys = [ 'id', 'host' ]
1686 const videoFileKeys = [
1687 'id',
1688 'createdAt',
1689 'updatedAt',
1690 'resolution',
1691 'size',
1692 'extname',
1693 'filename',
1694 'fileUrl',
1695 'torrentFilename',
1696 'torrentUrl',
1697 'infoHash',
1698 'fps',
1699 'videoId',
1700 'videoStreamingPlaylistId'
1701 ]
1702 const videoStreamingPlaylistKeys = [ 'id', 'type', 'playlistUrl' ]
1703 const videoKeys = [
1704 'id',
1705 'uuid',
1706 'name',
1707 'category',
1708 'licence',
1709 'language',
1710 'privacy',
1711 'nsfw',
1712 'description',
1713 'support',
1714 'duration',
1715 'views',
1716 'likes',
1717 'dislikes',
1718 'remote',
1719 'isLive',
1720 'url',
1721 'commentsEnabled',
1722 'downloadEnabled',
1723 'waitTranscoding',
1724 'state',
1725 'publishedAt',
1726 'originallyPublishedAt',
1727 'channelId',
1728 'createdAt',
1729 'updatedAt'
1730 ]
1731 const buildOpts = { raw: true }
1732
1733 function buildActor (rowActor: any) {
1734 const avatarModel = rowActor.Avatar.id !== null
1735 ? new ActorImageModel(pick(rowActor.Avatar, avatarKeys), buildOpts)
1736 : null
1737
1738 const serverModel = rowActor.Server.id !== null
1739 ? new ServerModel(pick(rowActor.Server, serverKeys), buildOpts)
1740 : null
1741
1742 const actorModel = new ActorModel(pick(rowActor, actorKeys), buildOpts)
1743 actorModel.Avatar = avatarModel
1744 actorModel.Server = serverModel
1745
1746 return actorModel
1747 }
1748
1749 for (const row of rows) {
1750 if (!videosMemo[row.id]) {
1751 // Build Channel
1752 const channel = row.VideoChannel
1753 const channelModel = new VideoChannelModel(pick(channel, [ 'id', 'name', 'description', 'actorId' ]), buildOpts)
1754 channelModel.Actor = buildActor(channel.Actor)
1755
1756 const account = row.VideoChannel.Account
1757 const accountModel = new AccountModel(pick(account, [ 'id', 'name' ]), buildOpts)
1758 accountModel.Actor = buildActor(account.Actor)
1759
1760 channelModel.Account = accountModel
1761
1762 const videoModel = new VideoModel(pick(row, videoKeys), buildOpts)
1763 videoModel.VideoChannel = channelModel
1764
1765 videoModel.UserVideoHistories = []
1766 videoModel.Thumbnails = []
1767 videoModel.VideoFiles = []
1768 videoModel.VideoStreamingPlaylists = []
1769
1770 videosMemo[row.id] = videoModel
1771 // Don't take object value to have a sorted array
1772 videos.push(videoModel)
1773 }
1774
1775 const videoModel = videosMemo[row.id]
1776
1777 if (row.userVideoHistory?.id && !historyDone.has(row.userVideoHistory.id)) {
1778 const historyModel = new UserVideoHistoryModel(pick(row.userVideoHistory, [ 'id', 'currentTime' ]), buildOpts)
1779 videoModel.UserVideoHistories.push(historyModel)
1780
1781 historyDone.add(row.userVideoHistory.id)
1782 }
1783
1784 if (row.Thumbnails?.id && !thumbnailsDone.has(row.Thumbnails.id)) {
1785 const thumbnailModel = new ThumbnailModel(pick(row.Thumbnails, [ 'id', 'type', 'filename' ]), buildOpts)
1786 videoModel.Thumbnails.push(thumbnailModel)
1787
1788 thumbnailsDone.add(row.Thumbnails.id)
1789 }
1790
1791 if (row.VideoFiles?.id && !videoFilesDone.has(row.VideoFiles.id)) {
1792 const videoFileModel = new VideoFileModel(pick(row.VideoFiles, videoFileKeys), buildOpts)
1793 videoModel.VideoFiles.push(videoFileModel)
1794
1795 videoFilesDone.add(row.VideoFiles.id)
1796 }
1797
1798 if (row.VideoStreamingPlaylists?.id && !videoStreamingPlaylistMemo[row.VideoStreamingPlaylists.id]) {
1799 const streamingPlaylist = new VideoStreamingPlaylistModel(pick(row.VideoStreamingPlaylists, videoStreamingPlaylistKeys), buildOpts)
1800 streamingPlaylist.VideoFiles = []
1801
1802 videoModel.VideoStreamingPlaylists.push(streamingPlaylist)
1803
1804 videoStreamingPlaylistMemo[streamingPlaylist.id] = streamingPlaylist
1805 }
1806
1807 if (row.VideoStreamingPlaylists?.VideoFiles?.id && !videoFilesDone.has(row.VideoStreamingPlaylists.VideoFiles.id)) {
1808 const streamingPlaylist = videoStreamingPlaylistMemo[row.VideoStreamingPlaylists.id]
1809
1810 const videoFileModel = new VideoFileModel(pick(row.VideoStreamingPlaylists.VideoFiles, videoFileKeys), buildOpts)
1811 streamingPlaylist.VideoFiles.push(videoFileModel)
1812
1813 videoFilesDone.add(row.VideoStreamingPlaylists.VideoFiles.id)
1814 }
1815 }
1816
1817 return videos
1818 }
1819
1820 static getCategoryLabel (id: number) {
1821 return VIDEO_CATEGORIES[id] || 'Misc'
1822 }
1823
1824 static getLicenceLabel (id: number) {
1825 return VIDEO_LICENCES[id] || 'Unknown'
1826 }
1827
1828 static getLanguageLabel (id: string) {
1829 return VIDEO_LANGUAGES[id] || 'Unknown'
1830 }
1831
1832 static getPrivacyLabel (id: number) {
1833 return VIDEO_PRIVACIES[id] || 'Unknown'
1834 }
1835
1836 static getStateLabel (id: number) {
1837 return VIDEO_STATES[id] || 'Unknown'
1838 }
1839
1840 isBlacklisted () { 1500 isBlacklisted () {
1841 return !!this.VideoBlacklist 1501 return !!this.VideoBlacklist
1842 } 1502 }
@@ -1885,7 +1545,7 @@ export class VideoModel extends Model {
1885 return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0 1545 return Array.isArray(this.VideoFiles) === true && this.VideoFiles.length !== 0
1886 } 1546 }
1887 1547
1888 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction: Transaction) { 1548 async addAndSaveThumbnail (thumbnail: MThumbnail, transaction?: Transaction) {
1889 thumbnail.videoId = this.id 1549 thumbnail.videoId = this.id
1890 1550
1891 const savedThumbnail = await thumbnail.save({ transaction }) 1551 const savedThumbnail = await thumbnail.save({ transaction })
@@ -1919,7 +1579,7 @@ export class VideoModel extends Model {
1919 } 1579 }
1920 1580
1921 getWatchStaticPath () { 1581 getWatchStaticPath () {
1922 return '/videos/watch/' + this.uuid 1582 return '/w/' + this.uuid
1923 } 1583 }
1924 1584
1925 getEmbedStaticPath () { 1585 getEmbedStaticPath () {
diff --git a/server/tests/api/activitypub/client.ts b/server/tests/api/activitypub/client.ts
index b6c538e19..be94e219c 100644
--- a/server/tests/api/activitypub/client.ts
+++ b/server/tests/api/activitypub/client.ts
@@ -1,23 +1,65 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import * as chai from 'chai'
4import 'mocha' 3import 'mocha'
4import * as chai from 'chai'
5import { VideoPlaylistPrivacy } from '@shared/models'
6import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
5import { 7import {
6 cleanupTests, 8 cleanupTests,
9 createVideoPlaylist,
7 doubleFollow, 10 doubleFollow,
8 flushAndRunMultipleServers, 11 flushAndRunMultipleServers,
9 makeActivityPubGetRequest, 12 makeActivityPubGetRequest,
10 ServerInfo, 13 ServerInfo,
11 setAccessTokensToServers, 14 setAccessTokensToServers,
12 uploadVideo 15 setDefaultVideoChannel,
16 uploadVideoAndGetId
13} from '../../../../shared/extra-utils' 17} from '../../../../shared/extra-utils'
14import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
15 18
16const expect = chai.expect 19const expect = chai.expect
17 20
18describe('Test activitypub', function () { 21describe('Test activitypub', function () {
19 let servers: ServerInfo[] = [] 22 let servers: ServerInfo[] = []
20 let videoUUID: string 23 let video: { id: number, uuid: string, shortUUID: string }
24 let playlist: { id: number, uuid: string, shortUUID: string }
25
26 async function testAccount (path: string) {
27 const res = await makeActivityPubGetRequest(servers[0].url, path)
28 const object = res.body
29
30 expect(object.type).to.equal('Person')
31 expect(object.id).to.equal('http://localhost:' + servers[0].port + '/accounts/root')
32 expect(object.name).to.equal('root')
33 expect(object.preferredUsername).to.equal('root')
34 }
35
36 async function testChannel (path: string) {
37 const res = await makeActivityPubGetRequest(servers[0].url, path)
38 const object = res.body
39
40 expect(object.type).to.equal('Group')
41 expect(object.id).to.equal('http://localhost:' + servers[0].port + '/video-channels/root_channel')
42 expect(object.name).to.equal('Main root channel')
43 expect(object.preferredUsername).to.equal('root_channel')
44 }
45
46 async function testVideo (path: string) {
47 const res = await makeActivityPubGetRequest(servers[0].url, path)
48 const object = res.body
49
50 expect(object.type).to.equal('Video')
51 expect(object.id).to.equal('http://localhost:' + servers[0].port + '/videos/watch/' + video.uuid)
52 expect(object.name).to.equal('video')
53 }
54
55 async function testPlaylist (path: string) {
56 const res = await makeActivityPubGetRequest(servers[0].url, path)
57 const object = res.body
58
59 expect(object.type).to.equal('Playlist')
60 expect(object.id).to.equal('http://localhost:' + servers[0].port + '/video-playlists/' + playlist.uuid)
61 expect(object.name).to.equal('playlist')
62 }
21 63
22 before(async function () { 64 before(async function () {
23 this.timeout(30000) 65 this.timeout(30000)
@@ -25,38 +67,56 @@ describe('Test activitypub', function () {
25 servers = await flushAndRunMultipleServers(2) 67 servers = await flushAndRunMultipleServers(2)
26 68
27 await setAccessTokensToServers(servers) 69 await setAccessTokensToServers(servers)
70 await setDefaultVideoChannel(servers)
28 71
29 { 72 {
30 const res = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'video' }) 73 video = await uploadVideoAndGetId({ server: servers[0], videoName: 'video' })
31 videoUUID = res.body.video.uuid 74 }
75
76 {
77 const playlistAttrs = { displayName: 'playlist', privacy: VideoPlaylistPrivacy.PUBLIC, videoChannelId: servers[0].videoChannel.id }
78 const resCreate = await createVideoPlaylist({ url: servers[0].url, token: servers[0].accessToken, playlistAttrs })
79 playlist = resCreate.body.videoPlaylist
32 } 80 }
33 81
34 await doubleFollow(servers[0], servers[1]) 82 await doubleFollow(servers[0], servers[1])
35 }) 83 })
36 84
37 it('Should return the account object', async function () { 85 it('Should return the account object', async function () {
38 const res = await makeActivityPubGetRequest(servers[0].url, '/accounts/root') 86 await testAccount('/accounts/root')
39 const object = res.body 87 await testAccount('/a/root')
88 })
40 89
41 expect(object.type).to.equal('Person') 90 it('Should return the channel object', async function () {
42 expect(object.id).to.equal('http://localhost:' + servers[0].port + '/accounts/root') 91 await testChannel('/video-channels/root_channel')
43 expect(object.name).to.equal('root') 92 await testChannel('/c/root_channel')
44 expect(object.preferredUsername).to.equal('root')
45 }) 93 })
46 94
47 it('Should return the video object', async function () { 95 it('Should return the video object', async function () {
48 const res = await makeActivityPubGetRequest(servers[0].url, '/videos/watch/' + videoUUID) 96 await testVideo('/videos/watch/' + video.id)
49 const object = res.body 97 await testVideo('/videos/watch/' + video.uuid)
98 await testVideo('/videos/watch/' + video.shortUUID)
99 await testVideo('/w/' + video.id)
100 await testVideo('/w/' + video.uuid)
101 await testVideo('/w/' + video.shortUUID)
102 })
50 103
51 expect(object.type).to.equal('Video') 104 it('Should return the playlist object', async function () {
52 expect(object.id).to.equal('http://localhost:' + servers[0].port + '/videos/watch/' + videoUUID) 105 await testPlaylist('/video-playlists/' + playlist.id)
53 expect(object.name).to.equal('video') 106 await testPlaylist('/video-playlists/' + playlist.uuid)
107 await testPlaylist('/video-playlists/' + playlist.shortUUID)
108 await testPlaylist('/w/p/' + playlist.id)
109 await testPlaylist('/w/p/' + playlist.uuid)
110 await testPlaylist('/w/p/' + playlist.shortUUID)
111 await testPlaylist('/videos/watch/playlist/' + playlist.id)
112 await testPlaylist('/videos/watch/playlist/' + playlist.uuid)
113 await testPlaylist('/videos/watch/playlist/' + playlist.shortUUID)
54 }) 114 })
55 115
56 it('Should redirect to the origin video object', async function () { 116 it('Should redirect to the origin video object', async function () {
57 const res = await makeActivityPubGetRequest(servers[1].url, '/videos/watch/' + videoUUID, HttpStatusCode.FOUND_302) 117 const res = await makeActivityPubGetRequest(servers[1].url, '/videos/watch/' + video.uuid, HttpStatusCode.FOUND_302)
58 118
59 expect(res.header.location).to.equal('http://localhost:' + servers[0].port + '/videos/watch/' + videoUUID) 119 expect(res.header.location).to.equal('http://localhost:' + servers[0].port + '/videos/watch/' + video.uuid)
60 }) 120 })
61 121
62 after(async function () { 122 after(async function () {
diff --git a/server/tests/api/activitypub/helpers.ts b/server/tests/api/activitypub/helpers.ts
index 60d95b823..66d7631b7 100644
--- a/server/tests/api/activitypub/helpers.ts
+++ b/server/tests/api/activitypub/helpers.ts
@@ -6,13 +6,14 @@ import { buildRequestStub } from '../../../../shared/extra-utils/miscs/stubs'
6import { isHTTPSignatureVerified, isJsonLDSignatureVerified, parseHTTPSignature } from '../../../helpers/peertube-crypto' 6import { isHTTPSignatureVerified, isJsonLDSignatureVerified, parseHTTPSignature } from '../../../helpers/peertube-crypto'
7import { cloneDeep } from 'lodash' 7import { cloneDeep } from 'lodash'
8import { buildSignedActivity } from '../../../helpers/activitypub' 8import { buildSignedActivity } from '../../../helpers/activitypub'
9import { buildAbsoluteFixturePath } from '@shared/extra-utils'
9 10
10describe('Test activity pub helpers', function () { 11describe('Test activity pub helpers', function () {
11 describe('When checking the Linked Signature', function () { 12 describe('When checking the Linked Signature', function () {
12 13
13 it('Should fail with an invalid Mastodon signature', async function () { 14 it('Should fail with an invalid Mastodon signature', async function () {
14 const body = require('./json/mastodon/create-bad-signature.json') 15 const body = require(buildAbsoluteFixturePath('./ap-json/mastodon/create-bad-signature.json'))
15 const publicKey = require('./json/mastodon/public-key.json').publicKey 16 const publicKey = require(buildAbsoluteFixturePath('./ap-json/mastodon/public-key.json')).publicKey
16 const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' } 17 const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' }
17 18
18 const result = await isJsonLDSignatureVerified(fromActor as any, body) 19 const result = await isJsonLDSignatureVerified(fromActor as any, body)
@@ -21,8 +22,8 @@ describe('Test activity pub helpers', function () {
21 }) 22 })
22 23
23 it('Should fail with an invalid public key', async function () { 24 it('Should fail with an invalid public key', async function () {
24 const body = require('./json/mastodon/create.json') 25 const body = require(buildAbsoluteFixturePath('./ap-json/mastodon/create.json'))
25 const publicKey = require('./json/mastodon/bad-public-key.json').publicKey 26 const publicKey = require(buildAbsoluteFixturePath('./ap-json/mastodon/bad-public-key.json')).publicKey
26 const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' } 27 const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' }
27 28
28 const result = await isJsonLDSignatureVerified(fromActor as any, body) 29 const result = await isJsonLDSignatureVerified(fromActor as any, body)
@@ -31,8 +32,8 @@ describe('Test activity pub helpers', function () {
31 }) 32 })
32 33
33 it('Should succeed with a valid Mastodon signature', async function () { 34 it('Should succeed with a valid Mastodon signature', async function () {
34 const body = require('./json/mastodon/create.json') 35 const body = require(buildAbsoluteFixturePath('./ap-json/mastodon/create.json'))
35 const publicKey = require('./json/mastodon/public-key.json').publicKey 36 const publicKey = require(buildAbsoluteFixturePath('./ap-json/mastodon/public-key.json')).publicKey
36 const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' } 37 const fromActor = { publicKey, url: 'http://localhost:9002/accounts/peertube' }
37 38
38 const result = await isJsonLDSignatureVerified(fromActor as any, body) 39 const result = await isJsonLDSignatureVerified(fromActor as any, body)
@@ -41,8 +42,8 @@ describe('Test activity pub helpers', function () {
41 }) 42 })
42 43
43 it('Should fail with an invalid PeerTube signature', async function () { 44 it('Should fail with an invalid PeerTube signature', async function () {
44 const keys = require('./json/peertube/invalid-keys.json') 45 const keys = require(buildAbsoluteFixturePath('./ap-json/peertube/invalid-keys.json'))
45 const body = require('./json/peertube/announce-without-context.json') 46 const body = require(buildAbsoluteFixturePath('./ap-json/peertube/announce-without-context.json'))
46 47
47 const actorSignature = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey } 48 const actorSignature = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey }
48 const signedBody = await buildSignedActivity(actorSignature as any, body) 49 const signedBody = await buildSignedActivity(actorSignature as any, body)
@@ -54,8 +55,8 @@ describe('Test activity pub helpers', function () {
54 }) 55 })
55 56
56 it('Should succeed with a valid PeerTube signature', async function () { 57 it('Should succeed with a valid PeerTube signature', async function () {
57 const keys = require('./json/peertube/keys.json') 58 const keys = require(buildAbsoluteFixturePath('./ap-json/peertube/keys.json'))
58 const body = require('./json/peertube/announce-without-context.json') 59 const body = require(buildAbsoluteFixturePath('./ap-json/peertube/announce-without-context.json'))
59 60
60 const actorSignature = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey } 61 const actorSignature = { url: 'http://localhost:9002/accounts/peertube', privateKey: keys.privateKey }
61 const signedBody = await buildSignedActivity(actorSignature as any, body) 62 const signedBody = await buildSignedActivity(actorSignature as any, body)
@@ -73,12 +74,12 @@ describe('Test activity pub helpers', function () {
73 req.method = 'POST' 74 req.method = 'POST'
74 req.url = '/accounts/ronan/inbox' 75 req.url = '/accounts/ronan/inbox'
75 76
76 const mastodonObject = cloneDeep(require('./json/mastodon/bad-http-signature.json')) 77 const mastodonObject = cloneDeep(require(buildAbsoluteFixturePath('./ap-json/mastodon/bad-http-signature.json')))
77 req.body = mastodonObject.body 78 req.body = mastodonObject.body
78 req.headers = mastodonObject.headers 79 req.headers = mastodonObject.headers
79 80
80 const parsed = parseHTTPSignature(req, 3600 * 1000 * 365 * 10) 81 const parsed = parseHTTPSignature(req, 3600 * 1000 * 365 * 10)
81 const publicKey = require('./json/mastodon/public-key.json').publicKey 82 const publicKey = require(buildAbsoluteFixturePath('./ap-json/mastodon/public-key.json')).publicKey
82 83
83 const actor = { publicKey } 84 const actor = { publicKey }
84 const verified = isHTTPSignatureVerified(parsed, actor as any) 85 const verified = isHTTPSignatureVerified(parsed, actor as any)
@@ -91,12 +92,12 @@ describe('Test activity pub helpers', function () {
91 req.method = 'POST' 92 req.method = 'POST'
92 req.url = '/accounts/ronan/inbox' 93 req.url = '/accounts/ronan/inbox'
93 94
94 const mastodonObject = cloneDeep(require('./json/mastodon/http-signature.json')) 95 const mastodonObject = cloneDeep(require(buildAbsoluteFixturePath('./ap-json/mastodon/http-signature.json')))
95 req.body = mastodonObject.body 96 req.body = mastodonObject.body
96 req.headers = mastodonObject.headers 97 req.headers = mastodonObject.headers
97 98
98 const parsed = parseHTTPSignature(req, 3600 * 1000 * 365 * 10) 99 const parsed = parseHTTPSignature(req, 3600 * 1000 * 365 * 10)
99 const publicKey = require('./json/mastodon/bad-public-key.json').publicKey 100 const publicKey = require(buildAbsoluteFixturePath('./ap-json/mastodon/bad-public-key.json')).publicKey
100 101
101 const actor = { publicKey } 102 const actor = { publicKey }
102 const verified = isHTTPSignatureVerified(parsed, actor as any) 103 const verified = isHTTPSignatureVerified(parsed, actor as any)
@@ -109,7 +110,7 @@ describe('Test activity pub helpers', function () {
109 req.method = 'POST' 110 req.method = 'POST'
110 req.url = '/accounts/ronan/inbox' 111 req.url = '/accounts/ronan/inbox'
111 112
112 const mastodonObject = cloneDeep(require('./json/mastodon/http-signature.json')) 113 const mastodonObject = cloneDeep(require(buildAbsoluteFixturePath('./ap-json/mastodon/http-signature.json')))
113 req.body = mastodonObject.body 114 req.body = mastodonObject.body
114 req.headers = mastodonObject.headers 115 req.headers = mastodonObject.headers
115 116
@@ -128,7 +129,7 @@ describe('Test activity pub helpers', function () {
128 req.method = 'POST' 129 req.method = 'POST'
129 req.url = '/accounts/ronan/inbox' 130 req.url = '/accounts/ronan/inbox'
130 131
131 const mastodonObject = cloneDeep(require('./json/mastodon/http-signature.json')) 132 const mastodonObject = cloneDeep(require(buildAbsoluteFixturePath('./ap-json/mastodon/http-signature.json')))
132 req.body = mastodonObject.body 133 req.body = mastodonObject.body
133 req.headers = mastodonObject.headers 134 req.headers = mastodonObject.headers
134 req.headers = 'Signature ' + mastodonObject.headers 135 req.headers = 'Signature ' + mastodonObject.headers
@@ -148,12 +149,12 @@ describe('Test activity pub helpers', function () {
148 req.method = 'POST' 149 req.method = 'POST'
149 req.url = '/accounts/ronan/inbox' 150 req.url = '/accounts/ronan/inbox'
150 151
151 const mastodonObject = cloneDeep(require('./json/mastodon/http-signature.json')) 152 const mastodonObject = cloneDeep(require(buildAbsoluteFixturePath('./ap-json/mastodon/http-signature.json')))
152 req.body = mastodonObject.body 153 req.body = mastodonObject.body
153 req.headers = mastodonObject.headers 154 req.headers = mastodonObject.headers
154 155
155 const parsed = parseHTTPSignature(req, 3600 * 1000 * 365 * 10) 156 const parsed = parseHTTPSignature(req, 3600 * 1000 * 365 * 10)
156 const publicKey = require('./json/mastodon/public-key.json').publicKey 157 const publicKey = require(buildAbsoluteFixturePath('./ap-json/mastodon/public-key.json')).publicKey
157 158
158 const actor = { publicKey } 159 const actor = { publicKey }
159 const verified = isHTTPSignatureVerified(parsed, actor as any) 160 const verified = isHTTPSignatureVerified(parsed, actor as any)
diff --git a/server/tests/api/activitypub/security.ts b/server/tests/api/activitypub/security.ts
index 364b53e0f..61db272f6 100644
--- a/server/tests/api/activitypub/security.ts
+++ b/server/tests/api/activitypub/security.ts
@@ -5,6 +5,7 @@ import * as chai from 'chai'
5import { buildDigest } from '@server/helpers/peertube-crypto' 5import { buildDigest } from '@server/helpers/peertube-crypto'
6import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 6import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
7import { 7import {
8 buildAbsoluteFixturePath,
8 cleanupTests, 9 cleanupTests,
9 closeAllSequelize, 10 closeAllSequelize,
10 flushAndRunMultipleServers, 11 flushAndRunMultipleServers,
@@ -40,7 +41,7 @@ function setUpdatedAtOfServer (onServer: ServerInfo, ofServer: ServerInfo, updat
40} 41}
41 42
42function getAnnounceWithoutContext (server: ServerInfo) { 43function getAnnounceWithoutContext (server: ServerInfo) {
43 const json = require('./json/peertube/announce-without-context.json') 44 const json = require(buildAbsoluteFixturePath('./ap-json/peertube/announce-without-context.json'))
44 const result: typeof json = {} 45 const result: typeof json = {}
45 46
46 for (const key of Object.keys(json)) { 47 for (const key of Object.keys(json)) {
@@ -58,8 +59,8 @@ describe('Test ActivityPub security', function () {
58 let servers: ServerInfo[] 59 let servers: ServerInfo[]
59 let url: string 60 let url: string
60 61
61 const keys = require('./json/peertube/keys.json') 62 const keys = require(buildAbsoluteFixturePath('./ap-json/peertube/keys.json'))
62 const invalidKeys = require('./json/peertube/invalid-keys.json') 63 const invalidKeys = require(buildAbsoluteFixturePath('./ap-json/peertube/invalid-keys.json'))
63 const baseHttpSignature = () => ({ 64 const baseHttpSignature = () => ({
64 algorithm: HTTP_SIGNATURE.ALGORITHM, 65 algorithm: HTTP_SIGNATURE.ALGORITHM,
65 authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME, 66 authorizationHeaderName: HTTP_SIGNATURE.HEADER_NAME,
diff --git a/server/tests/api/check-params/abuses.ts b/server/tests/api/check-params/abuses.ts
index 2aa09334c..2054776cc 100644
--- a/server/tests/api/check-params/abuses.ts
+++ b/server/tests/api/check-params/abuses.ts
@@ -258,7 +258,7 @@ describe('Test abuses API validators', function () {
258 }) 258 })
259 259
260 it('Should succeed with the correct parameters (basic)', async function () { 260 it('Should succeed with the correct parameters (basic)', async function () {
261 const fields: AbuseCreate = { video: { id: server.video.id }, reason: 'my super reason' } 261 const fields: AbuseCreate = { video: { id: server.video.shortUUID }, reason: 'my super reason' }
262 262
263 const res = await makePostBodyRequest({ 263 const res = await makePostBodyRequest({
264 url: server.url, 264 url: server.url,
diff --git a/server/tests/api/check-params/config.ts b/server/tests/api/check-params/config.ts
index 004aa65b3..9549070ef 100644
--- a/server/tests/api/check-params/config.ts
+++ b/server/tests/api/check-params/config.ts
@@ -73,7 +73,8 @@ describe('Test config API validators', function () {
73 signup: { 73 signup: {
74 enabled: false, 74 enabled: false,
75 limit: 5, 75 limit: 5,
76 requiresEmailVerification: false 76 requiresEmailVerification: false,
77 minimumAge: 16
77 }, 78 },
78 admin: { 79 admin: {
79 email: 'superadmin1@example.com' 80 email: 'superadmin1@example.com'
diff --git a/server/tests/api/check-params/custom-pages.ts b/server/tests/api/check-params/custom-pages.ts
new file mode 100644
index 000000000..74ca3384c
--- /dev/null
+++ b/server/tests/api/check-params/custom-pages.ts
@@ -0,0 +1,81 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
5import {
6 cleanupTests,
7 createUser,
8 flushAndRunServer,
9 ServerInfo,
10 setAccessTokensToServers,
11 userLogin
12} from '../../../../shared/extra-utils'
13import { makeGetRequest, makePutBodyRequest } from '../../../../shared/extra-utils/requests/requests'
14
15describe('Test custom pages validators', function () {
16 const path = '/api/v1/custom-pages/homepage/instance'
17
18 let server: ServerInfo
19 let userAccessToken: string
20
21 // ---------------------------------------------------------------
22
23 before(async function () {
24 this.timeout(120000)
25
26 server = await flushAndRunServer(1)
27 await setAccessTokensToServers([ server ])
28
29 const user = { username: 'user1', password: 'password' }
30 await createUser({ url: server.url, accessToken: server.accessToken, username: user.username, password: user.password })
31
32 userAccessToken = await userLogin(server, user)
33 })
34
35 describe('When updating instance homepage', function () {
36
37 it('Should fail with an unauthenticated user', async function () {
38 await makePutBodyRequest({
39 url: server.url,
40 path,
41 fields: { content: 'super content' },
42 statusCodeExpected: HttpStatusCode.UNAUTHORIZED_401
43 })
44 })
45
46 it('Should fail with a non admin user', async function () {
47 await makePutBodyRequest({
48 url: server.url,
49 path,
50 token: userAccessToken,
51 fields: { content: 'super content' },
52 statusCodeExpected: HttpStatusCode.FORBIDDEN_403
53 })
54 })
55
56 it('Should succeed with the correct params', async function () {
57 await makePutBodyRequest({
58 url: server.url,
59 path,
60 token: server.accessToken,
61 fields: { content: 'super content' },
62 statusCodeExpected: HttpStatusCode.NO_CONTENT_204
63 })
64 })
65 })
66
67 describe('When getting instance homapage', function () {
68
69 it('Should succeed with the correct params', async function () {
70 await makeGetRequest({
71 url: server.url,
72 path,
73 statusCodeExpected: HttpStatusCode.OK_200
74 })
75 })
76 })
77
78 after(async function () {
79 await cleanupTests([ server ])
80 })
81})
diff --git a/server/tests/api/check-params/index.ts b/server/tests/api/check-params/index.ts
index 143515838..ce2335e42 100644
--- a/server/tests/api/check-params/index.ts
+++ b/server/tests/api/check-params/index.ts
@@ -3,6 +3,7 @@ import './accounts'
3import './blocklist' 3import './blocklist'
4import './bulk' 4import './bulk'
5import './config' 5import './config'
6import './custom-pages'
6import './contact-form' 7import './contact-form'
7import './debug' 8import './debug'
8import './follows' 9import './follows'
diff --git a/server/tests/api/check-params/live.ts b/server/tests/api/check-params/live.ts
index c171b1f81..933d8abf2 100644
--- a/server/tests/api/check-params/live.ts
+++ b/server/tests/api/check-params/live.ts
@@ -2,9 +2,10 @@
2 2
3import 'mocha' 3import 'mocha'
4import { omit } from 'lodash' 4import { omit } from 'lodash'
5import { join } from 'path' 5import { LiveVideo, VideoCreateResult, VideoPrivacy } from '@shared/models'
6import { LiveVideo, VideoPrivacy } from '@shared/models' 6import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
7import { 7import {
8 buildAbsoluteFixturePath,
8 cleanupTests, 9 cleanupTests,
9 createUser, 10 createUser,
10 flushAndRunServer, 11 flushAndRunServer,
@@ -24,14 +25,13 @@ import {
24 userLogin, 25 userLogin,
25 waitUntilLivePublished 26 waitUntilLivePublished
26} from '../../../../shared/extra-utils' 27} from '../../../../shared/extra-utils'
27import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
28 28
29describe('Test video lives API validator', function () { 29describe('Test video lives API validator', function () {
30 const path = '/api/v1/videos/live' 30 const path = '/api/v1/videos/live'
31 let server: ServerInfo 31 let server: ServerInfo
32 let userAccessToken = '' 32 let userAccessToken = ''
33 let channelId: number 33 let channelId: number
34 let videoId: number 34 let video: VideoCreateResult
35 let videoIdNotLive: number 35 let videoIdNotLive: number
36 36
37 // --------------------------------------------------------------- 37 // ---------------------------------------------------------------
@@ -180,7 +180,7 @@ describe('Test video lives API validator', function () {
180 it('Should fail with an incorrect thumbnail file', async function () { 180 it('Should fail with an incorrect thumbnail file', async function () {
181 const fields = baseCorrectParams 181 const fields = baseCorrectParams
182 const attaches = { 182 const attaches = {
183 thumbnailfile: join(__dirname, '..', '..', 'fixtures', 'video_short.mp4') 183 thumbnailfile: buildAbsoluteFixturePath('video_short.mp4')
184 } 184 }
185 185
186 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) 186 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches })
@@ -189,7 +189,7 @@ describe('Test video lives API validator', function () {
189 it('Should fail with a big thumbnail file', async function () { 189 it('Should fail with a big thumbnail file', async function () {
190 const fields = baseCorrectParams 190 const fields = baseCorrectParams
191 const attaches = { 191 const attaches = {
192 thumbnailfile: join(__dirname, '..', '..', 'fixtures', 'preview-big.png') 192 thumbnailfile: buildAbsoluteFixturePath('preview-big.png')
193 } 193 }
194 194
195 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) 195 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches })
@@ -198,7 +198,7 @@ describe('Test video lives API validator', function () {
198 it('Should fail with an incorrect preview file', async function () { 198 it('Should fail with an incorrect preview file', async function () {
199 const fields = baseCorrectParams 199 const fields = baseCorrectParams
200 const attaches = { 200 const attaches = {
201 previewfile: join(__dirname, '..', '..', 'fixtures', 'video_short.mp4') 201 previewfile: buildAbsoluteFixturePath('video_short.mp4')
202 } 202 }
203 203
204 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) 204 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches })
@@ -207,7 +207,7 @@ describe('Test video lives API validator', function () {
207 it('Should fail with a big preview file', async function () { 207 it('Should fail with a big preview file', async function () {
208 const fields = baseCorrectParams 208 const fields = baseCorrectParams
209 const attaches = { 209 const attaches = {
210 previewfile: join(__dirname, '..', '..', 'fixtures', 'preview-big.png') 210 previewfile: buildAbsoluteFixturePath('preview-big.png')
211 } 211 }
212 212
213 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) 213 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches })
@@ -230,7 +230,7 @@ describe('Test video lives API validator', function () {
230 statusCodeExpected: HttpStatusCode.OK_200 230 statusCodeExpected: HttpStatusCode.OK_200
231 }) 231 })
232 232
233 videoId = res.body.video.id 233 video = res.body.video
234 }) 234 })
235 235
236 it('Should forbid if live is disabled', async function () { 236 it('Should forbid if live is disabled', async function () {
@@ -326,15 +326,15 @@ describe('Test video lives API validator', function () {
326 describe('When getting live information', function () { 326 describe('When getting live information', function () {
327 327
328 it('Should fail without access token', async function () { 328 it('Should fail without access token', async function () {
329 await getLive(server.url, '', videoId, HttpStatusCode.UNAUTHORIZED_401) 329 await getLive(server.url, '', video.id, HttpStatusCode.UNAUTHORIZED_401)
330 }) 330 })
331 331
332 it('Should fail with a bad access token', async function () { 332 it('Should fail with a bad access token', async function () {
333 await getLive(server.url, 'toto', videoId, HttpStatusCode.UNAUTHORIZED_401) 333 await getLive(server.url, 'toto', video.id, HttpStatusCode.UNAUTHORIZED_401)
334 }) 334 })
335 335
336 it('Should fail with access token of another user', async function () { 336 it('Should fail with access token of another user', async function () {
337 await getLive(server.url, userAccessToken, videoId, HttpStatusCode.FORBIDDEN_403) 337 await getLive(server.url, userAccessToken, video.id, HttpStatusCode.FORBIDDEN_403)
338 }) 338 })
339 339
340 it('Should fail with a bad video id', async function () { 340 it('Should fail with a bad video id', async function () {
@@ -350,22 +350,23 @@ describe('Test video lives API validator', function () {
350 }) 350 })
351 351
352 it('Should succeed with the correct params', async function () { 352 it('Should succeed with the correct params', async function () {
353 await getLive(server.url, server.accessToken, videoId) 353 await getLive(server.url, server.accessToken, video.id)
354 await getLive(server.url, server.accessToken, video.shortUUID)
354 }) 355 })
355 }) 356 })
356 357
357 describe('When updating live information', async function () { 358 describe('When updating live information', async function () {
358 359
359 it('Should fail without access token', async function () { 360 it('Should fail without access token', async function () {
360 await updateLive(server.url, '', videoId, {}, HttpStatusCode.UNAUTHORIZED_401) 361 await updateLive(server.url, '', video.id, {}, HttpStatusCode.UNAUTHORIZED_401)
361 }) 362 })
362 363
363 it('Should fail with a bad access token', async function () { 364 it('Should fail with a bad access token', async function () {
364 await updateLive(server.url, 'toto', videoId, {}, HttpStatusCode.UNAUTHORIZED_401) 365 await updateLive(server.url, 'toto', video.id, {}, HttpStatusCode.UNAUTHORIZED_401)
365 }) 366 })
366 367
367 it('Should fail with access token of another user', async function () { 368 it('Should fail with access token of another user', async function () {
368 await updateLive(server.url, userAccessToken, videoId, {}, HttpStatusCode.FORBIDDEN_403) 369 await updateLive(server.url, userAccessToken, video.id, {}, HttpStatusCode.FORBIDDEN_403)
369 }) 370 })
370 371
371 it('Should fail with a bad video id', async function () { 372 it('Should fail with a bad video id', async function () {
@@ -383,11 +384,12 @@ describe('Test video lives API validator', function () {
383 it('Should fail with save replay and permanent live set to true', async function () { 384 it('Should fail with save replay and permanent live set to true', async function () {
384 const fields = { saveReplay: true, permanentLive: true } 385 const fields = { saveReplay: true, permanentLive: true }
385 386
386 await updateLive(server.url, server.accessToken, videoId, fields, HttpStatusCode.BAD_REQUEST_400) 387 await updateLive(server.url, server.accessToken, video.id, fields, HttpStatusCode.BAD_REQUEST_400)
387 }) 388 })
388 389
389 it('Should succeed with the correct params', async function () { 390 it('Should succeed with the correct params', async function () {
390 await updateLive(server.url, server.accessToken, videoId, { saveReplay: false }) 391 await updateLive(server.url, server.accessToken, video.id, { saveReplay: false })
392 await updateLive(server.url, server.accessToken, video.shortUUID, { saveReplay: false })
391 }) 393 })
392 394
393 it('Should fail to update replay status if replay is not allowed on the instance', async function () { 395 it('Should fail to update replay status if replay is not allowed on the instance', async function () {
@@ -398,19 +400,19 @@ describe('Test video lives API validator', function () {
398 } 400 }
399 }) 401 })
400 402
401 await updateLive(server.url, server.accessToken, videoId, { saveReplay: true }, HttpStatusCode.FORBIDDEN_403) 403 await updateLive(server.url, server.accessToken, video.id, { saveReplay: true }, HttpStatusCode.FORBIDDEN_403)
402 }) 404 })
403 405
404 it('Should fail to update a live if it has already started', async function () { 406 it('Should fail to update a live if it has already started', async function () {
405 this.timeout(40000) 407 this.timeout(40000)
406 408
407 const resLive = await getLive(server.url, server.accessToken, videoId) 409 const resLive = await getLive(server.url, server.accessToken, video.id)
408 const live: LiveVideo = resLive.body 410 const live: LiveVideo = resLive.body
409 411
410 const command = sendRTMPStream(live.rtmpUrl, live.streamKey) 412 const command = sendRTMPStream(live.rtmpUrl, live.streamKey)
411 413
412 await waitUntilLivePublished(server.url, server.accessToken, videoId) 414 await waitUntilLivePublished(server.url, server.accessToken, video.id)
413 await updateLive(server.url, server.accessToken, videoId, {}, HttpStatusCode.BAD_REQUEST_400) 415 await updateLive(server.url, server.accessToken, video.id, {}, HttpStatusCode.BAD_REQUEST_400)
414 416
415 await stopFfmpeg(command) 417 await stopFfmpeg(command)
416 }) 418 })
@@ -418,14 +420,14 @@ describe('Test video lives API validator', function () {
418 it('Should fail to stream twice in the save live', async function () { 420 it('Should fail to stream twice in the save live', async function () {
419 this.timeout(40000) 421 this.timeout(40000)
420 422
421 const resLive = await getLive(server.url, server.accessToken, videoId) 423 const resLive = await getLive(server.url, server.accessToken, video.id)
422 const live: LiveVideo = resLive.body 424 const live: LiveVideo = resLive.body
423 425
424 const command = sendRTMPStream(live.rtmpUrl, live.streamKey) 426 const command = sendRTMPStream(live.rtmpUrl, live.streamKey)
425 427
426 await waitUntilLivePublished(server.url, server.accessToken, videoId) 428 await waitUntilLivePublished(server.url, server.accessToken, video.id)
427 429
428 await runAndTestFfmpegStreamError(server.url, server.accessToken, videoId, true) 430 await runAndTestFfmpegStreamError(server.url, server.accessToken, video.id, true)
429 431
430 await stopFfmpeg(command) 432 await stopFfmpeg(command)
431 }) 433 })
diff --git a/server/tests/api/check-params/plugins.ts b/server/tests/api/check-params/plugins.ts
index 6e540bcbb..a833fe6ff 100644
--- a/server/tests/api/check-params/plugins.ts
+++ b/server/tests/api/check-params/plugins.ts
@@ -1,7 +1,7 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import 'mocha' 3import 'mocha'
4 4import { HttpStatusCode } from '@shared/core-utils'
5import { 5import {
6 checkBadCountPagination, 6 checkBadCountPagination,
7 checkBadSortPagination, 7 checkBadSortPagination,
@@ -11,14 +11,14 @@ import {
11 flushAndRunServer, 11 flushAndRunServer,
12 immutableAssign, 12 immutableAssign,
13 installPlugin, 13 installPlugin,
14 makeGetRequest, makePostBodyRequest, makePutBodyRequest, 14 makeGetRequest,
15 makePostBodyRequest,
16 makePutBodyRequest,
15 ServerInfo, 17 ServerInfo,
16 setAccessTokensToServers, 18 setAccessTokensToServers,
17 userLogin 19 userLogin
18} from '../../../../shared/extra-utils' 20} from '@shared/extra-utils'
19import { PluginType } from '../../../../shared/models/plugins/plugin.type' 21import { PeerTubePlugin, PluginType } from '@shared/models'
20import { PeerTubePlugin } from '../../../../shared/models/plugins/peertube-plugin.model'
21import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
22 22
23describe('Test server plugins API validators', function () { 23describe('Test server plugins API validators', function () {
24 let server: ServerInfo 24 let server: ServerInfo
diff --git a/server/tests/api/check-params/redundancy.ts b/server/tests/api/check-params/redundancy.ts
index 71be50a6f..dac6938de 100644
--- a/server/tests/api/check-params/redundancy.ts
+++ b/server/tests/api/check-params/redundancy.ts
@@ -1,7 +1,8 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import 'mocha' 3import 'mocha'
4 4import { VideoCreateResult } from '@shared/models'
5import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
5import { 6import {
6 checkBadCountPagination, 7 checkBadCountPagination,
7 checkBadSortPagination, 8 checkBadSortPagination,
@@ -9,20 +10,24 @@ import {
9 cleanupTests, 10 cleanupTests,
10 createUser, 11 createUser,
11 doubleFollow, 12 doubleFollow,
12 flushAndRunMultipleServers, makeDeleteRequest, 13 flushAndRunMultipleServers,
13 makeGetRequest, makePostBodyRequest, 14 getVideo,
15 makeDeleteRequest,
16 makeGetRequest,
17 makePostBodyRequest,
14 makePutBodyRequest, 18 makePutBodyRequest,
15 ServerInfo, 19 ServerInfo,
16 setAccessTokensToServers, uploadVideoAndGetId, 20 setAccessTokensToServers,
17 userLogin, waitJobs, getVideoIdFromUUID 21 uploadVideoAndGetId,
22 userLogin,
23 waitJobs
18} from '../../../../shared/extra-utils' 24} from '../../../../shared/extra-utils'
19import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
20 25
21describe('Test server redundancy API validators', function () { 26describe('Test server redundancy API validators', function () {
22 let servers: ServerInfo[] 27 let servers: ServerInfo[]
23 let userAccessToken = null 28 let userAccessToken = null
24 let videoIdLocal: number 29 let videoIdLocal: number
25 let videoIdRemote: number 30 let videoRemote: VideoCreateResult
26 31
27 // --------------------------------------------------------------- 32 // ---------------------------------------------------------------
28 33
@@ -48,7 +53,8 @@ describe('Test server redundancy API validators', function () {
48 53
49 await waitJobs(servers) 54 await waitJobs(servers)
50 55
51 videoIdRemote = await getVideoIdFromUUID(servers[0].url, remoteUUID) 56 const resVideo = await getVideo(servers[0].url, remoteUUID)
57 videoRemote = resVideo.body
52 }) 58 })
53 59
54 describe('When listing redundancies', function () { 60 describe('When listing redundancies', function () {
@@ -131,7 +137,13 @@ describe('Test server redundancy API validators', function () {
131 }) 137 })
132 138
133 it('Should succeed with the correct params', async function () { 139 it('Should succeed with the correct params', async function () {
134 await makePostBodyRequest({ url, path, token, fields: { videoId: videoIdRemote }, statusCodeExpected: HttpStatusCode.NO_CONTENT_204 }) 140 await makePostBodyRequest({
141 url,
142 path,
143 token,
144 fields: { videoId: videoRemote.shortUUID },
145 statusCodeExpected: HttpStatusCode.NO_CONTENT_204
146 })
135 }) 147 })
136 148
137 it('Should fail if the video is already duplicated', async function () { 149 it('Should fail if the video is already duplicated', async function () {
@@ -139,7 +151,13 @@ describe('Test server redundancy API validators', function () {
139 151
140 await waitJobs(servers) 152 await waitJobs(servers)
141 153
142 await makePostBodyRequest({ url, path, token, fields: { videoId: videoIdRemote }, statusCodeExpected: HttpStatusCode.CONFLICT_409 }) 154 await makePostBodyRequest({
155 url,
156 path,
157 token,
158 fields: { videoId: videoRemote.uuid },
159 statusCodeExpected: HttpStatusCode.CONFLICT_409
160 })
143 }) 161 })
144 }) 162 })
145 163
diff --git a/server/tests/api/check-params/search.ts b/server/tests/api/check-params/search.ts
index 8378c3a89..20ad46cff 100644
--- a/server/tests/api/check-params/search.ts
+++ b/server/tests/api/check-params/search.ts
@@ -140,6 +140,30 @@ describe('Test videos API validator', function () {
140 }) 140 })
141 }) 141 })
142 142
143 describe('When searching video playlists', function () {
144 const path = '/api/v1/search/video-playlists/'
145
146 const query = {
147 search: 'coucou'
148 }
149
150 it('Should fail with a bad start pagination', async function () {
151 await checkBadStartPagination(server.url, path, null, query)
152 })
153
154 it('Should fail with a bad count pagination', async function () {
155 await checkBadCountPagination(server.url, path, null, query)
156 })
157
158 it('Should fail with an incorrect sort', async function () {
159 await checkBadSortPagination(server.url, path, null, query)
160 })
161
162 it('Should success with the correct parameters', async function () {
163 await makeGetRequest({ url: server.url, path, query, statusCodeExpected: HttpStatusCode.OK_200 })
164 })
165 })
166
143 describe('When searching video channels', function () { 167 describe('When searching video channels', function () {
144 const path = '/api/v1/search/video-channels/' 168 const path = '/api/v1/search/video-channels/'
145 169
@@ -171,6 +195,7 @@ describe('Test videos API validator', function () {
171 195
172 const query = { search: 'coucou' } 196 const query = { search: 'coucou' }
173 const paths = [ 197 const paths = [
198 '/api/v1/search/video-playlists/',
174 '/api/v1/search/video-channels/', 199 '/api/v1/search/video-channels/',
175 '/api/v1/search/videos/' 200 '/api/v1/search/videos/'
176 ] 201 ]
diff --git a/server/tests/api/check-params/users.ts b/server/tests/api/check-params/users.ts
index dcff0d52b..70a872ce5 100644
--- a/server/tests/api/check-params/users.ts
+++ b/server/tests/api/check-params/users.ts
@@ -2,12 +2,12 @@
2 2
3import 'mocha' 3import 'mocha'
4import { omit } from 'lodash' 4import { omit } from 'lodash'
5import { join } from 'path' 5import { User, UserRole, VideoCreateResult } from '../../../../shared'
6import { User, UserRole } from '../../../../shared'
7import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 6import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
8import { 7import {
9 addVideoChannel, 8 addVideoChannel,
10 blockUser, 9 blockUser,
10 buildAbsoluteFixturePath,
11 cleanupTests, 11 cleanupTests,
12 createUser, 12 createUser,
13 deleteMe, 13 deleteMe,
@@ -45,7 +45,7 @@ describe('Test users API validators', function () {
45 let userId: number 45 let userId: number
46 let rootId: number 46 let rootId: number
47 let moderatorId: number 47 let moderatorId: number
48 let videoId: number 48 let video: VideoCreateResult
49 let server: ServerInfo 49 let server: ServerInfo
50 let serverWithRegistrationDisabled: ServerInfo 50 let serverWithRegistrationDisabled: ServerInfo
51 let userAccessToken = '' 51 let userAccessToken = ''
@@ -126,7 +126,7 @@ describe('Test users API validators', function () {
126 126
127 { 127 {
128 const res = await uploadVideo(server.url, server.accessToken, {}) 128 const res = await uploadVideo(server.url, server.accessToken, {})
129 videoId = res.body.video.id 129 video = res.body.video
130 } 130 }
131 131
132 { 132 {
@@ -600,7 +600,7 @@ describe('Test users API validators', function () {
600 it('Should fail without an incorrect input file', async function () { 600 it('Should fail without an incorrect input file', async function () {
601 const fields = {} 601 const fields = {}
602 const attaches = { 602 const attaches = {
603 avatarfile: join(__dirname, '..', '..', 'fixtures', 'video_short.mp4') 603 avatarfile: buildAbsoluteFixturePath('video_short.mp4')
604 } 604 }
605 await makeUploadRequest({ url: server.url, path: path + '/me/avatar/pick', token: server.accessToken, fields, attaches }) 605 await makeUploadRequest({ url: server.url, path: path + '/me/avatar/pick', token: server.accessToken, fields, attaches })
606 }) 606 })
@@ -608,7 +608,7 @@ describe('Test users API validators', function () {
608 it('Should fail with a big file', async function () { 608 it('Should fail with a big file', async function () {
609 const fields = {} 609 const fields = {}
610 const attaches = { 610 const attaches = {
611 avatarfile: join(__dirname, '..', '..', 'fixtures', 'avatar-big.png') 611 avatarfile: buildAbsoluteFixturePath('avatar-big.png')
612 } 612 }
613 await makeUploadRequest({ url: server.url, path: path + '/me/avatar/pick', token: server.accessToken, fields, attaches }) 613 await makeUploadRequest({ url: server.url, path: path + '/me/avatar/pick', token: server.accessToken, fields, attaches })
614 }) 614 })
@@ -616,7 +616,7 @@ describe('Test users API validators', function () {
616 it('Should fail with an unauthenticated user', async function () { 616 it('Should fail with an unauthenticated user', async function () {
617 const fields = {} 617 const fields = {}
618 const attaches = { 618 const attaches = {
619 avatarfile: join(__dirname, '..', '..', 'fixtures', 'avatar.png') 619 avatarfile: buildAbsoluteFixturePath('avatar.png')
620 } 620 }
621 await makeUploadRequest({ 621 await makeUploadRequest({
622 url: server.url, 622 url: server.url,
@@ -630,7 +630,7 @@ describe('Test users API validators', function () {
630 it('Should succeed with the correct params', async function () { 630 it('Should succeed with the correct params', async function () {
631 const fields = {} 631 const fields = {}
632 const attaches = { 632 const attaches = {
633 avatarfile: join(__dirname, '..', '..', 'fixtures', 'avatar.png') 633 avatarfile: buildAbsoluteFixturePath('avatar.png')
634 } 634 }
635 await makeUploadRequest({ 635 await makeUploadRequest({
636 url: server.url, 636 url: server.url,
@@ -829,7 +829,7 @@ describe('Test users API validators', function () {
829 829
830 describe('When getting my video rating', function () { 830 describe('When getting my video rating', function () {
831 it('Should fail with a non authenticated user', async function () { 831 it('Should fail with a non authenticated user', async function () {
832 await getMyUserVideoRating(server.url, 'fake_token', videoId, HttpStatusCode.UNAUTHORIZED_401) 832 await getMyUserVideoRating(server.url, 'fake_token', video.id, HttpStatusCode.UNAUTHORIZED_401)
833 }) 833 })
834 834
835 it('Should fail with an incorrect video uuid', async function () { 835 it('Should fail with an incorrect video uuid', async function () {
@@ -841,7 +841,9 @@ describe('Test users API validators', function () {
841 }) 841 })
842 842
843 it('Should succeed with the correct parameters', async function () { 843 it('Should succeed with the correct parameters', async function () {
844 await getMyUserVideoRating(server.url, server.accessToken, videoId) 844 await getMyUserVideoRating(server.url, server.accessToken, video.id)
845 await getMyUserVideoRating(server.url, server.accessToken, video.uuid)
846 await getMyUserVideoRating(server.url, server.accessToken, video.shortUUID)
845 }) 847 })
846 }) 848 })
847 849
diff --git a/server/tests/api/check-params/video-blacklist.ts b/server/tests/api/check-params/video-blacklist.ts
index 3d4837d58..ce7f5fa17 100644
--- a/server/tests/api/check-params/video-blacklist.ts
+++ b/server/tests/api/check-params/video-blacklist.ts
@@ -191,7 +191,7 @@ describe('Test video blacklist API validators', function () {
191 }) 191 })
192 192
193 it('Should succeed with the correct params', async function () { 193 it('Should succeed with the correct params', async function () {
194 const path = basePath + servers[0].video.uuid + '/blacklist' 194 const path = basePath + servers[0].video.shortUUID + '/blacklist'
195 const fields = { reason: 'hello' } 195 const fields = { reason: 'hello' }
196 196
197 await makePutBodyRequest({ 197 await makePutBodyRequest({
@@ -222,10 +222,14 @@ describe('Test video blacklist API validators', function () {
222 }) 222 })
223 223
224 it('Should succeed with an admin', async function () { 224 it('Should succeed with an admin', async function () {
225 const res = await getVideoWithToken(servers[0].url, servers[0].accessToken, servers[0].video.uuid, HttpStatusCode.OK_200) 225 const video = servers[0].video
226 const video: VideoDetails = res.body
227 226
228 expect(video.blacklisted).to.be.true 227 for (const id of [ video.id, video.uuid, video.shortUUID ]) {
228 const res = await getVideoWithToken(servers[0].url, servers[0].accessToken, id, HttpStatusCode.OK_200)
229 const video: VideoDetails = res.body
230
231 expect(video.blacklisted).to.be.true
232 }
229 }) 233 })
230 }) 234 })
231 235
diff --git a/server/tests/api/check-params/video-captions.ts b/server/tests/api/check-params/video-captions.ts
index 2f049c03d..c0595c04d 100644
--- a/server/tests/api/check-params/video-captions.ts
+++ b/server/tests/api/check-params/video-captions.ts
@@ -1,7 +1,10 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import 'mocha' 3import 'mocha'
4import { VideoCreateResult } from '@shared/models'
5import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
4import { 6import {
7 buildAbsoluteFixturePath,
5 cleanupTests, 8 cleanupTests,
6 createUser, 9 createUser,
7 flushAndRunServer, 10 flushAndRunServer,
@@ -13,16 +16,14 @@ import {
13 uploadVideo, 16 uploadVideo,
14 userLogin 17 userLogin
15} from '../../../../shared/extra-utils' 18} from '../../../../shared/extra-utils'
16import { join } from 'path'
17import { createVideoCaption } from '../../../../shared/extra-utils/videos/video-captions' 19import { createVideoCaption } from '../../../../shared/extra-utils/videos/video-captions'
18import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
19 20
20describe('Test video captions API validator', function () { 21describe('Test video captions API validator', function () {
21 const path = '/api/v1/videos/' 22 const path = '/api/v1/videos/'
22 23
23 let server: ServerInfo 24 let server: ServerInfo
24 let userAccessToken: string 25 let userAccessToken: string
25 let videoUUID: string 26 let video: VideoCreateResult
26 27
27 // --------------------------------------------------------------- 28 // ---------------------------------------------------------------
28 29
@@ -35,7 +36,7 @@ describe('Test video captions API validator', function () {
35 36
36 { 37 {
37 const res = await uploadVideo(server.url, server.accessToken, {}) 38 const res = await uploadVideo(server.url, server.accessToken, {})
38 videoUUID = res.body.video.uuid 39 video = res.body.video
39 } 40 }
40 41
41 { 42 {
@@ -51,7 +52,7 @@ describe('Test video captions API validator', function () {
51 describe('When adding video caption', function () { 52 describe('When adding video caption', function () {
52 const fields = { } 53 const fields = { }
53 const attaches = { 54 const attaches = {
54 captionfile: join(__dirname, '..', '..', 'fixtures', 'subtitle-good1.vtt') 55 captionfile: buildAbsoluteFixturePath('subtitle-good1.vtt')
55 } 56 }
56 57
57 it('Should fail without a valid uuid', async function () { 58 it('Should fail without a valid uuid', async function () {
@@ -78,7 +79,7 @@ describe('Test video captions API validator', function () {
78 }) 79 })
79 80
80 it('Should fail with a missing language in path', async function () { 81 it('Should fail with a missing language in path', async function () {
81 const captionPath = path + videoUUID + '/captions' 82 const captionPath = path + video.uuid + '/captions'
82 await makeUploadRequest({ 83 await makeUploadRequest({
83 method: 'PUT', 84 method: 'PUT',
84 url: server.url, 85 url: server.url,
@@ -90,7 +91,7 @@ describe('Test video captions API validator', function () {
90 }) 91 })
91 92
92 it('Should fail with an unknown language', async function () { 93 it('Should fail with an unknown language', async function () {
93 const captionPath = path + videoUUID + '/captions/15' 94 const captionPath = path + video.uuid + '/captions/15'
94 await makeUploadRequest({ 95 await makeUploadRequest({
95 method: 'PUT', 96 method: 'PUT',
96 url: server.url, 97 url: server.url,
@@ -102,7 +103,7 @@ describe('Test video captions API validator', function () {
102 }) 103 })
103 104
104 it('Should fail without access token', async function () { 105 it('Should fail without access token', async function () {
105 const captionPath = path + videoUUID + '/captions/fr' 106 const captionPath = path + video.uuid + '/captions/fr'
106 await makeUploadRequest({ 107 await makeUploadRequest({
107 method: 'PUT', 108 method: 'PUT',
108 url: server.url, 109 url: server.url,
@@ -114,7 +115,7 @@ describe('Test video captions API validator', function () {
114 }) 115 })
115 116
116 it('Should fail with a bad access token', async function () { 117 it('Should fail with a bad access token', async function () {
117 const captionPath = path + videoUUID + '/captions/fr' 118 const captionPath = path + video.uuid + '/captions/fr'
118 await makeUploadRequest({ 119 await makeUploadRequest({
119 method: 'PUT', 120 method: 'PUT',
120 url: server.url, 121 url: server.url,
@@ -129,10 +130,10 @@ describe('Test video captions API validator', function () {
129 // We accept any file now 130 // We accept any file now
130 // it('Should fail with an invalid captionfile extension', async function () { 131 // it('Should fail with an invalid captionfile extension', async function () {
131 // const attaches = { 132 // const attaches = {
132 // 'captionfile': join(__dirname, '..', '..', 'fixtures', 'subtitle-bad.txt') 133 // 'captionfile': buildAbsoluteFixturePath('subtitle-bad.txt')
133 // } 134 // }
134 // 135 //
135 // const captionPath = path + videoUUID + '/captions/fr' 136 // const captionPath = path + video.uuid + '/captions/fr'
136 // await makeUploadRequest({ 137 // await makeUploadRequest({
137 // method: 'PUT', 138 // method: 'PUT',
138 // url: server.url, 139 // url: server.url,
@@ -150,7 +151,7 @@ describe('Test video captions API validator', function () {
150 // url: server.url, 151 // url: server.url,
151 // accessToken: server.accessToken, 152 // accessToken: server.accessToken,
152 // language: 'zh', 153 // language: 'zh',
153 // videoId: videoUUID, 154 // videoId: video.uuid,
154 // fixture: 'subtitle-bad.txt', 155 // fixture: 'subtitle-bad.txt',
155 // mimeType: 'application/octet-stream', 156 // mimeType: 'application/octet-stream',
156 // statusCodeExpected: HttpStatusCode.BAD_REQUEST_400 157 // statusCodeExpected: HttpStatusCode.BAD_REQUEST_400
@@ -162,7 +163,7 @@ describe('Test video captions API validator', function () {
162 url: server.url, 163 url: server.url,
163 accessToken: server.accessToken, 164 accessToken: server.accessToken,
164 language: 'zh', 165 language: 'zh',
165 videoId: videoUUID, 166 videoId: video.uuid,
166 fixture: 'subtitle-good.srt', 167 fixture: 'subtitle-good.srt',
167 mimeType: 'application/octet-stream' 168 mimeType: 'application/octet-stream'
168 }) 169 })
@@ -171,10 +172,10 @@ describe('Test video captions API validator', function () {
171 // We don't check the file validity yet 172 // We don't check the file validity yet
172 // it('Should fail with an invalid captionfile srt', async function () { 173 // it('Should fail with an invalid captionfile srt', async function () {
173 // const attaches = { 174 // const attaches = {
174 // 'captionfile': join(__dirname, '..', '..', 'fixtures', 'subtitle-bad.srt') 175 // 'captionfile': buildAbsoluteFixturePath('subtitle-bad.srt')
175 // } 176 // }
176 // 177 //
177 // const captionPath = path + videoUUID + '/captions/fr' 178 // const captionPath = path + video.uuid + '/captions/fr'
178 // await makeUploadRequest({ 179 // await makeUploadRequest({
179 // method: 'PUT', 180 // method: 'PUT',
180 // url: server.url, 181 // url: server.url,
@@ -187,7 +188,7 @@ describe('Test video captions API validator', function () {
187 // }) 188 // })
188 189
189 it('Should success with the correct parameters', async function () { 190 it('Should success with the correct parameters', async function () {
190 const captionPath = path + videoUUID + '/captions/fr' 191 const captionPath = path + video.uuid + '/captions/fr'
191 await makeUploadRequest({ 192 await makeUploadRequest({
192 method: 'PUT', 193 method: 'PUT',
193 url: server.url, 194 url: server.url,
@@ -214,7 +215,7 @@ describe('Test video captions API validator', function () {
214 }) 215 })
215 216
216 it('Should success with the correct parameters', async function () { 217 it('Should success with the correct parameters', async function () {
217 await makeGetRequest({ url: server.url, path: path + videoUUID + '/captions', statusCodeExpected: HttpStatusCode.OK_200 }) 218 await makeGetRequest({ url: server.url, path: path + video.shortUUID + '/captions', statusCodeExpected: HttpStatusCode.OK_200 })
218 }) 219 })
219 }) 220 })
220 221
@@ -245,27 +246,27 @@ describe('Test video captions API validator', function () {
245 }) 246 })
246 247
247 it('Should fail with a missing language', async function () { 248 it('Should fail with a missing language', async function () {
248 const captionPath = path + videoUUID + '/captions' 249 const captionPath = path + video.shortUUID + '/captions'
249 await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken }) 250 await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken })
250 }) 251 })
251 252
252 it('Should fail with an unknown language', async function () { 253 it('Should fail with an unknown language', async function () {
253 const captionPath = path + videoUUID + '/captions/15' 254 const captionPath = path + video.shortUUID + '/captions/15'
254 await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken }) 255 await makeDeleteRequest({ url: server.url, path: captionPath, token: server.accessToken })
255 }) 256 })
256 257
257 it('Should fail without access token', async function () { 258 it('Should fail without access token', async function () {
258 const captionPath = path + videoUUID + '/captions/fr' 259 const captionPath = path + video.shortUUID + '/captions/fr'
259 await makeDeleteRequest({ url: server.url, path: captionPath, statusCodeExpected: HttpStatusCode.UNAUTHORIZED_401 }) 260 await makeDeleteRequest({ url: server.url, path: captionPath, statusCodeExpected: HttpStatusCode.UNAUTHORIZED_401 })
260 }) 261 })
261 262
262 it('Should fail with a bad access token', async function () { 263 it('Should fail with a bad access token', async function () {
263 const captionPath = path + videoUUID + '/captions/fr' 264 const captionPath = path + video.shortUUID + '/captions/fr'
264 await makeDeleteRequest({ url: server.url, path: captionPath, token: 'coucou', statusCodeExpected: HttpStatusCode.UNAUTHORIZED_401 }) 265 await makeDeleteRequest({ url: server.url, path: captionPath, token: 'coucou', statusCodeExpected: HttpStatusCode.UNAUTHORIZED_401 })
265 }) 266 })
266 267
267 it('Should fail with another user', async function () { 268 it('Should fail with another user', async function () {
268 const captionPath = path + videoUUID + '/captions/fr' 269 const captionPath = path + video.shortUUID + '/captions/fr'
269 await makeDeleteRequest({ 270 await makeDeleteRequest({
270 url: server.url, 271 url: server.url,
271 path: captionPath, 272 path: captionPath,
@@ -275,7 +276,7 @@ describe('Test video captions API validator', function () {
275 }) 276 })
276 277
277 it('Should success with the correct parameters', async function () { 278 it('Should success with the correct parameters', async function () {
278 const captionPath = path + videoUUID + '/captions/fr' 279 const captionPath = path + video.shortUUID + '/captions/fr'
279 await makeDeleteRequest({ 280 await makeDeleteRequest({
280 url: server.url, 281 url: server.url,
281 path: captionPath, 282 path: captionPath,
diff --git a/server/tests/api/check-params/video-channels.ts b/server/tests/api/check-params/video-channels.ts
index bc2e6192e..5c02afd31 100644
--- a/server/tests/api/check-params/video-channels.ts
+++ b/server/tests/api/check-params/video-channels.ts
@@ -1,9 +1,11 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import 'mocha'
3import * as chai from 'chai' 4import * as chai from 'chai'
4import { omit } from 'lodash' 5import { omit } from 'lodash'
5import 'mocha' 6import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
6import { 7import {
8 buildAbsoluteFixturePath,
7 cleanupTests, 9 cleanupTests,
8 createUser, 10 createUser,
9 deleteVideoChannel, 11 deleteVideoChannel,
@@ -23,9 +25,7 @@ import {
23 checkBadSortPagination, 25 checkBadSortPagination,
24 checkBadStartPagination 26 checkBadStartPagination
25} from '../../../../shared/extra-utils/requests/check-api-params' 27} from '../../../../shared/extra-utils/requests/check-api-params'
26import { join } from 'path'
27import { VideoChannelUpdate } from '../../../../shared/models/videos' 28import { VideoChannelUpdate } from '../../../../shared/models/videos'
28import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
29 29
30const expect = chai.expect 30const expect = chai.expect
31 31
@@ -246,7 +246,7 @@ describe('Test video channels API validator', function () {
246 for (const type of types) { 246 for (const type of types) {
247 const fields = {} 247 const fields = {}
248 const attaches = { 248 const attaches = {
249 [type + 'file']: join(__dirname, '..', '..', 'fixtures', 'video_short.mp4') 249 [type + 'file']: buildAbsoluteFixturePath('video_short.mp4')
250 } 250 }
251 251
252 await makeUploadRequest({ url: server.url, path: `${path}/${type}/pick`, token: server.accessToken, fields, attaches }) 252 await makeUploadRequest({ url: server.url, path: `${path}/${type}/pick`, token: server.accessToken, fields, attaches })
@@ -257,7 +257,7 @@ describe('Test video channels API validator', function () {
257 for (const type of types) { 257 for (const type of types) {
258 const fields = {} 258 const fields = {}
259 const attaches = { 259 const attaches = {
260 [type + 'file']: join(__dirname, '..', '..', 'fixtures', 'avatar-big.png') 260 [type + 'file']: buildAbsoluteFixturePath('avatar-big.png')
261 } 261 }
262 await makeUploadRequest({ url: server.url, path: `${path}/${type}/pick`, token: server.accessToken, fields, attaches }) 262 await makeUploadRequest({ url: server.url, path: `${path}/${type}/pick`, token: server.accessToken, fields, attaches })
263 } 263 }
@@ -267,7 +267,7 @@ describe('Test video channels API validator', function () {
267 for (const type of types) { 267 for (const type of types) {
268 const fields = {} 268 const fields = {}
269 const attaches = { 269 const attaches = {
270 [type + 'file']: join(__dirname, '..', '..', 'fixtures', 'avatar.png') 270 [type + 'file']: buildAbsoluteFixturePath('avatar.png')
271 } 271 }
272 await makeUploadRequest({ 272 await makeUploadRequest({
273 url: server.url, 273 url: server.url,
@@ -283,7 +283,7 @@ describe('Test video channels API validator', function () {
283 for (const type of types) { 283 for (const type of types) {
284 const fields = {} 284 const fields = {}
285 const attaches = { 285 const attaches = {
286 [type + 'file']: join(__dirname, '..', '..', 'fixtures', 'avatar.png') 286 [type + 'file']: buildAbsoluteFixturePath('avatar.png')
287 } 287 }
288 await makeUploadRequest({ 288 await makeUploadRequest({
289 url: server.url, 289 url: server.url,
diff --git a/server/tests/api/check-params/video-comments.ts b/server/tests/api/check-params/video-comments.ts
index 659a10c41..a38420851 100644
--- a/server/tests/api/check-params/video-comments.ts
+++ b/server/tests/api/check-params/video-comments.ts
@@ -1,7 +1,9 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import * as chai from 'chai'
4import 'mocha' 3import 'mocha'
4import * as chai from 'chai'
5import { VideoCreateResult } from '@shared/models'
6import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
5import { 7import {
6 cleanupTests, 8 cleanupTests,
7 createUser, 9 createUser,
@@ -20,7 +22,6 @@ import {
20 checkBadStartPagination 22 checkBadStartPagination
21} from '../../../../shared/extra-utils/requests/check-api-params' 23} from '../../../../shared/extra-utils/requests/check-api-params'
22import { addVideoCommentThread } from '../../../../shared/extra-utils/videos/video-comments' 24import { addVideoCommentThread } from '../../../../shared/extra-utils/videos/video-comments'
23import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
24 25
25const expect = chai.expect 26const expect = chai.expect
26 27
@@ -28,7 +29,7 @@ describe('Test video comments API validator', function () {
28 let pathThread: string 29 let pathThread: string
29 let pathComment: string 30 let pathComment: string
30 let server: ServerInfo 31 let server: ServerInfo
31 let videoUUID: string 32 let video: VideoCreateResult
32 let userAccessToken: string 33 let userAccessToken: string
33 let userAccessToken2: string 34 let userAccessToken2: string
34 let commentId: number 35 let commentId: number
@@ -44,14 +45,14 @@ describe('Test video comments API validator', function () {
44 45
45 { 46 {
46 const res = await uploadVideo(server.url, server.accessToken, {}) 47 const res = await uploadVideo(server.url, server.accessToken, {})
47 videoUUID = res.body.video.uuid 48 video = res.body.video
48 pathThread = '/api/v1/videos/' + videoUUID + '/comment-threads' 49 pathThread = '/api/v1/videos/' + video.uuid + '/comment-threads'
49 } 50 }
50 51
51 { 52 {
52 const res = await addVideoCommentThread(server.url, server.accessToken, videoUUID, 'coucou') 53 const res = await addVideoCommentThread(server.url, server.accessToken, video.uuid, 'coucou')
53 commentId = res.body.comment.id 54 commentId = res.body.comment.id
54 pathComment = '/api/v1/videos/' + videoUUID + '/comments/' + commentId 55 pathComment = '/api/v1/videos/' + video.uuid + '/comments/' + commentId
55 } 56 }
56 57
57 { 58 {
@@ -101,7 +102,7 @@ describe('Test video comments API validator', function () {
101 it('Should fail with an incorrect thread id', async function () { 102 it('Should fail with an incorrect thread id', async function () {
102 await makeGetRequest({ 103 await makeGetRequest({
103 url: server.url, 104 url: server.url,
104 path: '/api/v1/videos/' + videoUUID + '/comment-threads/156', 105 path: '/api/v1/videos/' + video.shortUUID + '/comment-threads/156',
105 statusCodeExpected: HttpStatusCode.NOT_FOUND_404 106 statusCodeExpected: HttpStatusCode.NOT_FOUND_404
106 }) 107 })
107 }) 108 })
@@ -109,7 +110,7 @@ describe('Test video comments API validator', function () {
109 it('Should success with the correct params', async function () { 110 it('Should success with the correct params', async function () {
110 await makeGetRequest({ 111 await makeGetRequest({
111 url: server.url, 112 url: server.url,
112 path: '/api/v1/videos/' + videoUUID + '/comment-threads/' + commentId, 113 path: '/api/v1/videos/' + video.shortUUID + '/comment-threads/' + commentId,
113 statusCodeExpected: HttpStatusCode.OK_200 114 statusCodeExpected: HttpStatusCode.OK_200
114 }) 115 })
115 }) 116 })
@@ -225,7 +226,7 @@ describe('Test video comments API validator', function () {
225 }) 226 })
226 227
227 it('Should fail with an incorrect comment', async function () { 228 it('Should fail with an incorrect comment', async function () {
228 const path = '/api/v1/videos/' + videoUUID + '/comments/124' 229 const path = '/api/v1/videos/' + video.uuid + '/comments/124'
229 const fields = { 230 const fields = {
230 text: 'super comment' 231 text: 'super comment'
231 } 232 }
@@ -272,7 +273,7 @@ describe('Test video comments API validator', function () {
272 }) 273 })
273 274
274 it('Should fail with an incorrect comment', async function () { 275 it('Should fail with an incorrect comment', async function () {
275 const path = '/api/v1/videos/' + videoUUID + '/comments/124' 276 const path = '/api/v1/videos/' + video.uuid + '/comments/124'
276 await makeDeleteRequest({ url: server.url, path, token: server.accessToken, statusCodeExpected: HttpStatusCode.NOT_FOUND_404 }) 277 await makeDeleteRequest({ url: server.url, path, token: server.accessToken, statusCodeExpected: HttpStatusCode.NOT_FOUND_404 })
277 }) 278 })
278 279
@@ -280,11 +281,11 @@ describe('Test video comments API validator', function () {
280 let commentToDelete: number 281 let commentToDelete: number
281 282
282 { 283 {
283 const res = await addVideoCommentThread(server.url, userAccessToken, videoUUID, 'hello') 284 const res = await addVideoCommentThread(server.url, userAccessToken, video.uuid, 'hello')
284 commentToDelete = res.body.comment.id 285 commentToDelete = res.body.comment.id
285 } 286 }
286 287
287 const path = '/api/v1/videos/' + videoUUID + '/comments/' + commentToDelete 288 const path = '/api/v1/videos/' + video.uuid + '/comments/' + commentToDelete
288 289
289 await makeDeleteRequest({ url: server.url, path, token: userAccessToken2, statusCodeExpected: HttpStatusCode.FORBIDDEN_403 }) 290 await makeDeleteRequest({ url: server.url, path, token: userAccessToken2, statusCodeExpected: HttpStatusCode.FORBIDDEN_403 })
290 await makeDeleteRequest({ url: server.url, path, token: userAccessToken, statusCodeExpected: HttpStatusCode.NO_CONTENT_204 }) 291 await makeDeleteRequest({ url: server.url, path, token: userAccessToken, statusCodeExpected: HttpStatusCode.NO_CONTENT_204 })
@@ -323,8 +324,8 @@ describe('Test video comments API validator', function () {
323 describe('When a video has comments disabled', function () { 324 describe('When a video has comments disabled', function () {
324 before(async function () { 325 before(async function () {
325 const res = await uploadVideo(server.url, server.accessToken, { commentsEnabled: false }) 326 const res = await uploadVideo(server.url, server.accessToken, { commentsEnabled: false })
326 videoUUID = res.body.video.uuid 327 video = res.body.video
327 pathThread = '/api/v1/videos/' + videoUUID + '/comment-threads' 328 pathThread = '/api/v1/videos/' + video.uuid + '/comment-threads'
328 }) 329 })
329 330
330 it('Should return an empty thread list', async function () { 331 it('Should return an empty thread list', async function () {
diff --git a/server/tests/api/check-params/video-imports.ts b/server/tests/api/check-params/video-imports.ts
index 6de6b40c8..a27b624d0 100644
--- a/server/tests/api/check-params/video-imports.ts
+++ b/server/tests/api/check-params/video-imports.ts
@@ -2,8 +2,9 @@
2 2
3import 'mocha' 3import 'mocha'
4import { omit } from 'lodash' 4import { omit } from 'lodash'
5import { join } from 'path' 5import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
6import { 6import {
7 buildAbsoluteFixturePath,
7 cleanupTests, 8 cleanupTests,
8 createUser, 9 createUser,
9 flushAndRunServer, 10 flushAndRunServer,
@@ -22,9 +23,8 @@ import {
22 checkBadSortPagination, 23 checkBadSortPagination,
23 checkBadStartPagination 24 checkBadStartPagination
24} from '../../../../shared/extra-utils/requests/check-api-params' 25} from '../../../../shared/extra-utils/requests/check-api-params'
25import { getMagnetURI, getGoodVideoUrl } from '../../../../shared/extra-utils/videos/video-imports' 26import { getGoodVideoUrl, getMagnetURI } from '../../../../shared/extra-utils/videos/video-imports'
26import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum' 27import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum'
27import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
28 28
29describe('Test video imports API validator', function () { 29describe('Test video imports API validator', function () {
30 const path = '/api/v1/videos/imports' 30 const path = '/api/v1/videos/imports'
@@ -201,7 +201,7 @@ describe('Test video imports API validator', function () {
201 it('Should fail with an incorrect thumbnail file', async function () { 201 it('Should fail with an incorrect thumbnail file', async function () {
202 const fields = baseCorrectParams 202 const fields = baseCorrectParams
203 const attaches = { 203 const attaches = {
204 thumbnailfile: join(__dirname, '..', '..', 'fixtures', 'video_short.mp4') 204 thumbnailfile: buildAbsoluteFixturePath('video_short.mp4')
205 } 205 }
206 206
207 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) 207 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches })
@@ -210,7 +210,7 @@ describe('Test video imports API validator', function () {
210 it('Should fail with a big thumbnail file', async function () { 210 it('Should fail with a big thumbnail file', async function () {
211 const fields = baseCorrectParams 211 const fields = baseCorrectParams
212 const attaches = { 212 const attaches = {
213 thumbnailfile: join(__dirname, '..', '..', 'fixtures', 'preview-big.png') 213 thumbnailfile: buildAbsoluteFixturePath('preview-big.png')
214 } 214 }
215 215
216 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) 216 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches })
@@ -219,7 +219,7 @@ describe('Test video imports API validator', function () {
219 it('Should fail with an incorrect preview file', async function () { 219 it('Should fail with an incorrect preview file', async function () {
220 const fields = baseCorrectParams 220 const fields = baseCorrectParams
221 const attaches = { 221 const attaches = {
222 previewfile: join(__dirname, '..', '..', 'fixtures', 'video_short.mp4') 222 previewfile: buildAbsoluteFixturePath('video_short.mp4')
223 } 223 }
224 224
225 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) 225 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches })
@@ -228,7 +228,7 @@ describe('Test video imports API validator', function () {
228 it('Should fail with a big preview file', async function () { 228 it('Should fail with a big preview file', async function () {
229 const fields = baseCorrectParams 229 const fields = baseCorrectParams
230 const attaches = { 230 const attaches = {
231 previewfile: join(__dirname, '..', '..', 'fixtures', 'preview-big.png') 231 previewfile: buildAbsoluteFixturePath('preview-big.png')
232 } 232 }
233 233
234 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) 234 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches })
@@ -237,7 +237,7 @@ describe('Test video imports API validator', function () {
237 it('Should fail with an invalid torrent file', async function () { 237 it('Should fail with an invalid torrent file', async function () {
238 const fields = omit(baseCorrectParams, 'targetUrl') 238 const fields = omit(baseCorrectParams, 'targetUrl')
239 const attaches = { 239 const attaches = {
240 torrentfile: join(__dirname, '..', '..', 'fixtures', 'avatar-big.png') 240 torrentfile: buildAbsoluteFixturePath('avatar-big.png')
241 } 241 }
242 242
243 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches }) 243 await makeUploadRequest({ url: server.url, path, token: server.accessToken, fields, attaches })
@@ -312,7 +312,7 @@ describe('Test video imports API validator', function () {
312 312
313 fields = omit(fields, 'magnetUri') 313 fields = omit(fields, 'magnetUri')
314 const attaches = { 314 const attaches = {
315 torrentfile: join(__dirname, '..', '..', 'fixtures', 'video-720p.torrent') 315 torrentfile: buildAbsoluteFixturePath('video-720p.torrent')
316 } 316 }
317 317
318 await makeUploadRequest({ 318 await makeUploadRequest({
diff --git a/server/tests/api/check-params/video-playlists.ts b/server/tests/api/check-params/video-playlists.ts
index bbea88354..18253d11a 100644
--- a/server/tests/api/check-params/video-playlists.ts
+++ b/server/tests/api/check-params/video-playlists.ts
@@ -1,8 +1,13 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import 'mocha' 3import 'mocha'
4import { VideoPlaylistCreateResult, VideoPlaylistPrivacy, VideoPlaylistType } from '@shared/models'
5import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
4import { 6import {
5 addVideoInPlaylist, 7 addVideoInPlaylist,
8 checkBadCountPagination,
9 checkBadSortPagination,
10 checkBadStartPagination,
6 cleanupTests, 11 cleanupTests,
7 createVideoPlaylist, 12 createVideoPlaylist,
8 deleteVideoPlaylist, 13 deleteVideoPlaylist,
@@ -21,20 +26,14 @@ import {
21 updateVideoPlaylistElement, 26 updateVideoPlaylistElement,
22 uploadVideoAndGetId 27 uploadVideoAndGetId
23} from '../../../../shared/extra-utils' 28} from '../../../../shared/extra-utils'
24import {
25 checkBadCountPagination,
26 checkBadSortPagination,
27 checkBadStartPagination
28} from '../../../../shared/extra-utils/requests/check-api-params'
29import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
30import { VideoPlaylistType } from '../../../../shared/models/videos/playlist/video-playlist-type.model'
31import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
32 29
33describe('Test video playlists API validator', function () { 30describe('Test video playlists API validator', function () {
34 let server: ServerInfo 31 let server: ServerInfo
35 let userAccessToken: string 32 let userAccessToken: string
36 let playlistUUID: string 33
34 let playlist: VideoPlaylistCreateResult
37 let privatePlaylistUUID: string 35 let privatePlaylistUUID: string
36
38 let watchLaterPlaylistId: number 37 let watchLaterPlaylistId: number
39 let videoId: number 38 let videoId: number
40 let playlistElementId: number 39 let playlistElementId: number
@@ -67,7 +66,7 @@ describe('Test video playlists API validator', function () {
67 videoChannelId: server.videoChannel.id 66 videoChannelId: server.videoChannel.id
68 } 67 }
69 }) 68 })
70 playlistUUID = res.body.videoPlaylist.uuid 69 playlist = res.body.videoPlaylist
71 } 70 }
72 71
73 { 72 {
@@ -150,15 +149,15 @@ describe('Test video playlists API validator', function () {
150 const path = '/api/v1/video-playlists/' 149 const path = '/api/v1/video-playlists/'
151 150
152 it('Should fail with a bad start pagination', async function () { 151 it('Should fail with a bad start pagination', async function () {
153 await checkBadStartPagination(server.url, path + playlistUUID + '/videos', server.accessToken) 152 await checkBadStartPagination(server.url, path + playlist.shortUUID + '/videos', server.accessToken)
154 }) 153 })
155 154
156 it('Should fail with a bad count pagination', async function () { 155 it('Should fail with a bad count pagination', async function () {
157 await checkBadCountPagination(server.url, path + playlistUUID + '/videos', server.accessToken) 156 await checkBadCountPagination(server.url, path + playlist.shortUUID + '/videos', server.accessToken)
158 }) 157 })
159 158
160 it('Should success with the correct parameters', async function () { 159 it('Should success with the correct parameters', async function () {
161 await makeGetRequest({ url: server.url, path: path + playlistUUID + '/videos', statusCodeExpected: HttpStatusCode.OK_200 }) 160 await makeGetRequest({ url: server.url, path: path + playlist.shortUUID + '/videos', statusCodeExpected: HttpStatusCode.OK_200 })
162 }) 161 })
163 }) 162 })
164 163
@@ -177,6 +176,7 @@ describe('Test video playlists API validator', function () {
177 token: server.accessToken, 176 token: server.accessToken,
178 playlistAttrs: { 177 playlistAttrs: {
179 displayName: 'super playlist', 178 displayName: 'super playlist',
179 videoChannelId: server.videoChannel.id,
180 privacy: VideoPlaylistPrivacy.UNLISTED 180 privacy: VideoPlaylistPrivacy.UNLISTED
181 } 181 }
182 }) 182 })
@@ -187,7 +187,7 @@ describe('Test video playlists API validator', function () {
187 }) 187 })
188 188
189 it('Should succeed with the correct params', async function () { 189 it('Should succeed with the correct params', async function () {
190 await getVideoPlaylist(server.url, playlistUUID, HttpStatusCode.OK_200) 190 await getVideoPlaylist(server.url, playlist.uuid, HttpStatusCode.OK_200)
191 }) 191 })
192 }) 192 })
193 193
@@ -213,7 +213,7 @@ describe('Test video playlists API validator', function () {
213 const params = getBase({}, { token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) 213 const params = getBase({}, { token: null, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
214 214
215 await createVideoPlaylist(params) 215 await createVideoPlaylist(params)
216 await updateVideoPlaylist(getUpdate(params, playlistUUID)) 216 await updateVideoPlaylist(getUpdate(params, playlist.shortUUID))
217 }) 217 })
218 218
219 it('Should fail without displayName', async function () { 219 it('Should fail without displayName', async function () {
@@ -226,42 +226,42 @@ describe('Test video playlists API validator', function () {
226 const params = getBase({ displayName: 's'.repeat(300) }) 226 const params = getBase({ displayName: 's'.repeat(300) })
227 227
228 await createVideoPlaylist(params) 228 await createVideoPlaylist(params)
229 await updateVideoPlaylist(getUpdate(params, playlistUUID)) 229 await updateVideoPlaylist(getUpdate(params, playlist.shortUUID))
230 }) 230 })
231 231
232 it('Should fail with an incorrect description', async function () { 232 it('Should fail with an incorrect description', async function () {
233 const params = getBase({ description: 't' }) 233 const params = getBase({ description: 't' })
234 234
235 await createVideoPlaylist(params) 235 await createVideoPlaylist(params)
236 await updateVideoPlaylist(getUpdate(params, playlistUUID)) 236 await updateVideoPlaylist(getUpdate(params, playlist.shortUUID))
237 }) 237 })
238 238
239 it('Should fail with an incorrect privacy', async function () { 239 it('Should fail with an incorrect privacy', async function () {
240 const params = getBase({ privacy: 45 }) 240 const params = getBase({ privacy: 45 })
241 241
242 await createVideoPlaylist(params) 242 await createVideoPlaylist(params)
243 await updateVideoPlaylist(getUpdate(params, playlistUUID)) 243 await updateVideoPlaylist(getUpdate(params, playlist.shortUUID))
244 }) 244 })
245 245
246 it('Should fail with an unknown video channel id', async function () { 246 it('Should fail with an unknown video channel id', async function () {
247 const params = getBase({ videoChannelId: 42 }, { expectedStatus: HttpStatusCode.NOT_FOUND_404 }) 247 const params = getBase({ videoChannelId: 42 }, { expectedStatus: HttpStatusCode.NOT_FOUND_404 })
248 248
249 await createVideoPlaylist(params) 249 await createVideoPlaylist(params)
250 await updateVideoPlaylist(getUpdate(params, playlistUUID)) 250 await updateVideoPlaylist(getUpdate(params, playlist.shortUUID))
251 }) 251 })
252 252
253 it('Should fail with an incorrect thumbnail file', async function () { 253 it('Should fail with an incorrect thumbnail file', async function () {
254 const params = getBase({ thumbnailfile: 'video_short.mp4' }) 254 const params = getBase({ thumbnailfile: 'video_short.mp4' })
255 255
256 await createVideoPlaylist(params) 256 await createVideoPlaylist(params)
257 await updateVideoPlaylist(getUpdate(params, playlistUUID)) 257 await updateVideoPlaylist(getUpdate(params, playlist.shortUUID))
258 }) 258 })
259 259
260 it('Should fail with a thumbnail file too big', async function () { 260 it('Should fail with a thumbnail file too big', async function () {
261 const params = getBase({ thumbnailfile: 'preview-big.png' }) 261 const params = getBase({ thumbnailfile: 'preview-big.png' })
262 262
263 await createVideoPlaylist(params) 263 await createVideoPlaylist(params)
264 await updateVideoPlaylist(getUpdate(params, playlistUUID)) 264 await updateVideoPlaylist(getUpdate(params, playlist.shortUUID))
265 }) 265 })
266 266
267 it('Should fail to set "public" a playlist not assigned to a channel', async function () { 267 it('Should fail to set "public" a playlist not assigned to a channel', async function () {
@@ -272,8 +272,8 @@ describe('Test video playlists API validator', function () {
272 await createVideoPlaylist(params) 272 await createVideoPlaylist(params)
273 await createVideoPlaylist(params2) 273 await createVideoPlaylist(params2)
274 await updateVideoPlaylist(getUpdate(params, privatePlaylistUUID)) 274 await updateVideoPlaylist(getUpdate(params, privatePlaylistUUID))
275 await updateVideoPlaylist(getUpdate(params2, playlistUUID)) 275 await updateVideoPlaylist(getUpdate(params2, playlist.shortUUID))
276 await updateVideoPlaylist(getUpdate(params3, playlistUUID)) 276 await updateVideoPlaylist(getUpdate(params3, playlist.shortUUID))
277 }) 277 })
278 278
279 it('Should fail with an unknown playlist to update', async function () { 279 it('Should fail with an unknown playlist to update', async function () {
@@ -286,7 +286,7 @@ describe('Test video playlists API validator', function () {
286 it('Should fail to update a playlist of another user', async function () { 286 it('Should fail to update a playlist of another user', async function () {
287 await updateVideoPlaylist(getUpdate( 287 await updateVideoPlaylist(getUpdate(
288 getBase({}, { token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }), 288 getBase({}, { token: userAccessToken, expectedStatus: HttpStatusCode.FORBIDDEN_403 }),
289 playlistUUID 289 playlist.shortUUID
290 )) 290 ))
291 }) 291 })
292 292
@@ -305,7 +305,7 @@ describe('Test video playlists API validator', function () {
305 305
306 { 306 {
307 const params = getBase({}, { expectedStatus: HttpStatusCode.NO_CONTENT_204 }) 307 const params = getBase({}, { expectedStatus: HttpStatusCode.NO_CONTENT_204 })
308 await updateVideoPlaylist(getUpdate(params, playlistUUID)) 308 await updateVideoPlaylist(getUpdate(params, playlist.shortUUID))
309 } 309 }
310 }) 310 })
311 }) 311 })
@@ -316,7 +316,7 @@ describe('Test video playlists API validator', function () {
316 expectedStatus: HttpStatusCode.BAD_REQUEST_400, 316 expectedStatus: HttpStatusCode.BAD_REQUEST_400,
317 url: server.url, 317 url: server.url,
318 token: server.accessToken, 318 token: server.accessToken,
319 playlistId: playlistUUID, 319 playlistId: playlist.id,
320 elementAttrs: Object.assign({ 320 elementAttrs: Object.assign({
321 videoId, 321 videoId,
322 startTimestamp: 2, 322 startTimestamp: 2,
@@ -381,7 +381,7 @@ describe('Test video playlists API validator', function () {
381 stopTimestamp: 2 381 stopTimestamp: 2
382 }, elementAttrs), 382 }, elementAttrs),
383 playlistElementId, 383 playlistElementId,
384 playlistId: playlistUUID, 384 playlistId: playlist.id,
385 expectedStatus: HttpStatusCode.BAD_REQUEST_400 385 expectedStatus: HttpStatusCode.BAD_REQUEST_400
386 }, wrapper) 386 }, wrapper)
387 } 387 }
@@ -451,7 +451,7 @@ describe('Test video playlists API validator', function () {
451 return Object.assign({ 451 return Object.assign({
452 url: server.url, 452 url: server.url,
453 token: server.accessToken, 453 token: server.accessToken,
454 playlistId: playlistUUID, 454 playlistId: playlist.shortUUID,
455 elementAttrs: Object.assign({ 455 elementAttrs: Object.assign({
456 startPosition: 1, 456 startPosition: 1,
457 insertAfterPosition: 2, 457 insertAfterPosition: 2,
@@ -469,7 +469,7 @@ describe('Test video playlists API validator', function () {
469 await addVideoInPlaylist({ 469 await addVideoInPlaylist({
470 url: server.url, 470 url: server.url,
471 token: server.accessToken, 471 token: server.accessToken,
472 playlistId: playlistUUID, 472 playlistId: playlist.shortUUID,
473 elementAttrs: { videoId: id } 473 elementAttrs: { videoId: id }
474 }) 474 })
475 } 475 }
@@ -606,7 +606,7 @@ describe('Test video playlists API validator', function () {
606 url: server.url, 606 url: server.url,
607 token: server.accessToken, 607 token: server.accessToken,
608 playlistElementId, 608 playlistElementId,
609 playlistId: playlistUUID, 609 playlistId: playlist.uuid,
610 expectedStatus: HttpStatusCode.BAD_REQUEST_400 610 expectedStatus: HttpStatusCode.BAD_REQUEST_400
611 }, wrapper) 611 }, wrapper)
612 } 612 }
@@ -662,7 +662,7 @@ describe('Test video playlists API validator', function () {
662 }) 662 })
663 663
664 it('Should fail with a playlist of another user', async function () { 664 it('Should fail with a playlist of another user', async function () {
665 await deleteVideoPlaylist(server.url, userAccessToken, playlistUUID, HttpStatusCode.FORBIDDEN_403) 665 await deleteVideoPlaylist(server.url, userAccessToken, playlist.uuid, HttpStatusCode.FORBIDDEN_403)
666 }) 666 })
667 667
668 it('Should fail with the watch later playlist', async function () { 668 it('Should fail with the watch later playlist', async function () {
@@ -670,7 +670,7 @@ describe('Test video playlists API validator', function () {
670 }) 670 })
671 671
672 it('Should succeed with the correct params', async function () { 672 it('Should succeed with the correct params', async function () {
673 await deleteVideoPlaylist(server.url, server.accessToken, playlistUUID) 673 await deleteVideoPlaylist(server.url, server.accessToken, playlist.uuid)
674 }) 674 })
675 }) 675 })
676 676
diff --git a/server/tests/api/check-params/videos-filter.ts b/server/tests/api/check-params/videos-filter.ts
index 2391584a7..4d54a4fd0 100644
--- a/server/tests/api/check-params/videos-filter.ts
+++ b/server/tests/api/check-params/videos-filter.ts
@@ -35,7 +35,7 @@ async function testEndpoints (server: ServerInfo, token: string, filter: string,
35 } 35 }
36} 36}
37 37
38describe('Test videos filters', function () { 38describe('Test video filters validators', function () {
39 let server: ServerInfo 39 let server: ServerInfo
40 let userAccessToken: string 40 let userAccessToken: string
41 let moderatorAccessToken: string 41 let moderatorAccessToken: string
diff --git a/server/tests/api/check-params/videos.ts b/server/tests/api/check-params/videos.ts
index c970c4a15..4d7a9a23b 100644
--- a/server/tests/api/check-params/videos.ts
+++ b/server/tests/api/check-params/videos.ts
@@ -4,6 +4,8 @@ import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { omit } from 'lodash' 5import { omit } from 'lodash'
6import { join } from 'path' 6import { join } from 'path'
7import { randomInt } from '@shared/core-utils'
8import { PeerTubeProblemDocument, VideoCreateResult } from '@shared/models'
7import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 9import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
8import { 10import {
9 checkUploadVideoParam, 11 checkUploadVideoParam,
@@ -30,7 +32,6 @@ import {
30 checkBadStartPagination 32 checkBadStartPagination
31} from '../../../../shared/extra-utils/requests/check-api-params' 33} from '../../../../shared/extra-utils/requests/check-api-params'
32import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum' 34import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum'
33import { randomInt } from '@shared/core-utils'
34 35
35const expect = chai.expect 36const expect = chai.expect
36 37
@@ -41,7 +42,7 @@ describe('Test videos API validator', function () {
41 let accountName: string 42 let accountName: string
42 let channelId: number 43 let channelId: number
43 let channelName: string 44 let channelName: string
44 let videoId 45 let video: VideoCreateResult
45 46
46 // --------------------------------------------------------------- 47 // ---------------------------------------------------------------
47 48
@@ -411,6 +412,31 @@ describe('Test videos API validator', function () {
411 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode) 412 await checkUploadVideoParam(server.url, server.accessToken, { ...fields, ...attaches }, HttpStatusCode.BAD_REQUEST_400, mode)
412 }) 413 })
413 414
415 it('Should report the appropriate error', async function () {
416 const fields = immutableAssign(baseCorrectParams, { language: 'a'.repeat(15) })
417 const attaches = baseCorrectAttaches
418
419 const attributes = { ...fields, ...attaches }
420 const res = await checkUploadVideoParam(server.url, server.accessToken, attributes, HttpStatusCode.BAD_REQUEST_400, mode)
421
422 const error = res.body as PeerTubeProblemDocument
423
424 if (mode === 'legacy') {
425 expect(error.docs).to.equal('https://docs.joinpeertube.org/api-rest-reference.html#operation/uploadLegacy')
426 } else {
427 expect(error.docs).to.equal('https://docs.joinpeertube.org/api-rest-reference.html#operation/uploadResumableInit')
428 }
429
430 expect(error.type).to.equal('about:blank')
431 expect(error.title).to.equal('Bad Request')
432
433 expect(error.detail).to.equal('Incorrect request parameters: language')
434 expect(error.error).to.equal('Incorrect request parameters: language')
435
436 expect(error.status).to.equal(HttpStatusCode.BAD_REQUEST_400)
437 expect(error['invalid-params'].language).to.exist
438 })
439
414 it('Should succeed with the correct parameters', async function () { 440 it('Should succeed with the correct parameters', async function () {
415 this.timeout(10000) 441 this.timeout(10000)
416 442
@@ -464,7 +490,7 @@ describe('Test videos API validator', function () {
464 490
465 before(async function () { 491 before(async function () {
466 const res = await getVideosList(server.url) 492 const res = await getVideosList(server.url)
467 videoId = res.body.data[0].uuid 493 video = res.body.data[0]
468 }) 494 })
469 495
470 it('Should fail with nothing', async function () { 496 it('Should fail with nothing', async function () {
@@ -492,79 +518,79 @@ describe('Test videos API validator', function () {
492 it('Should fail with a long name', async function () { 518 it('Should fail with a long name', async function () {
493 const fields = immutableAssign(baseCorrectParams, { name: 'super'.repeat(65) }) 519 const fields = immutableAssign(baseCorrectParams, { name: 'super'.repeat(65) })
494 520
495 await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields }) 521 await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields })
496 }) 522 })
497 523
498 it('Should fail with a bad category', async function () { 524 it('Should fail with a bad category', async function () {
499 const fields = immutableAssign(baseCorrectParams, { category: 125 }) 525 const fields = immutableAssign(baseCorrectParams, { category: 125 })
500 526
501 await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields }) 527 await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields })
502 }) 528 })
503 529
504 it('Should fail with a bad licence', async function () { 530 it('Should fail with a bad licence', async function () {
505 const fields = immutableAssign(baseCorrectParams, { licence: 125 }) 531 const fields = immutableAssign(baseCorrectParams, { licence: 125 })
506 532
507 await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields }) 533 await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields })
508 }) 534 })
509 535
510 it('Should fail with a bad language', async function () { 536 it('Should fail with a bad language', async function () {
511 const fields = immutableAssign(baseCorrectParams, { language: 'a'.repeat(15) }) 537 const fields = immutableAssign(baseCorrectParams, { language: 'a'.repeat(15) })
512 538
513 await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields }) 539 await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields })
514 }) 540 })
515 541
516 it('Should fail with a long description', async function () { 542 it('Should fail with a long description', async function () {
517 const fields = immutableAssign(baseCorrectParams, { description: 'super'.repeat(2500) }) 543 const fields = immutableAssign(baseCorrectParams, { description: 'super'.repeat(2500) })
518 544
519 await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields }) 545 await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields })
520 }) 546 })
521 547
522 it('Should fail with a long support text', async function () { 548 it('Should fail with a long support text', async function () {
523 const fields = immutableAssign(baseCorrectParams, { support: 'super'.repeat(201) }) 549 const fields = immutableAssign(baseCorrectParams, { support: 'super'.repeat(201) })
524 550
525 await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields }) 551 await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields })
526 }) 552 })
527 553
528 it('Should fail with a bad channel', async function () { 554 it('Should fail with a bad channel', async function () {
529 const fields = immutableAssign(baseCorrectParams, { channelId: 545454 }) 555 const fields = immutableAssign(baseCorrectParams, { channelId: 545454 })
530 556
531 await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields }) 557 await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields })
532 }) 558 })
533 559
534 it('Should fail with too many tags', async function () { 560 it('Should fail with too many tags', async function () {
535 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6' ] }) 561 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6' ] })
536 562
537 await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields }) 563 await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields })
538 }) 564 })
539 565
540 it('Should fail with a tag length too low', async function () { 566 it('Should fail with a tag length too low', async function () {
541 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 't' ] }) 567 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 't' ] })
542 568
543 await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields }) 569 await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields })
544 }) 570 })
545 571
546 it('Should fail with a tag length too big', async function () { 572 it('Should fail with a tag length too big', async function () {
547 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 'my_super_tag_too_long_long_long_long_long_long' ] }) 573 const fields = immutableAssign(baseCorrectParams, { tags: [ 'tag1', 'my_super_tag_too_long_long_long_long_long_long' ] })
548 574
549 await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields }) 575 await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields })
550 }) 576 })
551 577
552 it('Should fail with a bad schedule update (miss updateAt)', async function () { 578 it('Should fail with a bad schedule update (miss updateAt)', async function () {
553 const fields = immutableAssign(baseCorrectParams, { scheduleUpdate: { privacy: VideoPrivacy.PUBLIC } }) 579 const fields = immutableAssign(baseCorrectParams, { scheduleUpdate: { privacy: VideoPrivacy.PUBLIC } })
554 580
555 await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields }) 581 await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields })
556 }) 582 })
557 583
558 it('Should fail with a bad schedule update (wrong updateAt)', async function () { 584 it('Should fail with a bad schedule update (wrong updateAt)', async function () {
559 const fields = immutableAssign(baseCorrectParams, { scheduleUpdate: { updateAt: 'toto', privacy: VideoPrivacy.PUBLIC } }) 585 const fields = immutableAssign(baseCorrectParams, { scheduleUpdate: { updateAt: 'toto', privacy: VideoPrivacy.PUBLIC } })
560 586
561 await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields }) 587 await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields })
562 }) 588 })
563 589
564 it('Should fail with a bad originally published at param', async function () { 590 it('Should fail with a bad originally published at param', async function () {
565 const fields = immutableAssign(baseCorrectParams, { originallyPublishedAt: 'toto' }) 591 const fields = immutableAssign(baseCorrectParams, { originallyPublishedAt: 'toto' })
566 592
567 await makePutBodyRequest({ url: server.url, path: path + videoId, token: server.accessToken, fields }) 593 await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields })
568 }) 594 })
569 595
570 it('Should fail with an incorrect thumbnail file', async function () { 596 it('Should fail with an incorrect thumbnail file', async function () {
@@ -576,7 +602,7 @@ describe('Test videos API validator', function () {
576 await makeUploadRequest({ 602 await makeUploadRequest({
577 url: server.url, 603 url: server.url,
578 method: 'PUT', 604 method: 'PUT',
579 path: path + videoId, 605 path: path + video.shortUUID,
580 token: server.accessToken, 606 token: server.accessToken,
581 fields, 607 fields,
582 attaches 608 attaches
@@ -592,7 +618,7 @@ describe('Test videos API validator', function () {
592 await makeUploadRequest({ 618 await makeUploadRequest({
593 url: server.url, 619 url: server.url,
594 method: 'PUT', 620 method: 'PUT',
595 path: path + videoId, 621 path: path + video.shortUUID,
596 token: server.accessToken, 622 token: server.accessToken,
597 fields, 623 fields,
598 attaches 624 attaches
@@ -608,7 +634,7 @@ describe('Test videos API validator', function () {
608 await makeUploadRequest({ 634 await makeUploadRequest({
609 url: server.url, 635 url: server.url,
610 method: 'PUT', 636 method: 'PUT',
611 path: path + videoId, 637 path: path + video.shortUUID,
612 token: server.accessToken, 638 token: server.accessToken,
613 fields, 639 fields,
614 attaches 640 attaches
@@ -624,7 +650,7 @@ describe('Test videos API validator', function () {
624 await makeUploadRequest({ 650 await makeUploadRequest({
625 url: server.url, 651 url: server.url,
626 method: 'PUT', 652 method: 'PUT',
627 path: path + videoId, 653 path: path + video.shortUUID,
628 token: server.accessToken, 654 token: server.accessToken,
629 fields, 655 fields,
630 attaches 656 attaches
@@ -636,7 +662,7 @@ describe('Test videos API validator', function () {
636 662
637 await makePutBodyRequest({ 663 await makePutBodyRequest({
638 url: server.url, 664 url: server.url,
639 path: path + videoId, 665 path: path + video.shortUUID,
640 token: userAccessToken, 666 token: userAccessToken,
641 fields, 667 fields,
642 statusCodeExpected: HttpStatusCode.FORBIDDEN_403 668 statusCodeExpected: HttpStatusCode.FORBIDDEN_403
@@ -645,12 +671,30 @@ describe('Test videos API validator', function () {
645 671
646 it('Should fail with a video of another server') 672 it('Should fail with a video of another server')
647 673
674 it('Shoud report the appropriate error', async function () {
675 const fields = immutableAssign(baseCorrectParams, { licence: 125 })
676
677 const res = await makePutBodyRequest({ url: server.url, path: path + video.shortUUID, token: server.accessToken, fields })
678 const error = res.body as PeerTubeProblemDocument
679
680 expect(error.docs).to.equal('https://docs.joinpeertube.org/api-rest-reference.html#operation/putVideo')
681
682 expect(error.type).to.equal('about:blank')
683 expect(error.title).to.equal('Bad Request')
684
685 expect(error.detail).to.equal('Incorrect request parameters: licence')
686 expect(error.error).to.equal('Incorrect request parameters: licence')
687
688 expect(error.status).to.equal(HttpStatusCode.BAD_REQUEST_400)
689 expect(error['invalid-params'].licence).to.exist
690 })
691
648 it('Should succeed with the correct parameters', async function () { 692 it('Should succeed with the correct parameters', async function () {
649 const fields = baseCorrectParams 693 const fields = baseCorrectParams
650 694
651 await makePutBodyRequest({ 695 await makePutBodyRequest({
652 url: server.url, 696 url: server.url,
653 path: path + videoId, 697 path: path + video.shortUUID,
654 token: server.accessToken, 698 token: server.accessToken,
655 fields, 699 fields,
656 statusCodeExpected: HttpStatusCode.NO_CONTENT_204 700 statusCodeExpected: HttpStatusCode.NO_CONTENT_204
@@ -678,8 +722,24 @@ describe('Test videos API validator', function () {
678 await getVideo(server.url, '4da6fde3-88f7-4d16-b119-108df5630b06', HttpStatusCode.NOT_FOUND_404) 722 await getVideo(server.url, '4da6fde3-88f7-4d16-b119-108df5630b06', HttpStatusCode.NOT_FOUND_404)
679 }) 723 })
680 724
725 it('Shoud report the appropriate error', async function () {
726 const res = await getVideo(server.url, 'hi', HttpStatusCode.BAD_REQUEST_400)
727 const error = res.body as PeerTubeProblemDocument
728
729 expect(error.docs).to.equal('https://docs.joinpeertube.org/api-rest-reference.html#operation/getVideo')
730
731 expect(error.type).to.equal('about:blank')
732 expect(error.title).to.equal('Bad Request')
733
734 expect(error.detail).to.equal('Incorrect request parameters: id')
735 expect(error.error).to.equal('Incorrect request parameters: id')
736
737 expect(error.status).to.equal(HttpStatusCode.BAD_REQUEST_400)
738 expect(error['invalid-params'].id).to.exist
739 })
740
681 it('Should succeed with the correct parameters', async function () { 741 it('Should succeed with the correct parameters', async function () {
682 await getVideo(server.url, videoId) 742 await getVideo(server.url, video.shortUUID)
683 }) 743 })
684 }) 744 })
685 745
@@ -750,13 +810,29 @@ describe('Test videos API validator', function () {
750 }) 810 })
751 811
752 it('Should fail with a video of another user without the appropriate right', async function () { 812 it('Should fail with a video of another user without the appropriate right', async function () {
753 await removeVideo(server.url, userAccessToken, videoId, HttpStatusCode.FORBIDDEN_403) 813 await removeVideo(server.url, userAccessToken, video.uuid, HttpStatusCode.FORBIDDEN_403)
754 }) 814 })
755 815
756 it('Should fail with a video of another server') 816 it('Should fail with a video of another server')
757 817
818 it('Shoud report the appropriate error', async function () {
819 const res = await removeVideo(server.url, server.accessToken, 'hello', HttpStatusCode.BAD_REQUEST_400)
820 const error = res.body as PeerTubeProblemDocument
821
822 expect(error.docs).to.equal('https://docs.joinpeertube.org/api-rest-reference.html#operation/delVideo')
823
824 expect(error.type).to.equal('about:blank')
825 expect(error.title).to.equal('Bad Request')
826
827 expect(error.detail).to.equal('Incorrect request parameters: id')
828 expect(error.error).to.equal('Incorrect request parameters: id')
829
830 expect(error.status).to.equal(HttpStatusCode.BAD_REQUEST_400)
831 expect(error['invalid-params'].id).to.exist
832 })
833
758 it('Should succeed with the correct parameters', async function () { 834 it('Should succeed with the correct parameters', async function () {
759 await removeVideo(server.url, server.accessToken, videoId) 835 await removeVideo(server.url, server.accessToken, video.uuid)
760 }) 836 })
761 }) 837 })
762 838
diff --git a/server/tests/api/live/index.ts b/server/tests/api/live/index.ts
index c733f564e..e6bcef49f 100644
--- a/server/tests/api/live/index.ts
+++ b/server/tests/api/live/index.ts
@@ -1,4 +1,6 @@
1import './live-constraints' 1import './live-constraints'
2import './live-socket-messages'
2import './live-permanent' 3import './live-permanent'
3import './live-save-replay' 4import './live-save-replay'
5import './live-views'
4import './live' 6import './live'
diff --git a/server/tests/api/live/live-permanent.ts b/server/tests/api/live/live-permanent.ts
index d52e8c7e4..71b7d28a8 100644
--- a/server/tests/api/live/live-permanent.ts
+++ b/server/tests/api/live/live-permanent.ts
@@ -27,7 +27,7 @@ import {
27 27
28const expect = chai.expect 28const expect = chai.expect
29 29
30describe('Permenant live', function () { 30describe('Permanent live', function () {
31 let servers: ServerInfo[] = [] 31 let servers: ServerInfo[] = []
32 let videoUUID: string 32 let videoUUID: string
33 33
@@ -106,7 +106,7 @@ describe('Permenant live', function () {
106 }) 106 })
107 107
108 it('Should stream into this permanent live', async function () { 108 it('Should stream into this permanent live', async function () {
109 this.timeout(60000) 109 this.timeout(120000)
110 110
111 const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, videoUUID) 111 const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, videoUUID)
112 112
diff --git a/server/tests/api/live/live-socket-messages.ts b/server/tests/api/live/live-socket-messages.ts
new file mode 100644
index 000000000..e00909ade
--- /dev/null
+++ b/server/tests/api/live/live-socket-messages.ts
@@ -0,0 +1,196 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import * as chai from 'chai'
5import { getLiveNotificationSocket } from '@shared/extra-utils/socket/socket-io'
6import { VideoPrivacy, VideoState } from '@shared/models'
7import {
8 cleanupTests,
9 createLive,
10 doubleFollow,
11 flushAndRunMultipleServers,
12 getVideoIdFromUUID,
13 sendRTMPStreamInVideo,
14 ServerInfo,
15 setAccessTokensToServers,
16 setDefaultVideoChannel,
17 stopFfmpeg,
18 updateCustomSubConfig,
19 viewVideo,
20 wait,
21 waitJobs,
22 waitUntilLiveEnded,
23 waitUntilLivePublishedOnAllServers
24} from '../../../../shared/extra-utils'
25
26const expect = chai.expect
27
28describe('Test live', function () {
29 let servers: ServerInfo[] = []
30
31 before(async function () {
32 this.timeout(120000)
33
34 servers = await flushAndRunMultipleServers(2)
35
36 // Get the access tokens
37 await setAccessTokensToServers(servers)
38 await setDefaultVideoChannel(servers)
39
40 await updateCustomSubConfig(servers[0].url, servers[0].accessToken, {
41 live: {
42 enabled: true,
43 allowReplay: true,
44 transcoding: {
45 enabled: false
46 }
47 }
48 })
49
50 // Server 1 and server 2 follow each other
51 await doubleFollow(servers[0], servers[1])
52 })
53
54 describe('Live socket messages', function () {
55
56 async function createLiveWrapper () {
57 const liveAttributes = {
58 name: 'live video',
59 channelId: servers[0].videoChannel.id,
60 privacy: VideoPrivacy.PUBLIC
61 }
62
63 const res = await createLive(servers[0].url, servers[0].accessToken, liveAttributes)
64 return res.body.video.uuid
65 }
66
67 it('Should correctly send a message when the live starts and ends', async function () {
68 this.timeout(60000)
69
70 const localStateChanges: VideoState[] = []
71 const remoteStateChanges: VideoState[] = []
72
73 const liveVideoUUID = await createLiveWrapper()
74 await waitJobs(servers)
75
76 {
77 const videoId = await getVideoIdFromUUID(servers[0].url, liveVideoUUID)
78
79 const localSocket = getLiveNotificationSocket(servers[0].url)
80 localSocket.on('state-change', data => localStateChanges.push(data.state))
81 localSocket.emit('subscribe', { videoId })
82 }
83
84 {
85 const videoId = await getVideoIdFromUUID(servers[1].url, liveVideoUUID)
86
87 const remoteSocket = getLiveNotificationSocket(servers[1].url)
88 remoteSocket.on('state-change', data => remoteStateChanges.push(data.state))
89 remoteSocket.emit('subscribe', { videoId })
90 }
91
92 const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoUUID)
93
94 await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID)
95 await waitJobs(servers)
96
97 for (const stateChanges of [ localStateChanges, remoteStateChanges ]) {
98 expect(stateChanges).to.have.length.at.least(1)
99 expect(stateChanges[stateChanges.length - 1]).to.equal(VideoState.PUBLISHED)
100 }
101
102 await stopFfmpeg(command)
103
104 for (const server of servers) {
105 await waitUntilLiveEnded(server.url, server.accessToken, liveVideoUUID)
106 }
107 await waitJobs(servers)
108
109 for (const stateChanges of [ localStateChanges, remoteStateChanges ]) {
110 expect(stateChanges).to.have.length.at.least(2)
111 expect(stateChanges[stateChanges.length - 1]).to.equal(VideoState.LIVE_ENDED)
112 }
113 })
114
115 it('Should correctly send views change notification', async function () {
116 this.timeout(60000)
117
118 let localLastVideoViews = 0
119 let remoteLastVideoViews = 0
120
121 const liveVideoUUID = await createLiveWrapper()
122 await waitJobs(servers)
123
124 {
125 const videoId = await getVideoIdFromUUID(servers[0].url, liveVideoUUID)
126
127 const localSocket = getLiveNotificationSocket(servers[0].url)
128 localSocket.on('views-change', data => { localLastVideoViews = data.views })
129 localSocket.emit('subscribe', { videoId })
130 }
131
132 {
133 const videoId = await getVideoIdFromUUID(servers[1].url, liveVideoUUID)
134
135 const remoteSocket = getLiveNotificationSocket(servers[1].url)
136 remoteSocket.on('views-change', data => { remoteLastVideoViews = data.views })
137 remoteSocket.emit('subscribe', { videoId })
138 }
139
140 const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoUUID)
141
142 await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID)
143 await waitJobs(servers)
144
145 expect(localLastVideoViews).to.equal(0)
146 expect(remoteLastVideoViews).to.equal(0)
147
148 await viewVideo(servers[0].url, liveVideoUUID)
149 await viewVideo(servers[1].url, liveVideoUUID)
150
151 await waitJobs(servers)
152 await wait(5000)
153 await waitJobs(servers)
154
155 expect(localLastVideoViews).to.equal(2)
156 expect(remoteLastVideoViews).to.equal(2)
157
158 await stopFfmpeg(command)
159 })
160
161 it('Should not receive a notification after unsubscribe', async function () {
162 this.timeout(120000)
163
164 const stateChanges: VideoState[] = []
165
166 const liveVideoUUID = await createLiveWrapper()
167 await waitJobs(servers)
168
169 const videoId = await getVideoIdFromUUID(servers[0].url, liveVideoUUID)
170
171 const socket = getLiveNotificationSocket(servers[0].url)
172 socket.on('state-change', data => stateChanges.push(data.state))
173 socket.emit('subscribe', { videoId })
174
175 const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoUUID)
176
177 await waitUntilLivePublishedOnAllServers(servers, liveVideoUUID)
178 await waitJobs(servers)
179
180 // Notifier waits before sending a notification
181 await wait(10000)
182
183 expect(stateChanges).to.have.lengthOf(1)
184 socket.emit('unsubscribe', { videoId })
185
186 await stopFfmpeg(command)
187 await waitJobs(servers)
188
189 expect(stateChanges).to.have.lengthOf(1)
190 })
191 })
192
193 after(async function () {
194 await cleanupTests(servers)
195 })
196})
diff --git a/server/tests/api/live/live-views.ts b/server/tests/api/live/live-views.ts
new file mode 100644
index 000000000..a44d21ffa
--- /dev/null
+++ b/server/tests/api/live/live-views.ts
@@ -0,0 +1,130 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import * as chai from 'chai'
5import { FfmpegCommand } from 'fluent-ffmpeg'
6import { VideoDetails, VideoPrivacy } from '@shared/models'
7import {
8 cleanupTests,
9 createLive,
10 doubleFollow,
11 flushAndRunMultipleServers,
12 getVideo,
13 sendRTMPStreamInVideo,
14 ServerInfo,
15 setAccessTokensToServers,
16 setDefaultVideoChannel,
17 stopFfmpeg,
18 updateCustomSubConfig,
19 viewVideo,
20 wait,
21 waitJobs,
22 waitUntilLivePublishedOnAllServers
23} from '../../../../shared/extra-utils'
24
25const expect = chai.expect
26
27describe('Test live', function () {
28 let servers: ServerInfo[] = []
29
30 before(async function () {
31 this.timeout(120000)
32
33 servers = await flushAndRunMultipleServers(2)
34
35 // Get the access tokens
36 await setAccessTokensToServers(servers)
37 await setDefaultVideoChannel(servers)
38
39 await updateCustomSubConfig(servers[0].url, servers[0].accessToken, {
40 live: {
41 enabled: true,
42 allowReplay: true,
43 transcoding: {
44 enabled: false
45 }
46 }
47 })
48
49 // Server 1 and server 2 follow each other
50 await doubleFollow(servers[0], servers[1])
51 })
52
53 describe('Live views', function () {
54 let liveVideoId: string
55 let command: FfmpegCommand
56
57 async function countViews (expected: number) {
58 for (const server of servers) {
59 const res = await getVideo(server.url, liveVideoId)
60 const video: VideoDetails = res.body
61
62 expect(video.views).to.equal(expected)
63 }
64 }
65
66 before(async function () {
67 this.timeout(30000)
68
69 const liveAttributes = {
70 name: 'live video',
71 channelId: servers[0].videoChannel.id,
72 privacy: VideoPrivacy.PUBLIC
73 }
74
75 const res = await createLive(servers[0].url, servers[0].accessToken, liveAttributes)
76 liveVideoId = res.body.video.uuid
77
78 command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoId)
79 await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
80 await waitJobs(servers)
81 })
82
83 it('Should display no views for a live', async function () {
84 await countViews(0)
85 })
86
87 it('Should view a live twice and display 1 view', async function () {
88 this.timeout(30000)
89
90 await viewVideo(servers[0].url, liveVideoId)
91 await viewVideo(servers[0].url, liveVideoId)
92
93 await wait(7000)
94
95 await waitJobs(servers)
96
97 await countViews(1)
98 })
99
100 it('Should wait and display 0 views', async function () {
101 this.timeout(30000)
102
103 await wait(12000)
104 await waitJobs(servers)
105
106 await countViews(0)
107 })
108
109 it('Should view a live on a remote and on local and display 2 views', async function () {
110 this.timeout(30000)
111
112 await viewVideo(servers[0].url, liveVideoId)
113 await viewVideo(servers[1].url, liveVideoId)
114 await viewVideo(servers[1].url, liveVideoId)
115
116 await wait(7000)
117 await waitJobs(servers)
118
119 await countViews(2)
120 })
121
122 after(async function () {
123 await stopFfmpeg(command)
124 })
125 })
126
127 after(async function () {
128 await cleanupTests(servers)
129 })
130})
diff --git a/server/tests/api/live/live.ts b/server/tests/api/live/live.ts
index 57fb58150..50397924e 100644
--- a/server/tests/api/live/live.ts
+++ b/server/tests/api/live/live.ts
@@ -2,10 +2,8 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { FfmpegCommand } from 'fluent-ffmpeg'
6import { join } from 'path' 5import { join } from 'path'
7import { ffprobePromise, getVideoStreamFromFile } from '@server/helpers/ffprobe-utils' 6import { ffprobePromise, getVideoStreamFromFile } from '@server/helpers/ffprobe-utils'
8import { getLiveNotificationSocket } from '@shared/extra-utils/socket/socket-io'
9import { LiveVideo, LiveVideoCreate, Video, VideoDetails, VideoPrivacy, VideoState, VideoStreamingPlaylistType } from '@shared/models' 7import { LiveVideo, LiveVideoCreate, Video, VideoDetails, VideoPrivacy, VideoState, VideoStreamingPlaylistType } from '@shared/models'
10import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 8import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
11import { 9import {
@@ -22,7 +20,6 @@ import {
22 getMyVideosWithFilter, 20 getMyVideosWithFilter,
23 getPlaylist, 21 getPlaylist,
24 getVideo, 22 getVideo,
25 getVideoIdFromUUID,
26 getVideosList, 23 getVideosList,
27 getVideosWithFilters, 24 getVideosWithFilters,
28 killallServers, 25 killallServers,
@@ -40,11 +37,11 @@ import {
40 updateCustomSubConfig, 37 updateCustomSubConfig,
41 updateLive, 38 updateLive,
42 uploadVideoAndGetId, 39 uploadVideoAndGetId,
43 viewVideo,
44 wait, 40 wait,
45 waitJobs, 41 waitJobs,
46 waitUntilLiveEnded, 42 waitUntilLiveEnded,
47 waitUntilLivePublished, 43 waitUntilLivePublished,
44 waitUntilLivePublishedOnAllServers,
48 waitUntilLiveSegmentGeneration 45 waitUntilLiveSegmentGeneration
49} from '../../../../shared/extra-utils' 46} from '../../../../shared/extra-utils'
50 47
@@ -53,12 +50,6 @@ const expect = chai.expect
53describe('Test live', function () { 50describe('Test live', function () {
54 let servers: ServerInfo[] = [] 51 let servers: ServerInfo[] = []
55 52
56 async function waitUntilLivePublishedOnAllServers (videoId: string) {
57 for (const server of servers) {
58 await waitUntilLivePublished(server.url, server.accessToken, videoId)
59 }
60 }
61
62 before(async function () { 53 before(async function () {
63 this.timeout(120000) 54 this.timeout(120000)
64 55
@@ -247,7 +238,7 @@ describe('Test live', function () {
247 liveVideoId = resLive.body.video.uuid 238 liveVideoId = resLive.body.video.uuid
248 239
249 command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoId) 240 command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoId)
250 await waitUntilLivePublishedOnAllServers(liveVideoId) 241 await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
251 await waitJobs(servers) 242 await waitJobs(servers)
252 }) 243 })
253 244
@@ -461,7 +452,7 @@ describe('Test live', function () {
461 liveVideoId = await createLiveWrapper(false) 452 liveVideoId = await createLiveWrapper(false)
462 453
463 const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoId) 454 const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoId)
464 await waitUntilLivePublishedOnAllServers(liveVideoId) 455 await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
465 await waitJobs(servers) 456 await waitJobs(servers)
466 457
467 await testVideoResolutions(liveVideoId, [ 720 ]) 458 await testVideoResolutions(liveVideoId, [ 720 ])
@@ -477,7 +468,7 @@ describe('Test live', function () {
477 liveVideoId = await createLiveWrapper(false) 468 liveVideoId = await createLiveWrapper(false)
478 469
479 const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoId) 470 const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoId)
480 await waitUntilLivePublishedOnAllServers(liveVideoId) 471 await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
481 await waitJobs(servers) 472 await waitJobs(servers)
482 473
483 await testVideoResolutions(liveVideoId, resolutions) 474 await testVideoResolutions(liveVideoId, resolutions)
@@ -494,7 +485,7 @@ describe('Test live', function () {
494 liveVideoId = await createLiveWrapper(true) 485 liveVideoId = await createLiveWrapper(true)
495 486
496 const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoId, 'video_short2.webm') 487 const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoId, 'video_short2.webm')
497 await waitUntilLivePublishedOnAllServers(liveVideoId) 488 await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
498 await waitJobs(servers) 489 await waitJobs(servers)
499 490
500 await testVideoResolutions(liveVideoId, resolutions) 491 await testVideoResolutions(liveVideoId, resolutions)
@@ -504,7 +495,7 @@ describe('Test live', function () {
504 495
505 await waitJobs(servers) 496 await waitJobs(servers)
506 497
507 await waitUntilLivePublishedOnAllServers(liveVideoId) 498 await waitUntilLivePublishedOnAllServers(servers, liveVideoId)
508 499
509 const bitrateLimits = { 500 const bitrateLimits = {
510 720: 5000 * 1000, // 60FPS 501 720: 5000 * 1000, // 60FPS
@@ -559,216 +550,6 @@ describe('Test live', function () {
559 }) 550 })
560 }) 551 })
561 552
562 describe('Live views', function () {
563 let liveVideoId: string
564 let command: FfmpegCommand
565
566 async function countViews (expected: number) {
567 for (const server of servers) {
568 const res = await getVideo(server.url, liveVideoId)
569 const video: VideoDetails = res.body
570
571 expect(video.views).to.equal(expected)
572 }
573 }
574
575 before(async function () {
576 this.timeout(30000)
577
578 const liveAttributes = {
579 name: 'live video',
580 channelId: servers[0].videoChannel.id,
581 privacy: VideoPrivacy.PUBLIC
582 }
583
584 const res = await createLive(servers[0].url, servers[0].accessToken, liveAttributes)
585 liveVideoId = res.body.video.uuid
586
587 command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoId)
588 await waitUntilLivePublishedOnAllServers(liveVideoId)
589 await waitJobs(servers)
590 })
591
592 it('Should display no views for a live', async function () {
593 await countViews(0)
594 })
595
596 it('Should view a live twice and display 1 view', async function () {
597 this.timeout(30000)
598
599 await viewVideo(servers[0].url, liveVideoId)
600 await viewVideo(servers[0].url, liveVideoId)
601
602 await wait(7000)
603
604 await waitJobs(servers)
605
606 await countViews(1)
607 })
608
609 it('Should wait and display 0 views', async function () {
610 this.timeout(30000)
611
612 await wait(7000)
613 await waitJobs(servers)
614
615 await countViews(0)
616 })
617
618 it('Should view a live on a remote and on local and display 2 views', async function () {
619 this.timeout(30000)
620
621 await viewVideo(servers[0].url, liveVideoId)
622 await viewVideo(servers[1].url, liveVideoId)
623 await viewVideo(servers[1].url, liveVideoId)
624
625 await wait(7000)
626 await waitJobs(servers)
627
628 await countViews(2)
629 })
630
631 after(async function () {
632 await stopFfmpeg(command)
633 })
634 })
635
636 describe('Live socket messages', function () {
637
638 async function createLiveWrapper () {
639 const liveAttributes = {
640 name: 'live video',
641 channelId: servers[0].videoChannel.id,
642 privacy: VideoPrivacy.PUBLIC
643 }
644
645 const res = await createLive(servers[0].url, servers[0].accessToken, liveAttributes)
646 return res.body.video.uuid
647 }
648
649 it('Should correctly send a message when the live starts and ends', async function () {
650 this.timeout(60000)
651
652 const localStateChanges: VideoState[] = []
653 const remoteStateChanges: VideoState[] = []
654
655 const liveVideoUUID = await createLiveWrapper()
656 await waitJobs(servers)
657
658 {
659 const videoId = await getVideoIdFromUUID(servers[0].url, liveVideoUUID)
660
661 const localSocket = getLiveNotificationSocket(servers[0].url)
662 localSocket.on('state-change', data => localStateChanges.push(data.state))
663 localSocket.emit('subscribe', { videoId })
664 }
665
666 {
667 const videoId = await getVideoIdFromUUID(servers[1].url, liveVideoUUID)
668
669 const remoteSocket = getLiveNotificationSocket(servers[1].url)
670 remoteSocket.on('state-change', data => remoteStateChanges.push(data.state))
671 remoteSocket.emit('subscribe', { videoId })
672 }
673
674 const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoUUID)
675
676 await waitUntilLivePublishedOnAllServers(liveVideoUUID)
677 await waitJobs(servers)
678
679 for (const stateChanges of [ localStateChanges, remoteStateChanges ]) {
680 expect(stateChanges).to.have.length.at.least(1)
681 expect(stateChanges[stateChanges.length - 1]).to.equal(VideoState.PUBLISHED)
682 }
683
684 await stopFfmpeg(command)
685
686 for (const server of servers) {
687 await waitUntilLiveEnded(server.url, server.accessToken, liveVideoUUID)
688 }
689 await waitJobs(servers)
690
691 for (const stateChanges of [ localStateChanges, remoteStateChanges ]) {
692 expect(stateChanges).to.have.length.at.least(2)
693 expect(stateChanges[stateChanges.length - 1]).to.equal(VideoState.LIVE_ENDED)
694 }
695 })
696
697 it('Should correctly send views change notification', async function () {
698 this.timeout(60000)
699
700 let localLastVideoViews = 0
701 let remoteLastVideoViews = 0
702
703 const liveVideoUUID = await createLiveWrapper()
704 await waitJobs(servers)
705
706 {
707 const videoId = await getVideoIdFromUUID(servers[0].url, liveVideoUUID)
708
709 const localSocket = getLiveNotificationSocket(servers[0].url)
710 localSocket.on('views-change', data => { localLastVideoViews = data.views })
711 localSocket.emit('subscribe', { videoId })
712 }
713
714 {
715 const videoId = await getVideoIdFromUUID(servers[1].url, liveVideoUUID)
716
717 const remoteSocket = getLiveNotificationSocket(servers[1].url)
718 remoteSocket.on('views-change', data => { remoteLastVideoViews = data.views })
719 remoteSocket.emit('subscribe', { videoId })
720 }
721
722 const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoUUID)
723
724 await waitUntilLivePublishedOnAllServers(liveVideoUUID)
725 await waitJobs(servers)
726
727 expect(localLastVideoViews).to.equal(0)
728 expect(remoteLastVideoViews).to.equal(0)
729
730 await viewVideo(servers[0].url, liveVideoUUID)
731 await viewVideo(servers[1].url, liveVideoUUID)
732
733 await waitJobs(servers)
734 await wait(5000)
735 await waitJobs(servers)
736
737 expect(localLastVideoViews).to.equal(2)
738 expect(remoteLastVideoViews).to.equal(2)
739
740 await stopFfmpeg(command)
741 })
742
743 it('Should not receive a notification after unsubscribe', async function () {
744 this.timeout(60000)
745
746 const stateChanges: VideoState[] = []
747
748 const liveVideoUUID = await createLiveWrapper()
749 await waitJobs(servers)
750
751 const videoId = await getVideoIdFromUUID(servers[0].url, liveVideoUUID)
752
753 const socket = getLiveNotificationSocket(servers[0].url)
754 socket.on('state-change', data => stateChanges.push(data.state))
755 socket.emit('subscribe', { videoId })
756
757 const command = await sendRTMPStreamInVideo(servers[0].url, servers[0].accessToken, liveVideoUUID)
758
759 await waitUntilLivePublishedOnAllServers(liveVideoUUID)
760 await waitJobs(servers)
761
762 expect(stateChanges).to.have.lengthOf(1)
763 socket.emit('unsubscribe', { videoId })
764
765 await stopFfmpeg(command)
766 await waitJobs(servers)
767
768 expect(stateChanges).to.have.lengthOf(1)
769 })
770 })
771
772 describe('After a server restart', function () { 553 describe('After a server restart', function () {
773 let liveVideoId: string 554 let liveVideoId: string
774 let liveVideoReplayId: string 555 let liveVideoReplayId: string
diff --git a/server/tests/api/moderation/blocklist.ts b/server/tests/api/moderation/blocklist.ts
index e8202aff1..793abbcb4 100644
--- a/server/tests/api/moderation/blocklist.ts
+++ b/server/tests/api/moderation/blocklist.ts
@@ -1,46 +1,50 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import * as chai from 'chai'
4import 'mocha' 3import 'mocha'
5import { AccountBlock, ServerBlock, Video, UserNotification, UserNotificationType } from '../../../../shared/index' 4import * as chai from 'chai'
6import { 5import {
6 addAccountToAccountBlocklist,
7 addAccountToServerBlocklist,
8 addServerToAccountBlocklist,
9 addServerToServerBlocklist,
10 addVideoCommentReply,
11 addVideoCommentThread,
7 cleanupTests, 12 cleanupTests,
8 createUser, 13 createUser,
9 deleteVideoComment, 14 deleteVideoComment,
10 doubleFollow, 15 doubleFollow,
16 findCommentId,
11 flushAndRunMultipleServers, 17 flushAndRunMultipleServers,
12 ServerInfo,
13 uploadVideo,
14 userLogin,
15 follow, 18 follow,
16 unfollow
17} from '../../../../shared/extra-utils/index'
18import { setAccessTokensToServers } from '../../../../shared/extra-utils/users/login'
19import { getVideosList, getVideosListWithToken } from '../../../../shared/extra-utils/videos/videos'
20import {
21 addVideoCommentReply,
22 addVideoCommentThread,
23 getVideoCommentThreads,
24 getVideoThreadComments,
25 findCommentId
26} from '../../../../shared/extra-utils/videos/video-comments'
27import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
28import { VideoComment, VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model'
29import {
30 addAccountToAccountBlocklist,
31 addAccountToServerBlocklist,
32 addServerToAccountBlocklist,
33 addServerToServerBlocklist,
34 getAccountBlocklistByAccount, 19 getAccountBlocklistByAccount,
35 getAccountBlocklistByServer, 20 getAccountBlocklistByServer,
36 getServerBlocklistByAccount, 21 getServerBlocklistByAccount,
37 getServerBlocklistByServer, 22 getServerBlocklistByServer,
23 getUserNotifications,
24 getVideoCommentThreads,
25 getVideosList,
26 getVideosListWithToken,
27 getVideoThreadComments,
38 removeAccountFromAccountBlocklist, 28 removeAccountFromAccountBlocklist,
39 removeAccountFromServerBlocklist, 29 removeAccountFromServerBlocklist,
40 removeServerFromAccountBlocklist, 30 removeServerFromAccountBlocklist,
41 removeServerFromServerBlocklist 31 removeServerFromServerBlocklist,
42} from '../../../../shared/extra-utils/users/blocklist' 32 ServerInfo,
43import { getUserNotifications } from '../../../../shared/extra-utils/users/user-notifications' 33 setAccessTokensToServers,
34 unfollow,
35 uploadVideo,
36 userLogin,
37 waitJobs
38} from '@shared/extra-utils'
39import {
40 AccountBlock,
41 ServerBlock,
42 UserNotification,
43 UserNotificationType,
44 Video,
45 VideoComment,
46 VideoCommentThreadTree
47} from '@shared/models'
44 48
45const expect = chai.expect 49const expect = chai.expect
46 50
@@ -211,7 +215,7 @@ describe('Test blocklist', function () {
211 215
212 const threads: VideoComment[] = resThreads.body.data 216 const threads: VideoComment[] = resThreads.body.data
213 expect(threads).to.have.lengthOf(1) 217 expect(threads).to.have.lengthOf(1)
214 expect(threads[0].totalReplies).to.equal(0) 218 expect(threads[0].totalReplies).to.equal(1)
215 219
216 const t = threads.find(t => t.text === 'comment user 1') 220 const t = threads.find(t => t.text === 'comment user 1')
217 expect(t).to.be.undefined 221 expect(t).to.be.undefined
@@ -561,7 +565,7 @@ describe('Test blocklist', function () {
561 threads = threads.filter(t => t.isDeleted === false) 565 threads = threads.filter(t => t.isDeleted === false)
562 566
563 expect(threads).to.have.lengthOf(1) 567 expect(threads).to.have.lengthOf(1)
564 expect(threads[0].totalReplies).to.equal(0) 568 expect(threads[0].totalReplies).to.equal(1)
565 569
566 const t = threads.find(t => t.text === 'comment user 1') 570 const t = threads.find(t => t.text === 'comment user 1')
567 expect(t).to.be.undefined 571 expect(t).to.be.undefined
diff --git a/server/tests/api/notifications/comments-notifications.ts b/server/tests/api/notifications/comments-notifications.ts
index 5e4ab0d6c..d2badf237 100644
--- a/server/tests/api/notifications/comments-notifications.ts
+++ b/server/tests/api/notifications/comments-notifications.ts
@@ -2,20 +2,25 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { cleanupTests, getVideoCommentThreads, getVideoThreadComments, updateMyUser } from '../../../../shared/extra-utils'
6import { ServerInfo, uploadVideo } from '../../../../shared/extra-utils/index'
7import { MockSmtpServer } from '../../../../shared/extra-utils/miscs/email'
8import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
9import { addAccountToAccountBlocklist, removeAccountFromAccountBlocklist } from '../../../../shared/extra-utils/users/blocklist'
10import { 5import {
6 addAccountToAccountBlocklist,
7 addVideoCommentReply,
8 addVideoCommentThread,
11 checkCommentMention, 9 checkCommentMention,
12 CheckerBaseParams, 10 CheckerBaseParams,
13 checkNewCommentOnMyVideo, 11 checkNewCommentOnMyVideo,
14 prepareNotificationsTest 12 cleanupTests,
15} from '../../../../shared/extra-utils/users/user-notifications' 13 getVideoCommentThreads,
16import { addVideoCommentReply, addVideoCommentThread } from '../../../../shared/extra-utils/videos/video-comments' 14 getVideoThreadComments,
17import { UserNotification } from '../../../../shared/models/users' 15 MockSmtpServer,
18import { VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model' 16 prepareNotificationsTest,
17 removeAccountFromAccountBlocklist,
18 ServerInfo,
19 updateMyUser,
20 uploadVideo,
21 waitJobs
22} from '@shared/extra-utils'
23import { UserNotification, VideoCommentThreadTree } from '@shared/models'
19 24
20const expect = chai.expect 25const expect = chai.expect
21 26
diff --git a/server/tests/api/notifications/moderation-notifications.ts b/server/tests/api/notifications/moderation-notifications.ts
index 4ce6675b6..3425480ae 100644
--- a/server/tests/api/notifications/moderation-notifications.ts
+++ b/server/tests/api/notifications/moderation-notifications.ts
@@ -1,7 +1,7 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import 'mocha' 3import 'mocha'
4import { v4 as uuidv4 } from 'uuid' 4import { buildUUID } from '@server/helpers/uuid'
5import { AbuseState } from '@shared/models' 5import { AbuseState } from '@shared/models'
6import { 6import {
7 addAbuseMessage, 7 addAbuseMessage,
@@ -85,7 +85,7 @@ describe('Test moderation notifications', function () {
85 it('Should send a notification to moderators on local video abuse', async function () { 85 it('Should send a notification to moderators on local video abuse', async function () {
86 this.timeout(20000) 86 this.timeout(20000)
87 87
88 const name = 'video for abuse ' + uuidv4() 88 const name = 'video for abuse ' + buildUUID()
89 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name }) 89 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
90 const video = resVideo.body.video 90 const video = resVideo.body.video
91 91
@@ -98,7 +98,7 @@ describe('Test moderation notifications', function () {
98 it('Should send a notification to moderators on remote video abuse', async function () { 98 it('Should send a notification to moderators on remote video abuse', async function () {
99 this.timeout(20000) 99 this.timeout(20000)
100 100
101 const name = 'video for abuse ' + uuidv4() 101 const name = 'video for abuse ' + buildUUID()
102 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name }) 102 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
103 const video = resVideo.body.video 103 const video = resVideo.body.video
104 104
@@ -114,10 +114,10 @@ describe('Test moderation notifications', function () {
114 it('Should send a notification to moderators on local comment abuse', async function () { 114 it('Should send a notification to moderators on local comment abuse', async function () {
115 this.timeout(20000) 115 this.timeout(20000)
116 116
117 const name = 'video for abuse ' + uuidv4() 117 const name = 'video for abuse ' + buildUUID()
118 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name }) 118 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
119 const video = resVideo.body.video 119 const video = resVideo.body.video
120 const resComment = await addVideoCommentThread(servers[0].url, userAccessToken, video.id, 'comment abuse ' + uuidv4()) 120 const resComment = await addVideoCommentThread(servers[0].url, userAccessToken, video.id, 'comment abuse ' + buildUUID())
121 const comment = resComment.body.comment 121 const comment = resComment.body.comment
122 122
123 await waitJobs(servers) 123 await waitJobs(servers)
@@ -131,10 +131,10 @@ describe('Test moderation notifications', function () {
131 it('Should send a notification to moderators on remote comment abuse', async function () { 131 it('Should send a notification to moderators on remote comment abuse', async function () {
132 this.timeout(20000) 132 this.timeout(20000)
133 133
134 const name = 'video for abuse ' + uuidv4() 134 const name = 'video for abuse ' + buildUUID()
135 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name }) 135 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
136 const video = resVideo.body.video 136 const video = resVideo.body.video
137 await addVideoCommentThread(servers[0].url, userAccessToken, video.id, 'comment abuse ' + uuidv4()) 137 await addVideoCommentThread(servers[0].url, userAccessToken, video.id, 'comment abuse ' + buildUUID())
138 138
139 await waitJobs(servers) 139 await waitJobs(servers)
140 140
@@ -188,7 +188,7 @@ describe('Test moderation notifications', function () {
188 token: userAccessToken 188 token: userAccessToken
189 } 189 }
190 190
191 const name = 'abuse ' + uuidv4() 191 const name = 'abuse ' + buildUUID()
192 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name }) 192 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
193 const video = resVideo.body.video 193 const video = resVideo.body.video
194 194
@@ -236,7 +236,7 @@ describe('Test moderation notifications', function () {
236 token: servers[0].accessToken 236 token: servers[0].accessToken
237 } 237 }
238 238
239 const name = 'abuse ' + uuidv4() 239 const name = 'abuse ' + buildUUID()
240 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name }) 240 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
241 const video = resVideo.body.video 241 const video = resVideo.body.video
242 242
@@ -307,7 +307,7 @@ describe('Test moderation notifications', function () {
307 it('Should send a notification to video owner on blacklist', async function () { 307 it('Should send a notification to video owner on blacklist', async function () {
308 this.timeout(10000) 308 this.timeout(10000)
309 309
310 const name = 'video for abuse ' + uuidv4() 310 const name = 'video for abuse ' + buildUUID()
311 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name }) 311 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
312 const uuid = resVideo.body.video.uuid 312 const uuid = resVideo.body.video.uuid
313 313
@@ -320,7 +320,7 @@ describe('Test moderation notifications', function () {
320 it('Should send a notification to video owner on unblacklist', async function () { 320 it('Should send a notification to video owner on unblacklist', async function () {
321 this.timeout(10000) 321 this.timeout(10000)
322 322
323 const name = 'video for abuse ' + uuidv4() 323 const name = 'video for abuse ' + buildUUID()
324 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name }) 324 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name })
325 const uuid = resVideo.body.video.uuid 325 const uuid = resVideo.body.video.uuid
326 326
@@ -507,7 +507,7 @@ describe('Test moderation notifications', function () {
507 it('Should send notification to moderators on new video with auto-blacklist', async function () { 507 it('Should send notification to moderators on new video with auto-blacklist', async function () {
508 this.timeout(40000) 508 this.timeout(40000)
509 509
510 videoName = 'video with auto-blacklist ' + uuidv4() 510 videoName = 'video with auto-blacklist ' + buildUUID()
511 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName }) 511 const resVideo = await uploadVideo(servers[0].url, userAccessToken, { name: videoName })
512 videoUUID = resVideo.body.video.uuid 512 videoUUID = resVideo.body.video.uuid
513 513
@@ -553,7 +553,7 @@ describe('Test moderation notifications', function () {
553 553
554 const updateAt = new Date(new Date().getTime() + 1000000) 554 const updateAt = new Date(new Date().getTime() + 1000000)
555 555
556 const name = 'video with auto-blacklist and future schedule ' + uuidv4() 556 const name = 'video with auto-blacklist and future schedule ' + buildUUID()
557 557
558 const data = { 558 const data = {
559 name, 559 name,
@@ -586,7 +586,7 @@ describe('Test moderation notifications', function () {
586 // In 2 seconds 586 // In 2 seconds
587 const updateAt = new Date(new Date().getTime() + 2000) 587 const updateAt = new Date(new Date().getTime() + 2000)
588 588
589 const name = 'video with schedule done and still auto-blacklisted ' + uuidv4() 589 const name = 'video with schedule done and still auto-blacklisted ' + buildUUID()
590 590
591 const data = { 591 const data = {
592 name, 592 name,
@@ -609,7 +609,7 @@ describe('Test moderation notifications', function () {
609 it('Should not send a notification to moderators on new video without auto-blacklist', async function () { 609 it('Should not send a notification to moderators on new video without auto-blacklist', async function () {
610 this.timeout(60000) 610 this.timeout(60000)
611 611
612 const name = 'video without auto-blacklist ' + uuidv4() 612 const name = 'video without auto-blacklist ' + buildUUID()
613 613
614 // admin with blacklist right will not be auto-blacklisted 614 // admin with blacklist right will not be auto-blacklisted
615 const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name }) 615 const resVideo = await uploadVideo(servers[0].url, servers[0].accessToken, { name })
diff --git a/server/tests/api/notifications/user-notifications.ts b/server/tests/api/notifications/user-notifications.ts
index 7e88d979b..e981c1718 100644
--- a/server/tests/api/notifications/user-notifications.ts
+++ b/server/tests/api/notifications/user-notifications.ts
@@ -2,7 +2,7 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { v4 as uuidv4 } from 'uuid' 5import { buildUUID } from '@server/helpers/uuid'
6import { 6import {
7 cleanupTests, 7 cleanupTests,
8 updateMyUser, 8 updateMyUser,
@@ -207,7 +207,7 @@ describe('Test user notifications', function () {
207 it('Should send a new video notification after a video import', async function () { 207 it('Should send a new video notification after a video import', async function () {
208 this.timeout(100000) 208 this.timeout(100000)
209 209
210 const name = 'video import ' + uuidv4() 210 const name = 'video import ' + buildUUID()
211 211
212 const attributes = { 212 const attributes = {
213 name, 213 name,
@@ -278,7 +278,7 @@ describe('Test user notifications', function () {
278 it('Should send a notification when an imported video is transcoded', async function () { 278 it('Should send a notification when an imported video is transcoded', async function () {
279 this.timeout(50000) 279 this.timeout(50000)
280 280
281 const name = 'video import ' + uuidv4() 281 const name = 'video import ' + buildUUID()
282 282
283 const attributes = { 283 const attributes = {
284 name, 284 name,
@@ -347,7 +347,7 @@ describe('Test user notifications', function () {
347 it('Should send a notification when the video import failed', async function () { 347 it('Should send a notification when the video import failed', async function () {
348 this.timeout(70000) 348 this.timeout(70000)
349 349
350 const name = 'video import ' + uuidv4() 350 const name = 'video import ' + buildUUID()
351 351
352 const attributes = { 352 const attributes = {
353 name, 353 name,
@@ -365,7 +365,7 @@ describe('Test user notifications', function () {
365 it('Should send a notification when the video import succeeded', async function () { 365 it('Should send a notification when the video import succeeded', async function () {
366 this.timeout(70000) 366 this.timeout(70000)
367 367
368 const name = 'video import ' + uuidv4() 368 const name = 'video import ' + buildUUID()
369 369
370 const attributes = { 370 const attributes = {
371 name, 371 name,
diff --git a/server/tests/api/search/index.ts b/server/tests/api/search/index.ts
index 232c1f2a4..a976d210d 100644
--- a/server/tests/api/search/index.ts
+++ b/server/tests/api/search/index.ts
@@ -1,5 +1,7 @@
1import './search-activitypub-video-playlists'
1import './search-activitypub-video-channels' 2import './search-activitypub-video-channels'
2import './search-activitypub-videos' 3import './search-activitypub-videos'
4import './search-channels'
3import './search-index' 5import './search-index'
6import './search-playlists'
4import './search-videos' 7import './search-videos'
5import './search-channels'
diff --git a/server/tests/api/search/search-activitypub-video-channels.ts b/server/tests/api/search/search-activitypub-video-channels.ts
index d7e3ed5be..e83eb7171 100644
--- a/server/tests/api/search/search-activitypub-video-channels.ts
+++ b/server/tests/api/search/search-activitypub-video-channels.ts
@@ -106,9 +106,25 @@ describe('Test ActivityPub video channels search', function () {
106 } 106 }
107 }) 107 })
108 108
109 it('Should search a local video channel with an alternative URL', async function () {
110 const search = 'http://localhost:' + servers[0].port + '/c/channel1_server1'
111
112 for (const token of [ undefined, servers[0].accessToken ]) {
113 const res = await searchVideoChannel(servers[0].url, search, token)
114
115 expect(res.body.total).to.equal(1)
116 expect(res.body.data).to.be.an('array')
117 expect(res.body.data).to.have.lengthOf(1)
118 expect(res.body.data[0].name).to.equal('channel1_server1')
119 expect(res.body.data[0].displayName).to.equal('Channel 1 server 1')
120 }
121 })
122
109 it('Should search a remote video channel with URL or handle', async function () { 123 it('Should search a remote video channel with URL or handle', async function () {
110 const searches = [ 124 const searches = [
111 'http://localhost:' + servers[1].port + '/video-channels/channel1_server2', 125 'http://localhost:' + servers[1].port + '/video-channels/channel1_server2',
126 'http://localhost:' + servers[1].port + '/c/channel1_server2',
127 'http://localhost:' + servers[1].port + '/c/channel1_server2/videos',
112 'channel1_server2@localhost:' + servers[1].port 128 'channel1_server2@localhost:' + servers[1].port
113 ] 129 ]
114 130
diff --git a/server/tests/api/search/search-activitypub-video-playlists.ts b/server/tests/api/search/search-activitypub-video-playlists.ts
new file mode 100644
index 000000000..4c08e9548
--- /dev/null
+++ b/server/tests/api/search/search-activitypub-video-playlists.ts
@@ -0,0 +1,212 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import * as chai from 'chai'
5import {
6 addVideoInPlaylist,
7 cleanupTests,
8 createVideoPlaylist,
9 deleteVideoPlaylist,
10 flushAndRunMultipleServers,
11 getVideoPlaylistsList,
12 searchVideoPlaylists,
13 ServerInfo,
14 setAccessTokensToServers,
15 setDefaultVideoChannel,
16 uploadVideoAndGetId,
17 wait
18} from '../../../../shared/extra-utils'
19import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
20import { VideoPlaylist, VideoPlaylistPrivacy } from '../../../../shared/models/videos'
21
22const expect = chai.expect
23
24describe('Test ActivityPub playlists search', function () {
25 let servers: ServerInfo[]
26 let playlistServer1UUID: string
27 let playlistServer2UUID: string
28 let video2Server2: string
29
30 before(async function () {
31 this.timeout(120000)
32
33 servers = await flushAndRunMultipleServers(2)
34
35 await setAccessTokensToServers(servers)
36 await setDefaultVideoChannel(servers)
37
38 {
39 const video1 = (await uploadVideoAndGetId({ server: servers[0], videoName: 'video 1' })).uuid
40 const video2 = (await uploadVideoAndGetId({ server: servers[0], videoName: 'video 2' })).uuid
41
42 const attributes = {
43 displayName: 'playlist 1 on server 1',
44 privacy: VideoPlaylistPrivacy.PUBLIC,
45 videoChannelId: servers[0].videoChannel.id
46 }
47 const res = await createVideoPlaylist({ url: servers[0].url, token: servers[0].accessToken, playlistAttrs: attributes })
48 playlistServer1UUID = res.body.videoPlaylist.uuid
49
50 for (const videoId of [ video1, video2 ]) {
51 await addVideoInPlaylist({
52 url: servers[0].url,
53 token: servers[0].accessToken,
54 playlistId: playlistServer1UUID,
55 elementAttrs: { videoId }
56 })
57 }
58 }
59
60 {
61 const videoId = (await uploadVideoAndGetId({ server: servers[1], videoName: 'video 1' })).uuid
62 video2Server2 = (await uploadVideoAndGetId({ server: servers[1], videoName: 'video 2' })).uuid
63
64 const attributes = {
65 displayName: 'playlist 1 on server 2',
66 privacy: VideoPlaylistPrivacy.PUBLIC,
67 videoChannelId: servers[1].videoChannel.id
68 }
69 const res = await createVideoPlaylist({ url: servers[1].url, token: servers[1].accessToken, playlistAttrs: attributes })
70 playlistServer2UUID = res.body.videoPlaylist.uuid
71
72 await addVideoInPlaylist({
73 url: servers[1].url,
74 token: servers[1].accessToken,
75 playlistId: playlistServer2UUID,
76 elementAttrs: { videoId }
77 })
78 }
79
80 await waitJobs(servers)
81 })
82
83 it('Should not find a remote playlist', async function () {
84 {
85 const search = 'http://localhost:' + servers[1].port + '/video-playlists/43'
86 const res = await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
87
88 expect(res.body.total).to.equal(0)
89 expect(res.body.data).to.be.an('array')
90 expect(res.body.data).to.have.lengthOf(0)
91 }
92
93 {
94 // Without token
95 const search = 'http://localhost:' + servers[1].port + '/video-playlists/' + playlistServer2UUID
96 const res = await searchVideoPlaylists(servers[0].url, search)
97
98 expect(res.body.total).to.equal(0)
99 expect(res.body.data).to.be.an('array')
100 expect(res.body.data).to.have.lengthOf(0)
101 }
102 })
103
104 it('Should search a local playlist', async function () {
105 const search = 'http://localhost:' + servers[0].port + '/video-playlists/' + playlistServer1UUID
106 const res = await searchVideoPlaylists(servers[0].url, search)
107
108 expect(res.body.total).to.equal(1)
109 expect(res.body.data).to.be.an('array')
110 expect(res.body.data).to.have.lengthOf(1)
111 expect(res.body.data[0].displayName).to.equal('playlist 1 on server 1')
112 expect(res.body.data[0].videosLength).to.equal(2)
113 })
114
115 it('Should search a local playlist with an alternative URL', async function () {
116 const searches = [
117 'http://localhost:' + servers[0].port + '/videos/watch/playlist/' + playlistServer1UUID,
118 'http://localhost:' + servers[0].port + '/w/p/' + playlistServer1UUID
119 ]
120
121 for (const search of searches) {
122 for (const token of [ undefined, servers[0].accessToken ]) {
123 const res = await searchVideoPlaylists(servers[0].url, search, token)
124
125 expect(res.body.total).to.equal(1)
126 expect(res.body.data).to.be.an('array')
127 expect(res.body.data).to.have.lengthOf(1)
128 expect(res.body.data[0].displayName).to.equal('playlist 1 on server 1')
129 expect(res.body.data[0].videosLength).to.equal(2)
130 }
131 }
132 })
133
134 it('Should search a remote playlist', async function () {
135 const searches = [
136 'http://localhost:' + servers[1].port + '/video-playlists/' + playlistServer2UUID,
137 'http://localhost:' + servers[1].port + '/videos/watch/playlist/' + playlistServer2UUID,
138 'http://localhost:' + servers[1].port + '/w/p/' + playlistServer2UUID
139 ]
140
141 for (const search of searches) {
142 const res = await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
143
144 expect(res.body.total).to.equal(1)
145 expect(res.body.data).to.be.an('array')
146 expect(res.body.data).to.have.lengthOf(1)
147 expect(res.body.data[0].displayName).to.equal('playlist 1 on server 2')
148 expect(res.body.data[0].videosLength).to.equal(1)
149 }
150 })
151
152 it('Should not list this remote playlist', async function () {
153 const res = await getVideoPlaylistsList(servers[0].url, 0, 10)
154 expect(res.body.total).to.equal(1)
155 expect(res.body.data).to.have.lengthOf(1)
156 expect(res.body.data[0].displayName).to.equal('playlist 1 on server 1')
157 })
158
159 it('Should update the playlist of server 2, and refresh it on server 1', async function () {
160 this.timeout(60000)
161
162 await addVideoInPlaylist({
163 url: servers[1].url,
164 token: servers[1].accessToken,
165 playlistId: playlistServer2UUID,
166 elementAttrs: { videoId: video2Server2 }
167 })
168
169 await waitJobs(servers)
170 // Expire playlist
171 await wait(10000)
172
173 // Will run refresh async
174 const search = 'http://localhost:' + servers[1].port + '/video-playlists/' + playlistServer2UUID
175 await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
176
177 // Wait refresh
178 await wait(5000)
179
180 const res = await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
181 expect(res.body.total).to.equal(1)
182 expect(res.body.data).to.have.lengthOf(1)
183
184 const playlist: VideoPlaylist = res.body.data[0]
185 expect(playlist.videosLength).to.equal(2)
186 })
187
188 it('Should delete playlist of server 2, and delete it on server 1', async function () {
189 this.timeout(60000)
190
191 await deleteVideoPlaylist(servers[1].url, servers[1].accessToken, playlistServer2UUID)
192
193 await waitJobs(servers)
194 // Expiration
195 await wait(10000)
196
197 // Will run refresh async
198 const search = 'http://localhost:' + servers[1].port + '/video-playlists/' + playlistServer2UUID
199 await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
200
201 // Wait refresh
202 await wait(5000)
203
204 const res = await searchVideoPlaylists(servers[0].url, search, servers[0].accessToken)
205 expect(res.body.total).to.equal(0)
206 expect(res.body.data).to.have.lengthOf(0)
207 })
208
209 after(async function () {
210 await cleanupTests(servers)
211 })
212})
diff --git a/server/tests/api/search/search-activitypub-videos.ts b/server/tests/api/search/search-activitypub-videos.ts
index c62dfca0d..e9b4978da 100644
--- a/server/tests/api/search/search-activitypub-videos.ts
+++ b/server/tests/api/search/search-activitypub-videos.ts
@@ -77,14 +77,33 @@ describe('Test ActivityPub videos search', function () {
77 expect(res.body.data[0].name).to.equal('video 1 on server 1') 77 expect(res.body.data[0].name).to.equal('video 1 on server 1')
78 }) 78 })
79 79
80 it('Should search a local video with an alternative URL', async function () {
81 const search = 'http://localhost:' + servers[0].port + '/w/' + videoServer1UUID
82 const res1 = await searchVideo(servers[0].url, search)
83 const res2 = await searchVideoWithToken(servers[0].url, search, servers[0].accessToken)
84
85 for (const res of [ res1, res2 ]) {
86 expect(res.body.total).to.equal(1)
87 expect(res.body.data).to.be.an('array')
88 expect(res.body.data).to.have.lengthOf(1)
89 expect(res.body.data[0].name).to.equal('video 1 on server 1')
90 }
91 })
92
80 it('Should search a remote video', async function () { 93 it('Should search a remote video', async function () {
81 const search = 'http://localhost:' + servers[1].port + '/videos/watch/' + videoServer2UUID 94 const searches = [
82 const res = await searchVideoWithToken(servers[0].url, search, servers[0].accessToken) 95 'http://localhost:' + servers[1].port + '/w/' + videoServer2UUID,
96 'http://localhost:' + servers[1].port + '/videos/watch/' + videoServer2UUID
97 ]
83 98
84 expect(res.body.total).to.equal(1) 99 for (const search of searches) {
85 expect(res.body.data).to.be.an('array') 100 const res = await searchVideoWithToken(servers[0].url, search, servers[0].accessToken)
86 expect(res.body.data).to.have.lengthOf(1) 101
87 expect(res.body.data[0].name).to.equal('video 1 on server 2') 102 expect(res.body.total).to.equal(1)
103 expect(res.body.data).to.be.an('array')
104 expect(res.body.data).to.have.lengthOf(1)
105 expect(res.body.data[0].name).to.equal('video 1 on server 2')
106 }
88 }) 107 })
89 108
90 it('Should not list this remote video', async function () { 109 it('Should not list this remote video', async function () {
@@ -95,7 +114,7 @@ describe('Test ActivityPub videos search', function () {
95 }) 114 })
96 115
97 it('Should update video of server 2, and refresh it on server 1', async function () { 116 it('Should update video of server 2, and refresh it on server 1', async function () {
98 this.timeout(60000) 117 this.timeout(120000)
99 118
100 const channelAttributes = { 119 const channelAttributes = {
101 name: 'super_channel', 120 name: 'super_channel',
@@ -134,7 +153,7 @@ describe('Test ActivityPub videos search', function () {
134 }) 153 })
135 154
136 it('Should delete video of server 2, and delete it on server 1', async function () { 155 it('Should delete video of server 2, and delete it on server 1', async function () {
137 this.timeout(60000) 156 this.timeout(120000)
138 157
139 await removeVideo(servers[1].url, servers[1].accessToken, videoServer2UUID) 158 await removeVideo(servers[1].url, servers[1].accessToken, videoServer2UUID)
140 159
diff --git a/server/tests/api/search/search-index.ts b/server/tests/api/search/search-index.ts
index 849a8a893..00f79232a 100644
--- a/server/tests/api/search/search-index.ts
+++ b/server/tests/api/search/search-index.ts
@@ -2,19 +2,21 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { advancedVideoChannelSearch, searchVideoChannel } from '@shared/extra-utils/search/video-channels'
6import { Video, VideoChannel, VideoPlaylist, VideoPlaylistPrivacy, VideoPlaylistType, VideosSearchQuery } from '@shared/models'
5import { 7import {
8 advancedVideoPlaylistSearch,
9 advancedVideosSearch,
6 cleanupTests, 10 cleanupTests,
7 flushAndRunServer, 11 flushAndRunServer,
12 immutableAssign,
8 searchVideo, 13 searchVideo,
14 searchVideoPlaylists,
9 ServerInfo, 15 ServerInfo,
10 setAccessTokensToServers, 16 setAccessTokensToServers,
11 updateCustomSubConfig, 17 updateCustomSubConfig,
12 uploadVideo, 18 uploadVideo
13 advancedVideosSearch,
14 immutableAssign
15} from '../../../../shared/extra-utils' 19} from '../../../../shared/extra-utils'
16import { searchVideoChannel, advancedVideoChannelSearch } from '@shared/extra-utils/search/video-channels'
17import { VideosSearchQuery, Video, VideoChannel } from '@shared/models'
18 20
19const expect = chai.expect 21const expect = chai.expect
20 22
@@ -277,6 +279,56 @@ describe('Test videos search', function () {
277 }) 279 })
278 }) 280 })
279 281
282 describe('Playlists search', async function () {
283
284 it('Should make a simple search and not have results', async function () {
285 const res = await searchVideoPlaylists(server.url, 'a'.repeat(500))
286
287 expect(res.body.total).to.equal(0)
288 expect(res.body.data).to.have.lengthOf(0)
289 })
290
291 it('Should make a search and have results', async function () {
292 const res = await advancedVideoPlaylistSearch(server.url, { search: 'E2E playlist', sort: '-match' })
293
294 expect(res.body.total).to.be.greaterThan(0)
295 expect(res.body.data).to.have.length.greaterThan(0)
296
297 const videoPlaylist: VideoPlaylist = res.body.data[0]
298
299 expect(videoPlaylist.url).to.equal('https://peertube2.cpy.re/videos/watch/playlist/73804a40-da9a-40c2-b1eb-2c6d9eec8f0a')
300 expect(videoPlaylist.thumbnailUrl).to.exist
301 expect(videoPlaylist.embedUrl).to.equal('https://peertube2.cpy.re/video-playlists/embed/73804a40-da9a-40c2-b1eb-2c6d9eec8f0a')
302
303 expect(videoPlaylist.type.id).to.equal(VideoPlaylistType.REGULAR)
304 expect(videoPlaylist.privacy.id).to.equal(VideoPlaylistPrivacy.PUBLIC)
305 expect(videoPlaylist.videosLength).to.exist
306
307 expect(videoPlaylist.createdAt).to.exist
308 expect(videoPlaylist.updatedAt).to.exist
309
310 expect(videoPlaylist.uuid).to.equal('73804a40-da9a-40c2-b1eb-2c6d9eec8f0a')
311 expect(videoPlaylist.displayName).to.exist
312
313 expect(videoPlaylist.ownerAccount.url).to.equal('https://peertube2.cpy.re/accounts/chocobozzz')
314 expect(videoPlaylist.ownerAccount.name).to.equal('chocobozzz')
315 expect(videoPlaylist.ownerAccount.host).to.equal('peertube2.cpy.re')
316 expect(videoPlaylist.ownerAccount.avatar).to.exist
317
318 expect(videoPlaylist.videoChannel.url).to.equal('https://peertube2.cpy.re/video-channels/chocobozzz_channel')
319 expect(videoPlaylist.videoChannel.name).to.equal('chocobozzz_channel')
320 expect(videoPlaylist.videoChannel.host).to.equal('peertube2.cpy.re')
321 expect(videoPlaylist.videoChannel.avatar).to.exist
322 })
323
324 it('Should have a correct pagination', async function () {
325 const res = await advancedVideoChannelSearch(server.url, { search: 'root', start: 0, count: 2 })
326
327 expect(res.body.total).to.be.greaterThan(2)
328 expect(res.body.data).to.have.lengthOf(2)
329 })
330 })
331
280 after(async function () { 332 after(async function () {
281 await cleanupTests([ server ]) 333 await cleanupTests([ server ])
282 }) 334 })
diff --git a/server/tests/api/search/search-playlists.ts b/server/tests/api/search/search-playlists.ts
new file mode 100644
index 000000000..ab17d55e9
--- /dev/null
+++ b/server/tests/api/search/search-playlists.ts
@@ -0,0 +1,128 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import * as chai from 'chai'
5import { VideoPlaylist, VideoPlaylistPrivacy } from '@shared/models'
6import {
7 addVideoInPlaylist,
8 advancedVideoPlaylistSearch,
9 cleanupTests,
10 createVideoPlaylist,
11 flushAndRunServer,
12 searchVideoPlaylists,
13 ServerInfo,
14 setAccessTokensToServers,
15 setDefaultVideoChannel,
16 uploadVideoAndGetId
17} from '../../../../shared/extra-utils'
18
19const expect = chai.expect
20
21describe('Test playlists search', function () {
22 let server: ServerInfo = null
23
24 before(async function () {
25 this.timeout(30000)
26
27 server = await flushAndRunServer(1)
28
29 await setAccessTokensToServers([ server ])
30 await setDefaultVideoChannel([ server ])
31
32 const videoId = (await uploadVideoAndGetId({ server: server, videoName: 'video' })).uuid
33
34 {
35 const attributes = {
36 displayName: 'Dr. Kenzo Tenma hospital videos',
37 privacy: VideoPlaylistPrivacy.PUBLIC,
38 videoChannelId: server.videoChannel.id
39 }
40 const res = await createVideoPlaylist({ url: server.url, token: server.accessToken, playlistAttrs: attributes })
41
42 await addVideoInPlaylist({
43 url: server.url,
44 token: server.accessToken,
45 playlistId: res.body.videoPlaylist.id,
46 elementAttrs: { videoId }
47 })
48 }
49
50 {
51 const attributes = {
52 displayName: 'Johan & Anna Libert musics',
53 privacy: VideoPlaylistPrivacy.PUBLIC,
54 videoChannelId: server.videoChannel.id
55 }
56 const res = await createVideoPlaylist({ url: server.url, token: server.accessToken, playlistAttrs: attributes })
57
58 await addVideoInPlaylist({
59 url: server.url,
60 token: server.accessToken,
61 playlistId: res.body.videoPlaylist.id,
62 elementAttrs: { videoId }
63 })
64 }
65
66 {
67 const attributes = {
68 displayName: 'Inspector Lunge playlist',
69 privacy: VideoPlaylistPrivacy.PUBLIC,
70 videoChannelId: server.videoChannel.id
71 }
72 await createVideoPlaylist({ url: server.url, token: server.accessToken, playlistAttrs: attributes })
73 }
74 })
75
76 it('Should make a simple search and not have results', async function () {
77 const res = await searchVideoPlaylists(server.url, 'abc')
78
79 expect(res.body.total).to.equal(0)
80 expect(res.body.data).to.have.lengthOf(0)
81 })
82
83 it('Should make a search and have results', async function () {
84 {
85 const search = {
86 search: 'tenma',
87 start: 0,
88 count: 1
89 }
90 const res = await advancedVideoPlaylistSearch(server.url, search)
91 expect(res.body.total).to.equal(1)
92 expect(res.body.data).to.have.lengthOf(1)
93
94 const playlist: VideoPlaylist = res.body.data[0]
95 expect(playlist.displayName).to.equal('Dr. Kenzo Tenma hospital videos')
96 expect(playlist.url).to.equal(server.url + '/video-playlists/' + playlist.uuid)
97 }
98
99 {
100 const search = {
101 search: 'Anna Livert',
102 start: 0,
103 count: 1
104 }
105 const res = await advancedVideoPlaylistSearch(server.url, search)
106 expect(res.body.total).to.equal(1)
107 expect(res.body.data).to.have.lengthOf(1)
108
109 const playlist: VideoPlaylist = res.body.data[0]
110 expect(playlist.displayName).to.equal('Johan & Anna Libert musics')
111 }
112 })
113
114 it('Should not display playlists without videos', async function () {
115 const search = {
116 search: 'Lunge',
117 start: 0,
118 count: 1
119 }
120 const res = await advancedVideoPlaylistSearch(server.url, search)
121 expect(res.body.total).to.equal(0)
122 expect(res.body.data).to.have.lengthOf(0)
123 })
124
125 after(async function () {
126 await cleanupTests([ server ])
127 })
128})
diff --git a/server/tests/api/server/bulk.ts b/server/tests/api/server/bulk.ts
index 51ba0e7af..80fa7fce6 100644
--- a/server/tests/api/server/bulk.ts
+++ b/server/tests/api/server/bulk.ts
@@ -2,12 +2,14 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { VideoComment } from '@shared/models/videos/video-comment.model' 5import { Video, VideoComment } from '@shared/models'
6import { 6import {
7 addVideoCommentReply,
7 addVideoCommentThread, 8 addVideoCommentThread,
8 bulkRemoveCommentsOf, 9 bulkRemoveCommentsOf,
9 cleanupTests, 10 cleanupTests,
10 createUser, 11 createUser,
12 doubleFollow,
11 flushAndRunMultipleServers, 13 flushAndRunMultipleServers,
12 getVideoCommentThreads, 14 getVideoCommentThreads,
13 getVideosList, 15 getVideosList,
@@ -15,11 +17,8 @@ import {
15 setAccessTokensToServers, 17 setAccessTokensToServers,
16 uploadVideo, 18 uploadVideo,
17 userLogin, 19 userLogin,
18 waitJobs, 20 waitJobs
19 addVideoCommentReply
20} from '../../../../shared/extra-utils/index' 21} from '../../../../shared/extra-utils/index'
21import { doubleFollow } from '../../../../shared/extra-utils/server/follows'
22import { Video } from '@shared/models'
23 22
24const expect = chai.expect 23const expect = chai.expect
25 24
diff --git a/server/tests/api/server/config.ts b/server/tests/api/server/config.ts
index 1d9ea31df..19bf9582c 100644
--- a/server/tests/api/server/config.ts
+++ b/server/tests/api/server/config.ts
@@ -60,6 +60,7 @@ function checkInitialConfig (server: ServerInfo, data: CustomConfig) {
60 60
61 expect(data.signup.enabled).to.be.true 61 expect(data.signup.enabled).to.be.true
62 expect(data.signup.limit).to.equal(4) 62 expect(data.signup.limit).to.equal(4)
63 expect(data.signup.minimumAge).to.equal(16)
63 expect(data.signup.requiresEmailVerification).to.be.false 64 expect(data.signup.requiresEmailVerification).to.be.false
64 65
65 expect(data.admin.email).to.equal('admin' + server.internalServerNumber + '@example.com') 66 expect(data.admin.email).to.equal('admin' + server.internalServerNumber + '@example.com')
@@ -151,6 +152,7 @@ function checkUpdatedConfig (data: CustomConfig) {
151 expect(data.signup.enabled).to.be.false 152 expect(data.signup.enabled).to.be.false
152 expect(data.signup.limit).to.equal(5) 153 expect(data.signup.limit).to.equal(5)
153 expect(data.signup.requiresEmailVerification).to.be.false 154 expect(data.signup.requiresEmailVerification).to.be.false
155 expect(data.signup.minimumAge).to.equal(10)
154 156
155 // We override admin email in parallel tests, so skip this exception 157 // We override admin email in parallel tests, so skip this exception
156 if (parallelTests() === false) { 158 if (parallelTests() === false) {
@@ -316,7 +318,8 @@ describe('Test config', function () {
316 signup: { 318 signup: {
317 enabled: false, 319 enabled: false,
318 limit: 5, 320 limit: 5,
319 requiresEmailVerification: false 321 requiresEmailVerification: false,
322 minimumAge: 10
320 }, 323 },
321 admin: { 324 admin: {
322 email: 'superadmin1@example.com' 325 email: 'superadmin1@example.com'
diff --git a/server/tests/api/server/contact-form.ts b/server/tests/api/server/contact-form.ts
index 9b4af1915..8851ad55e 100644
--- a/server/tests/api/server/contact-form.ts
+++ b/server/tests/api/server/contact-form.ts
@@ -54,6 +54,10 @@ describe('Test contact form', function () {
54 }) 54 })
55 55
56 it('Should not be able to send another contact form because of the anti spam checker', async function () { 56 it('Should not be able to send another contact form because of the anti spam checker', async function () {
57 this.timeout(10000)
58
59 await wait(1000)
60
57 await sendContactForm({ 61 await sendContactForm({
58 url: server.url, 62 url: server.url,
59 fromEmail: 'toto@example.com', 63 fromEmail: 'toto@example.com',
diff --git a/server/tests/api/server/follow-constraints.ts b/server/tests/api/server/follow-constraints.ts
index 8a91fbba3..3f2f71f46 100644
--- a/server/tests/api/server/follow-constraints.ts
+++ b/server/tests/api/server/follow-constraints.ts
@@ -18,6 +18,7 @@ import { unfollow } from '../../../../shared/extra-utils/server/follows'
18import { userLogin } from '../../../../shared/extra-utils/users/login' 18import { userLogin } from '../../../../shared/extra-utils/users/login'
19import { createUser } from '../../../../shared/extra-utils/users/users' 19import { createUser } from '../../../../shared/extra-utils/users/users'
20import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 20import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
21import { PeerTubeProblemDocument, ServerErrorCode } from '@shared/models'
21 22
22const expect = chai.expect 23const expect = chai.expect
23 24
@@ -153,7 +154,20 @@ describe('Test follow constraints', function () {
153 }) 154 })
154 155
155 it('Should not get the remote video', async function () { 156 it('Should not get the remote video', async function () {
156 await getVideo(servers[0].url, video2UUID, HttpStatusCode.FORBIDDEN_403) 157 const res = await getVideo(servers[0].url, video2UUID, HttpStatusCode.FORBIDDEN_403)
158
159 const error = res.body as PeerTubeProblemDocument
160
161 const doc = 'https://docs.joinpeertube.org/api-rest-reference.html#section/Errors/does_not_respect_follow_constraints'
162 expect(error.type).to.equal(doc)
163 expect(error.code).to.equal(ServerErrorCode.DOES_NOT_RESPECT_FOLLOW_CONSTRAINTS)
164
165 expect(error.detail).to.equal('Cannot get this video regarding follow constraints')
166 expect(error.error).to.equal(error.detail)
167
168 expect(error.status).to.equal(HttpStatusCode.FORBIDDEN_403)
169
170 expect(error.originUrl).to.contains(servers[1].url)
157 }) 171 })
158 172
159 it('Should list local account videos', async function () { 173 it('Should list local account videos', async function () {
diff --git a/server/tests/api/server/follows.ts b/server/tests/api/server/follows.ts
index eb9ab10eb..9e5aa00c7 100644
--- a/server/tests/api/server/follows.ts
+++ b/server/tests/api/server/follows.ts
@@ -1,37 +1,35 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import * as chai from 'chai'
4import 'mocha' 3import 'mocha'
5import { Video, VideoPrivacy } from '../../../../shared/models/videos' 4import * as chai from 'chai'
6import { VideoComment, VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model'
7import { cleanupTests, completeVideoCheck, deleteVideoComment } from '../../../../shared/extra-utils'
8import { 5import {
6 addVideoCommentReply,
7 addVideoCommentThread,
8 cleanupTests,
9 completeVideoCheck,
10 createUser,
11 createVideoCaption,
12 dateIsValid,
13 deleteVideoComment,
14 expectAccountFollows,
9 flushAndRunMultipleServers, 15 flushAndRunMultipleServers,
10 getVideosList,
11 ServerInfo,
12 setAccessTokensToServers,
13 uploadVideo
14} from '../../../../shared/extra-utils/index'
15import { dateIsValid } from '../../../../shared/extra-utils/miscs/miscs'
16import {
17 follow, 16 follow,
18 getFollowersListPaginationAndSort, 17 getFollowersListPaginationAndSort,
19 getFollowingListPaginationAndSort, 18 getFollowingListPaginationAndSort,
20 unfollow
21} from '../../../../shared/extra-utils/server/follows'
22import { expectAccountFollows } from '../../../../shared/extra-utils/users/accounts'
23import { userLogin } from '../../../../shared/extra-utils/users/login'
24import { createUser } from '../../../../shared/extra-utils/users/users'
25import {
26 addVideoCommentReply,
27 addVideoCommentThread,
28 getVideoCommentThreads, 19 getVideoCommentThreads,
29 getVideoThreadComments 20 getVideosList,
30} from '../../../../shared/extra-utils/videos/video-comments' 21 getVideoThreadComments,
31import { rateVideo } from '../../../../shared/extra-utils/videos/videos' 22 listVideoCaptions,
32import { waitJobs } from '../../../../shared/extra-utils/server/jobs' 23 rateVideo,
33import { createVideoCaption, listVideoCaptions, testCaptionFile } from '../../../../shared/extra-utils/videos/video-captions' 24 ServerInfo,
34import { VideoCaption } from '../../../../shared/models/videos/caption/video-caption.model' 25 setAccessTokensToServers,
26 testCaptionFile,
27 unfollow,
28 uploadVideo,
29 userLogin,
30 waitJobs
31} from '@shared/extra-utils'
32import { Video, VideoCaption, VideoComment, VideoCommentThreadTree, VideoPrivacy } from '@shared/models'
35 33
36const expect = chai.expect 34const expect = chai.expect
37 35
@@ -521,7 +519,7 @@ describe('Test follows', function () {
521 expect(deletedComment.text).to.equal('') 519 expect(deletedComment.text).to.equal('')
522 expect(deletedComment.inReplyToCommentId).to.be.null 520 expect(deletedComment.inReplyToCommentId).to.be.null
523 expect(deletedComment.account).to.be.null 521 expect(deletedComment.account).to.be.null
524 expect(deletedComment.totalReplies).to.equal(3) 522 expect(deletedComment.totalReplies).to.equal(2)
525 expect(dateIsValid(deletedComment.deletedAt as string)).to.be.true 523 expect(dateIsValid(deletedComment.deletedAt as string)).to.be.true
526 524
527 const res2 = await getVideoThreadComments(servers[0].url, video4.id, deletedComment.threadId) 525 const res2 = await getVideoThreadComments(servers[0].url, video4.id, deletedComment.threadId)
diff --git a/server/tests/api/server/handle-down.ts b/server/tests/api/server/handle-down.ts
index 817c79f6e..d57d72f5e 100644
--- a/server/tests/api/server/handle-down.ts
+++ b/server/tests/api/server/handle-down.ts
@@ -4,7 +4,7 @@ import * as chai from 'chai'
4import 'mocha' 4import 'mocha'
5import { JobState, Video } from '../../../../shared/models' 5import { JobState, Video } from '../../../../shared/models'
6import { VideoPrivacy } from '../../../../shared/models/videos' 6import { VideoPrivacy } from '../../../../shared/models/videos'
7import { VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model' 7import { VideoCommentThreadTree } from '../../../../shared/models/videos/comment/video-comment.model'
8 8
9import { 9import {
10 cleanupTests, 10 cleanupTests,
@@ -47,7 +47,7 @@ describe('Test handle downs', function () {
47 let missedVideo2: Video 47 let missedVideo2: Video
48 let unlistedVideo: Video 48 let unlistedVideo: Video
49 49
50 const videoIdsServer1: number[] = [] 50 const videoIdsServer1: string[] = []
51 51
52 const videoAttributes = { 52 const videoAttributes = {
53 name: 'my super name for server 1', 53 name: 'my super name for server 1',
@@ -346,10 +346,12 @@ describe('Test handle downs', function () {
346 // Wait video expiration 346 // Wait video expiration
347 await wait(11000) 347 await wait(11000)
348 348
349 for (let i = 0; i < 3; i++) { 349 for (let i = 0; i < 5; i++) {
350 await getVideo(servers[1].url, videoIdsServer1[i]) 350 try {
351 await waitJobs([ servers[1] ]) 351 await getVideo(servers[1].url, videoIdsServer1[i])
352 await wait(1500) 352 await waitJobs([ servers[1] ])
353 await wait(1500)
354 } catch {}
353 } 355 }
354 356
355 for (const id of videoIdsServer1) { 357 for (const id of videoIdsServer1) {
diff --git a/server/tests/api/server/homepage.ts b/server/tests/api/server/homepage.ts
new file mode 100644
index 000000000..e8ba89ca6
--- /dev/null
+++ b/server/tests/api/server/homepage.ts
@@ -0,0 +1,85 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import 'mocha'
4import * as chai from 'chai'
5import { HttpStatusCode } from '@shared/core-utils'
6import { CustomPage, ServerConfig } from '@shared/models'
7import {
8 cleanupTests,
9 flushAndRunServer,
10 getConfig,
11 getInstanceHomepage,
12 killallServers,
13 reRunServer,
14 ServerInfo,
15 setAccessTokensToServers,
16 updateInstanceHomepage
17} from '../../../../shared/extra-utils/index'
18
19const expect = chai.expect
20
21async function getHomepageState (server: ServerInfo) {
22 const res = await getConfig(server.url)
23
24 const config = res.body as ServerConfig
25 return config.homepage.enabled
26}
27
28describe('Test instance homepage actions', function () {
29 let server: ServerInfo
30
31 before(async function () {
32 this.timeout(30000)
33
34 server = await flushAndRunServer(1)
35 await setAccessTokensToServers([ server ])
36 })
37
38 it('Should not have a homepage', async function () {
39 const state = await getHomepageState(server)
40 expect(state).to.be.false
41
42 await getInstanceHomepage(server.url, HttpStatusCode.NOT_FOUND_404)
43 })
44
45 it('Should set a homepage', async function () {
46 await updateInstanceHomepage(server.url, server.accessToken, '<picsou-magazine></picsou-magazine>')
47
48 const res = await getInstanceHomepage(server.url)
49 const page: CustomPage = res.body
50 expect(page.content).to.equal('<picsou-magazine></picsou-magazine>')
51
52 const state = await getHomepageState(server)
53 expect(state).to.be.true
54 })
55
56 it('Should have the same homepage after a restart', async function () {
57 this.timeout(30000)
58
59 killallServers([ server ])
60
61 await reRunServer(server)
62
63 const res = await getInstanceHomepage(server.url)
64 const page: CustomPage = res.body
65 expect(page.content).to.equal('<picsou-magazine></picsou-magazine>')
66
67 const state = await getHomepageState(server)
68 expect(state).to.be.true
69 })
70
71 it('Should empty the homepage', async function () {
72 await updateInstanceHomepage(server.url, server.accessToken, '')
73
74 const res = await getInstanceHomepage(server.url)
75 const page: CustomPage = res.body
76 expect(page.content).to.be.empty
77
78 const state = await getHomepageState(server)
79 expect(state).to.be.false
80 })
81
82 after(async function () {
83 await cleanupTests([ server ])
84 })
85})
diff --git a/server/tests/api/server/index.ts b/server/tests/api/server/index.ts
index be743973a..56e6eb5da 100644
--- a/server/tests/api/server/index.ts
+++ b/server/tests/api/server/index.ts
@@ -5,6 +5,7 @@ import './email'
5import './follow-constraints' 5import './follow-constraints'
6import './follows' 6import './follows'
7import './follows-moderation' 7import './follows-moderation'
8import './homepage'
8import './handle-down' 9import './handle-down'
9import './jobs' 10import './jobs'
10import './logs' 11import './logs'
diff --git a/server/tests/api/server/plugins.ts b/server/tests/api/server/plugins.ts
index f4190c352..6b61c7c33 100644
--- a/server/tests/api/server/plugins.ts
+++ b/server/tests/api/server/plugins.ts
@@ -2,6 +2,7 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { HttpStatusCode } from '@shared/core-utils'
5import { 6import {
6 cleanupTests, 7 cleanupTests,
7 closeAllSequelize, 8 closeAllSequelize,
@@ -10,6 +11,7 @@ import {
10 getMyUserInformation, 11 getMyUserInformation,
11 getPlugin, 12 getPlugin,
12 getPluginPackageJSON, 13 getPluginPackageJSON,
14 getPluginTestPath,
13 getPublicSettings, 15 getPublicSettings,
14 installPlugin, 16 installPlugin,
15 killallServers, 17 killallServers,
@@ -28,14 +30,8 @@ import {
28 updatePluginSettings, 30 updatePluginSettings,
29 wait, 31 wait,
30 waitUntilLog 32 waitUntilLog
31} from '../../../../shared/extra-utils' 33} from '@shared/extra-utils'
32import { PeerTubePluginIndex } from '../../../../shared/models/plugins/peertube-plugin-index.model' 34import { PeerTubePlugin, PeerTubePluginIndex, PluginPackageJson, PluginType, PublicServerSetting, ServerConfig, User } from '@shared/models'
33import { PeerTubePlugin } from '../../../../shared/models/plugins/peertube-plugin.model'
34import { PluginPackageJson } from '../../../../shared/models/plugins/plugin-package-json.model'
35import { PluginType } from '../../../../shared/models/plugins/plugin.type'
36import { PublicServerSetting } from '../../../../shared/models/plugins/public-server.setting'
37import { ServerConfig } from '../../../../shared/models/server'
38import { User } from '../../../../shared/models/users'
39 35
40const expect = chai.expect 36const expect = chai.expect
41 37
@@ -406,6 +402,36 @@ describe('Test plugins', function () {
406 expect((res.body as User).theme).to.equal('instance-default') 402 expect((res.body as User).theme).to.equal('instance-default')
407 }) 403 })
408 404
405 it('Should not install a broken plugin', async function () {
406 this.timeout(60000)
407
408 async function check () {
409 const res = await listPlugins({
410 url: server.url,
411 accessToken: server.accessToken,
412 pluginType: PluginType.PLUGIN
413 })
414
415 const plugins: PeerTubePlugin[] = res.body.data
416
417 expect(plugins.find(p => p.name === 'test-broken')).to.not.exist
418 }
419
420 await installPlugin({
421 url: server.url,
422 accessToken: server.accessToken,
423 path: getPluginTestPath('-broken'),
424 expectedStatus: HttpStatusCode.BAD_REQUEST_400
425 })
426
427 await check()
428
429 killallServers([ server ])
430 await reRunServer(server)
431
432 await check()
433 })
434
409 after(async function () { 435 after(async function () {
410 await closeAllSequelize([ server ]) 436 await closeAllSequelize([ server ])
411 await cleanupTests([ server ]) 437 await cleanupTests([ server ])
diff --git a/server/tests/api/server/services.ts b/server/tests/api/server/services.ts
index f0fa91674..ea64e4040 100644
--- a/server/tests/api/server/services.ts
+++ b/server/tests/api/server/services.ts
@@ -67,61 +67,67 @@ describe('Test services', function () {
67 }) 67 })
68 68
69 it('Should have a valid oEmbed video response', async function () { 69 it('Should have a valid oEmbed video response', async function () {
70 const oembedUrl = 'http://localhost:' + server.port + '/videos/watch/' + video.uuid 70 for (const basePath of [ '/videos/watch/', '/w/' ]) {
71 71 const oembedUrl = 'http://localhost:' + server.port + basePath + video.uuid
72 const res = await getOEmbed(server.url, oembedUrl) 72
73 const expectedHtml = '<iframe width="560" height="315" sandbox="allow-same-origin allow-scripts" ' + 73 const res = await getOEmbed(server.url, oembedUrl)
74 `title="${video.name}" src="http://localhost:${server.port}/videos/embed/${video.uuid}" ` + 74 const expectedHtml = '<iframe width="560" height="315" sandbox="allow-same-origin allow-scripts" ' +
75 'frameborder="0" allowfullscreen></iframe>' 75 `title="${video.name}" src="http://localhost:${server.port}/videos/embed/${video.uuid}" ` +
76 const expectedThumbnailUrl = 'http://localhost:' + server.port + video.previewPath 76 'frameborder="0" allowfullscreen></iframe>'
77 77 const expectedThumbnailUrl = 'http://localhost:' + server.port + video.previewPath
78 expect(res.body.html).to.equal(expectedHtml) 78
79 expect(res.body.title).to.equal(video.name) 79 expect(res.body.html).to.equal(expectedHtml)
80 expect(res.body.author_name).to.equal(server.videoChannel.displayName) 80 expect(res.body.title).to.equal(video.name)
81 expect(res.body.width).to.equal(560) 81 expect(res.body.author_name).to.equal(server.videoChannel.displayName)
82 expect(res.body.height).to.equal(315) 82 expect(res.body.width).to.equal(560)
83 expect(res.body.thumbnail_url).to.equal(expectedThumbnailUrl) 83 expect(res.body.height).to.equal(315)
84 expect(res.body.thumbnail_width).to.equal(850) 84 expect(res.body.thumbnail_url).to.equal(expectedThumbnailUrl)
85 expect(res.body.thumbnail_height).to.equal(480) 85 expect(res.body.thumbnail_width).to.equal(850)
86 expect(res.body.thumbnail_height).to.equal(480)
87 }
86 }) 88 })
87 89
88 it('Should have a valid playlist oEmbed response', async function () { 90 it('Should have a valid playlist oEmbed response', async function () {
89 const oembedUrl = 'http://localhost:' + server.port + '/videos/watch/playlist/' + playlistUUID 91 for (const basePath of [ '/videos/watch/playlist/', '/w/p/' ]) {
90 92 const oembedUrl = 'http://localhost:' + server.port + basePath + playlistUUID
91 const res = await getOEmbed(server.url, oembedUrl) 93
92 const expectedHtml = '<iframe width="560" height="315" sandbox="allow-same-origin allow-scripts" ' + 94 const res = await getOEmbed(server.url, oembedUrl)
93 `title="${playlistDisplayName}" src="http://localhost:${server.port}/video-playlists/embed/${playlistUUID}" ` + 95 const expectedHtml = '<iframe width="560" height="315" sandbox="allow-same-origin allow-scripts" ' +
94 'frameborder="0" allowfullscreen></iframe>' 96 `title="${playlistDisplayName}" src="http://localhost:${server.port}/video-playlists/embed/${playlistUUID}" ` +
95 97 'frameborder="0" allowfullscreen></iframe>'
96 expect(res.body.html).to.equal(expectedHtml) 98
97 expect(res.body.title).to.equal('The Life and Times of Scrooge McDuck') 99 expect(res.body.html).to.equal(expectedHtml)
98 expect(res.body.author_name).to.equal(server.videoChannel.displayName) 100 expect(res.body.title).to.equal('The Life and Times of Scrooge McDuck')
99 expect(res.body.width).to.equal(560) 101 expect(res.body.author_name).to.equal(server.videoChannel.displayName)
100 expect(res.body.height).to.equal(315) 102 expect(res.body.width).to.equal(560)
101 expect(res.body.thumbnail_url).exist 103 expect(res.body.height).to.equal(315)
102 expect(res.body.thumbnail_width).to.equal(280) 104 expect(res.body.thumbnail_url).exist
103 expect(res.body.thumbnail_height).to.equal(157) 105 expect(res.body.thumbnail_width).to.equal(280)
106 expect(res.body.thumbnail_height).to.equal(157)
107 }
104 }) 108 })
105 109
106 it('Should have a valid oEmbed response with small max height query', async function () { 110 it('Should have a valid oEmbed response with small max height query', async function () {
107 const oembedUrl = 'http://localhost:' + server.port + '/videos/watch/' + video.uuid 111 for (const basePath of [ '/videos/watch/', '/w/' ]) {
108 const format = 'json' 112 const oembedUrl = 'http://localhost:' + server.port + basePath + video.uuid
109 const maxHeight = 50 113 const format = 'json'
110 const maxWidth = 50 114 const maxHeight = 50
111 115 const maxWidth = 50
112 const res = await getOEmbed(server.url, oembedUrl, format, maxHeight, maxWidth) 116
113 const expectedHtml = '<iframe width="50" height="50" sandbox="allow-same-origin allow-scripts" ' + 117 const res = await getOEmbed(server.url, oembedUrl, format, maxHeight, maxWidth)
114 `title="${video.name}" src="http://localhost:${server.port}/videos/embed/${video.uuid}" ` + 118 const expectedHtml = '<iframe width="50" height="50" sandbox="allow-same-origin allow-scripts" ' +
115 'frameborder="0" allowfullscreen></iframe>' 119 `title="${video.name}" src="http://localhost:${server.port}/videos/embed/${video.uuid}" ` +
116 120 'frameborder="0" allowfullscreen></iframe>'
117 expect(res.body.html).to.equal(expectedHtml) 121
118 expect(res.body.title).to.equal(video.name) 122 expect(res.body.html).to.equal(expectedHtml)
119 expect(res.body.author_name).to.equal(server.videoChannel.displayName) 123 expect(res.body.title).to.equal(video.name)
120 expect(res.body.height).to.equal(50) 124 expect(res.body.author_name).to.equal(server.videoChannel.displayName)
121 expect(res.body.width).to.equal(50) 125 expect(res.body.height).to.equal(50)
122 expect(res.body).to.not.have.property('thumbnail_url') 126 expect(res.body.width).to.equal(50)
123 expect(res.body).to.not.have.property('thumbnail_width') 127 expect(res.body).to.not.have.property('thumbnail_url')
124 expect(res.body).to.not.have.property('thumbnail_height') 128 expect(res.body).to.not.have.property('thumbnail_width')
129 expect(res.body).to.not.have.property('thumbnail_height')
130 }
125 }) 131 })
126 132
127 after(async function () { 133 after(async function () {
diff --git a/server/tests/api/users/users-verification.ts b/server/tests/api/users/users-verification.ts
index 1a9a519a0..e0f2f2112 100644
--- a/server/tests/api/users/users-verification.ts
+++ b/server/tests/api/users/users-verification.ts
@@ -19,6 +19,7 @@ import { setAccessTokensToServers } from '../../../../shared/extra-utils/users/l
19import { MockSmtpServer } from '../../../../shared/extra-utils/miscs/email' 19import { MockSmtpServer } from '../../../../shared/extra-utils/miscs/email'
20import { waitJobs } from '../../../../shared/extra-utils/server/jobs' 20import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
21import { User } from '../../../../shared/models/users' 21import { User } from '../../../../shared/models/users'
22import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
22 23
23const expect = chai.expect 24const expect = chai.expect
24 25
@@ -89,8 +90,8 @@ describe('Test users account verification', function () {
89 }) 90 })
90 91
91 it('Should not allow login for user with unverified email', async function () { 92 it('Should not allow login for user with unverified email', async function () {
92 const resLogin = await login(server.url, server.client, user1, 400) 93 const resLogin = await login(server.url, server.client, user1, HttpStatusCode.BAD_REQUEST_400)
93 expect(resLogin.body.error).to.contain('User email is not verified.') 94 expect(resLogin.body.detail).to.contain('User email is not verified.')
94 }) 95 })
95 96
96 it('Should verify the user via email and allow login', async function () { 97 it('Should verify the user via email and allow login', async function () {
diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts
index cea98aac7..87ba775f6 100644
--- a/server/tests/api/users/users.ts
+++ b/server/tests/api/users/users.ts
@@ -3,7 +3,7 @@
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { AbuseState, AbuseUpdate, MyUser, User, UserRole, Video, VideoPlaylistType } from '@shared/models' 5import { AbuseState, AbuseUpdate, MyUser, User, UserRole, Video, VideoPlaylistType } from '@shared/models'
6import { CustomConfig } from '@shared/models/server' 6import { CustomConfig, OAuth2ErrorCode } from '@shared/models/server'
7import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes' 7import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
8import { 8import {
9 addVideoCommentThread, 9 addVideoCommentThread,
@@ -93,16 +93,20 @@ describe('Test users', function () {
93 const client = { id: 'client', secret: server.client.secret } 93 const client = { id: 'client', secret: server.client.secret }
94 const res = await login(server.url, client, server.user, HttpStatusCode.BAD_REQUEST_400) 94 const res = await login(server.url, client, server.user, HttpStatusCode.BAD_REQUEST_400)
95 95
96 expect(res.body.code).to.equal('invalid_client') 96 expect(res.body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
97 expect(res.body.error).to.contain('client is invalid') 97 expect(res.body.error).to.contain('client is invalid')
98 expect(res.body.type.startsWith('https://')).to.be.true
99 expect(res.body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
98 }) 100 })
99 101
100 it('Should not login with an invalid client secret', async function () { 102 it('Should not login with an invalid client secret', async function () {
101 const client = { id: server.client.id, secret: 'coucou' } 103 const client = { id: server.client.id, secret: 'coucou' }
102 const res = await login(server.url, client, server.user, HttpStatusCode.BAD_REQUEST_400) 104 const res = await login(server.url, client, server.user, HttpStatusCode.BAD_REQUEST_400)
103 105
104 expect(res.body.code).to.equal('invalid_client') 106 expect(res.body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
105 expect(res.body.error).to.contain('client is invalid') 107 expect(res.body.error).to.contain('client is invalid')
108 expect(res.body.type.startsWith('https://')).to.be.true
109 expect(res.body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
106 }) 110 })
107 }) 111 })
108 112
@@ -112,16 +116,20 @@ describe('Test users', function () {
112 const user = { username: 'captain crochet', password: server.user.password } 116 const user = { username: 'captain crochet', password: server.user.password }
113 const res = await login(server.url, server.client, user, HttpStatusCode.BAD_REQUEST_400) 117 const res = await login(server.url, server.client, user, HttpStatusCode.BAD_REQUEST_400)
114 118
115 expect(res.body.code).to.equal('invalid_grant') 119 expect(res.body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
116 expect(res.body.error).to.contain('credentials are invalid') 120 expect(res.body.error).to.contain('credentials are invalid')
121 expect(res.body.type.startsWith('https://')).to.be.true
122 expect(res.body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
117 }) 123 })
118 124
119 it('Should not login with an invalid password', async function () { 125 it('Should not login with an invalid password', async function () {
120 const user = { username: server.user.username, password: 'mew_three' } 126 const user = { username: server.user.username, password: 'mew_three' }
121 const res = await login(server.url, server.client, user, HttpStatusCode.BAD_REQUEST_400) 127 const res = await login(server.url, server.client, user, HttpStatusCode.BAD_REQUEST_400)
122 128
123 expect(res.body.code).to.equal('invalid_grant') 129 expect(res.body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
124 expect(res.body.error).to.contain('credentials are invalid') 130 expect(res.body.error).to.contain('credentials are invalid')
131 expect(res.body.type.startsWith('https://')).to.be.true
132 expect(res.body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
125 }) 133 })
126 134
127 it('Should not be able to upload a video', async function () { 135 it('Should not be able to upload a video', async function () {
diff --git a/server/tests/api/videos/multiple-servers.ts b/server/tests/api/videos/multiple-servers.ts
index 41cd814e0..a8c8a889b 100644
--- a/server/tests/api/videos/multiple-servers.ts
+++ b/server/tests/api/videos/multiple-servers.ts
@@ -1,13 +1,12 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import * as chai from 'chai'
4import 'mocha' 3import 'mocha'
5import { join } from 'path' 4import * as chai from 'chai'
6import * as request from 'supertest' 5import * as request from 'supertest'
7import { VideoPrivacy } from '../../../../shared/models/videos' 6import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
8import { VideoComment, VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model'
9import { 7import {
10 addVideoChannel, 8 addVideoChannel,
9 buildAbsoluteFixturePath,
11 checkTmpIsEmpty, 10 checkTmpIsEmpty,
12 checkVideoFilesWereRemoved, 11 checkVideoFilesWereRemoved,
13 cleanupTests, 12 cleanupTests,
@@ -32,16 +31,16 @@ import {
32 wait, 31 wait,
33 webtorrentAdd 32 webtorrentAdd
34} from '../../../../shared/extra-utils' 33} from '../../../../shared/extra-utils'
34import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
35import { 35import {
36 addVideoCommentReply, 36 addVideoCommentReply,
37 addVideoCommentThread, 37 addVideoCommentThread,
38 deleteVideoComment, 38 deleteVideoComment,
39 findCommentId,
39 getVideoCommentThreads, 40 getVideoCommentThreads,
40 getVideoThreadComments, 41 getVideoThreadComments
41 findCommentId
42} from '../../../../shared/extra-utils/videos/video-comments' 42} from '../../../../shared/extra-utils/videos/video-comments'
43import { waitJobs } from '../../../../shared/extra-utils/server/jobs' 43import { VideoComment, VideoCommentThreadTree, VideoPrivacy } from '../../../../shared/models/videos'
44import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
45 44
46const expect = chai.expect 45const expect = chai.expect
47 46
@@ -935,7 +934,7 @@ describe('Test multiple servers', function () {
935 expect(deletedComment.text).to.equal('') 934 expect(deletedComment.text).to.equal('')
936 expect(deletedComment.inReplyToCommentId).to.be.null 935 expect(deletedComment.inReplyToCommentId).to.be.null
937 expect(deletedComment.account).to.be.null 936 expect(deletedComment.account).to.be.null
938 expect(deletedComment.totalReplies).to.equal(3) 937 expect(deletedComment.totalReplies).to.equal(2)
939 expect(dateIsValid(deletedComment.createdAt as string)).to.be.true 938 expect(dateIsValid(deletedComment.createdAt as string)).to.be.true
940 expect(dateIsValid(deletedComment.updatedAt as string)).to.be.true 939 expect(dateIsValid(deletedComment.updatedAt as string)).to.be.true
941 expect(dateIsValid(deletedComment.deletedAt as string)).to.be.true 940 expect(dateIsValid(deletedComment.deletedAt as string)).to.be.true
@@ -977,7 +976,7 @@ describe('Test multiple servers', function () {
977 expect(comment.createdAt).to.not.be.null 976 expect(comment.createdAt).to.not.be.null
978 expect(comment.deletedAt).to.not.be.null 977 expect(comment.deletedAt).to.not.be.null
979 expect(comment.account).to.be.null 978 expect(comment.account).to.be.null
980 expect(comment.totalReplies).to.equal(3) 979 expect(comment.totalReplies).to.equal(2)
981 } 980 }
982 } 981 }
983 }) 982 })
@@ -1019,9 +1018,7 @@ describe('Test multiple servers', function () {
1019 .field('privacy', '1') 1018 .field('privacy', '1')
1020 .field('channelId', '1') 1019 .field('channelId', '1')
1021 1020
1022 const filePath = join(__dirname, '..', '..', 'fixtures', 'video_short.webm') 1021 await req.attach('videofile', buildAbsoluteFixturePath('video_short.webm'))
1023
1024 await req.attach('videofile', filePath)
1025 .expect(HttpStatusCode.OK_200) 1022 .expect(HttpStatusCode.OK_200)
1026 1023
1027 await waitJobs(servers) 1024 await waitJobs(servers)
diff --git a/server/tests/api/videos/resumable-upload.ts b/server/tests/api/videos/resumable-upload.ts
index af9221c43..4fc3317df 100644
--- a/server/tests/api/videos/resumable-upload.ts
+++ b/server/tests/api/videos/resumable-upload.ts
@@ -8,6 +8,7 @@ import { HttpStatusCode } from '@shared/core-utils'
8import { 8import {
9 buildAbsoluteFixturePath, 9 buildAbsoluteFixturePath,
10 buildServerDirectory, 10 buildServerDirectory,
11 cleanupTests,
11 flushAndRunServer, 12 flushAndRunServer,
12 getMyUserInformation, 13 getMyUserInformation,
13 prepareResumableUpload, 14 prepareResumableUpload,
@@ -184,4 +185,7 @@ describe('Test resumable upload', function () {
184 }) 185 })
185 }) 186 })
186 187
188 after(async function () {
189 await cleanupTests([ server ])
190 })
187}) 191})
diff --git a/server/tests/api/videos/video-change-ownership.ts b/server/tests/api/videos/video-change-ownership.ts
index fad4c8b1f..a3384851b 100644
--- a/server/tests/api/videos/video-change-ownership.ts
+++ b/server/tests/api/videos/video-change-ownership.ts
@@ -1,11 +1,13 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import * as chai from 'chai'
4import 'mocha' 3import 'mocha'
4import * as chai from 'chai'
5import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
5import { 6import {
6 acceptChangeOwnership, 7 acceptChangeOwnership,
7 changeVideoOwnership, 8 changeVideoOwnership,
8 cleanupTests, 9 cleanupTests,
10 createLive,
9 createUser, 11 createUser,
10 doubleFollow, 12 doubleFollow,
11 flushAndRunMultipleServers, 13 flushAndRunMultipleServers,
@@ -17,13 +19,14 @@ import {
17 refuseChangeOwnership, 19 refuseChangeOwnership,
18 ServerInfo, 20 ServerInfo,
19 setAccessTokensToServers, 21 setAccessTokensToServers,
22 setDefaultVideoChannel,
23 updateCustomSubConfig,
20 uploadVideo, 24 uploadVideo,
21 userLogin 25 userLogin
22} from '../../../../shared/extra-utils' 26} from '../../../../shared/extra-utils'
23import { waitJobs } from '../../../../shared/extra-utils/server/jobs' 27import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
24import { User } from '../../../../shared/models/users' 28import { User } from '../../../../shared/models/users'
25import { VideoDetails } from '../../../../shared/models/videos' 29import { VideoDetails, VideoPrivacy } from '../../../../shared/models/videos'
26import { HttpStatusCode } from '../../../../shared/core-utils/miscs/http-error-codes'
27 30
28const expect = chai.expect 31const expect = chai.expect
29 32
@@ -37,15 +40,32 @@ describe('Test video change ownership - nominal', function () {
37 username: 'second', 40 username: 'second',
38 password: 'My other password' 41 password: 'My other password'
39 } 42 }
43
40 let firstUserAccessToken = '' 44 let firstUserAccessToken = ''
45 let firstUserChannelId: number
46
41 let secondUserAccessToken = '' 47 let secondUserAccessToken = ''
48 let secondUserChannelId: number
49
42 let lastRequestChangeOwnershipId = '' 50 let lastRequestChangeOwnershipId = ''
43 51
52 let liveId: number
53
44 before(async function () { 54 before(async function () {
45 this.timeout(50000) 55 this.timeout(50000)
46 56
47 servers = await flushAndRunMultipleServers(2) 57 servers = await flushAndRunMultipleServers(2)
48 await setAccessTokensToServers(servers) 58 await setAccessTokensToServers(servers)
59 await setDefaultVideoChannel(servers)
60
61 await updateCustomSubConfig(servers[0].url, servers[0].accessToken, {
62 transcoding: {
63 enabled: false
64 },
65 live: {
66 enabled: true
67 }
68 })
49 69
50 const videoQuota = 42000000 70 const videoQuota = 42000000
51 await createUser({ 71 await createUser({
@@ -66,22 +86,35 @@ describe('Test video change ownership - nominal', function () {
66 firstUserAccessToken = await userLogin(servers[0], firstUser) 86 firstUserAccessToken = await userLogin(servers[0], firstUser)
67 secondUserAccessToken = await userLogin(servers[0], secondUser) 87 secondUserAccessToken = await userLogin(servers[0], secondUser)
68 88
69 const videoAttributes = { 89 {
70 name: 'my super name', 90 const res = await getMyUserInformation(servers[0].url, firstUserAccessToken)
71 description: 'my super description' 91 const firstUserInformation: User = res.body
92 firstUserChannelId = firstUserInformation.videoChannels[0].id
72 } 93 }
73 await uploadVideo(servers[0].url, firstUserAccessToken, videoAttributes)
74 94
75 await waitJobs(servers) 95 {
96 const res = await getMyUserInformation(servers[0].url, secondUserAccessToken)
97 const secondUserInformation: User = res.body
98 secondUserChannelId = secondUserInformation.videoChannels[0].id
99 }
76 100
77 const res = await getVideosList(servers[0].url) 101 {
78 const videos = res.body.data 102 const videoAttributes = {
103 name: 'my super name',
104 description: 'my super description'
105 }
106 const res = await uploadVideo(servers[0].url, firstUserAccessToken, videoAttributes)
79 107
80 expect(videos.length).to.equal(1) 108 const resVideo = await getVideo(servers[0].url, res.body.video.id)
109 servers[0].video = resVideo.body
110 }
81 111
82 const video = videos.find(video => video.name === 'my super name') 112 {
83 expect(video.channel.name).to.equal('first_channel') 113 const attributes = { name: 'live', channelId: firstUserChannelId, privacy: VideoPrivacy.PUBLIC }
84 servers[0].video = video 114 const res = await createLive(servers[0].url, firstUserAccessToken, attributes)
115
116 liveId = res.body.video.id
117 }
85 118
86 await doubleFollow(servers[0], servers[1]) 119 await doubleFollow(servers[0], servers[1])
87 }) 120 })
@@ -175,19 +208,19 @@ describe('Test video change ownership - nominal', function () {
175 it('Should not be possible to accept the change of ownership from first user', async function () { 208 it('Should not be possible to accept the change of ownership from first user', async function () {
176 this.timeout(10000) 209 this.timeout(10000)
177 210
178 const secondUserInformationResponse = await getMyUserInformation(servers[0].url, secondUserAccessToken) 211 await acceptChangeOwnership(
179 const secondUserInformation: User = secondUserInformationResponse.body 212 servers[0].url,
180 const channelId = secondUserInformation.videoChannels[0].id 213 firstUserAccessToken,
181 await acceptChangeOwnership(servers[0].url, firstUserAccessToken, lastRequestChangeOwnershipId, channelId, HttpStatusCode.FORBIDDEN_403) 214 lastRequestChangeOwnershipId,
215 secondUserChannelId,
216 HttpStatusCode.FORBIDDEN_403
217 )
182 }) 218 })
183 219
184 it('Should be possible to accept the change of ownership from second user', async function () { 220 it('Should be possible to accept the change of ownership from second user', async function () {
185 this.timeout(10000) 221 this.timeout(10000)
186 222
187 const secondUserInformationResponse = await getMyUserInformation(servers[0].url, secondUserAccessToken) 223 await acceptChangeOwnership(servers[0].url, secondUserAccessToken, lastRequestChangeOwnershipId, secondUserChannelId)
188 const secondUserInformation: User = secondUserInformationResponse.body
189 const channelId = secondUserInformation.videoChannels[0].id
190 await acceptChangeOwnership(servers[0].url, secondUserAccessToken, lastRequestChangeOwnershipId, channelId)
191 224
192 await waitJobs(servers) 225 await waitJobs(servers)
193 }) 226 })
@@ -204,6 +237,37 @@ describe('Test video change ownership - nominal', function () {
204 } 237 }
205 }) 238 })
206 239
240 it('Should send a request to change ownership of a live', async function () {
241 this.timeout(15000)
242
243 await changeVideoOwnership(servers[0].url, firstUserAccessToken, liveId, secondUser.username)
244
245 const resSecondUser = await getVideoChangeOwnershipList(servers[0].url, secondUserAccessToken)
246
247 expect(resSecondUser.body.total).to.equal(3)
248 expect(resSecondUser.body.data.length).to.equal(3)
249
250 lastRequestChangeOwnershipId = resSecondUser.body.data[0].id
251 })
252
253 it('Should accept a live ownership change', async function () {
254 this.timeout(20000)
255
256 await acceptChangeOwnership(servers[0].url, secondUserAccessToken, lastRequestChangeOwnershipId, secondUserChannelId)
257
258 await waitJobs(servers)
259
260 for (const server of servers) {
261 const res = await getVideo(server.url, servers[0].video.uuid)
262
263 const video: VideoDetails = res.body
264
265 expect(video.name).to.equal('my super name')
266 expect(video.channel.displayName).to.equal('Main second channel')
267 expect(video.channel.name).to.equal('second_channel')
268 }
269 })
270
207 after(async function () { 271 after(async function () {
208 await cleanupTests(servers) 272 await cleanupTests(servers)
209 }) 273 })
diff --git a/server/tests/api/videos/video-channels.ts b/server/tests/api/videos/video-channels.ts
index 7e7ad028c..865098777 100644
--- a/server/tests/api/videos/video-channels.ts
+++ b/server/tests/api/videos/video-channels.ts
@@ -15,6 +15,7 @@ import {
15 getVideoChannel, 15 getVideoChannel,
16 getVideoChannelVideos, 16 getVideoChannelVideos,
17 setDefaultVideoChannel, 17 setDefaultVideoChannel,
18 testFileExistsOrNot,
18 testImage, 19 testImage,
19 updateVideo, 20 updateVideo,
20 updateVideoChannelImage, 21 updateVideoChannelImage,
@@ -53,6 +54,9 @@ describe('Test video channels', function () {
53 let videoUUID: string 54 let videoUUID: string
54 let accountName: string 55 let accountName: string
55 56
57 const avatarPaths: { [ port: number ]: string } = {}
58 const bannerPaths: { [ port: number ]: string } = {}
59
56 before(async function () { 60 before(async function () {
57 this.timeout(60000) 61 this.timeout(60000)
58 62
@@ -287,9 +291,11 @@ describe('Test video channels', function () {
287 for (const server of servers) { 291 for (const server of servers) {
288 const videoChannel = await findChannel(server, secondVideoChannelId) 292 const videoChannel = await findChannel(server, secondVideoChannelId)
289 293
290 await testImage(server.url, 'avatar-resized', videoChannel.avatar.path, '.png') 294 avatarPaths[server.port] = videoChannel.avatar.path
295 await testImage(server.url, 'avatar-resized', avatarPaths[server.port], '.png')
296 await testFileExistsOrNot(server, 'avatars', basename(avatarPaths[server.port]), true)
291 297
292 const row = await getActorImage(server.internalServerNumber, basename(videoChannel.avatar.path)) 298 const row = await getActorImage(server.internalServerNumber, basename(avatarPaths[server.port]))
293 expect(row.height).to.equal(ACTOR_IMAGES_SIZE.AVATARS.height) 299 expect(row.height).to.equal(ACTOR_IMAGES_SIZE.AVATARS.height)
294 expect(row.width).to.equal(ACTOR_IMAGES_SIZE.AVATARS.width) 300 expect(row.width).to.equal(ACTOR_IMAGES_SIZE.AVATARS.width)
295 } 301 }
@@ -314,9 +320,11 @@ describe('Test video channels', function () {
314 const res = await getVideoChannel(server.url, 'second_video_channel@' + servers[0].host) 320 const res = await getVideoChannel(server.url, 'second_video_channel@' + servers[0].host)
315 const videoChannel = res.body 321 const videoChannel = res.body
316 322
317 await testImage(server.url, 'banner-resized', videoChannel.banner.path) 323 bannerPaths[server.port] = videoChannel.banner.path
324 await testImage(server.url, 'banner-resized', bannerPaths[server.port])
325 await testFileExistsOrNot(server, 'avatars', basename(bannerPaths[server.port]), true)
318 326
319 const row = await getActorImage(server.internalServerNumber, basename(videoChannel.banner.path)) 327 const row = await getActorImage(server.internalServerNumber, basename(bannerPaths[server.port]))
320 expect(row.height).to.equal(ACTOR_IMAGES_SIZE.BANNERS.height) 328 expect(row.height).to.equal(ACTOR_IMAGES_SIZE.BANNERS.height)
321 expect(row.width).to.equal(ACTOR_IMAGES_SIZE.BANNERS.width) 329 expect(row.width).to.equal(ACTOR_IMAGES_SIZE.BANNERS.width)
322 } 330 }
@@ -336,6 +344,7 @@ describe('Test video channels', function () {
336 344
337 for (const server of servers) { 345 for (const server of servers) {
338 const videoChannel = await findChannel(server, secondVideoChannelId) 346 const videoChannel = await findChannel(server, secondVideoChannelId)
347 await testFileExistsOrNot(server, 'avatars', basename(avatarPaths[server.port]), false)
339 348
340 expect(videoChannel.avatar).to.be.null 349 expect(videoChannel.avatar).to.be.null
341 } 350 }
@@ -355,6 +364,7 @@ describe('Test video channels', function () {
355 364
356 for (const server of servers) { 365 for (const server of servers) {
357 const videoChannel = await findChannel(server, secondVideoChannelId) 366 const videoChannel = await findChannel(server, secondVideoChannelId)
367 await testFileExistsOrNot(server, 'avatars', basename(bannerPaths[server.port]), false)
358 368
359 expect(videoChannel.banner).to.be.null 369 expect(videoChannel.banner).to.be.null
360 } 370 }
diff --git a/server/tests/api/videos/video-comments.ts b/server/tests/api/videos/video-comments.ts
index 615e0ea45..b6b002307 100644
--- a/server/tests/api/videos/video-comments.ts
+++ b/server/tests/api/videos/video-comments.ts
@@ -2,7 +2,7 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5 5import { VideoComment, VideoCommentAdmin, VideoCommentThreadTree } from '@shared/models'
6import { cleanupTests, testImage } from '../../../../shared/extra-utils' 6import { cleanupTests, testImage } from '../../../../shared/extra-utils'
7import { 7import {
8 createUser, 8 createUser,
@@ -22,7 +22,6 @@ import {
22 getVideoCommentThreads, 22 getVideoCommentThreads,
23 getVideoThreadComments 23 getVideoThreadComments
24} from '../../../../shared/extra-utils/videos/video-comments' 24} from '../../../../shared/extra-utils/videos/video-comments'
25import { VideoComment, VideoCommentAdmin, VideoCommentThreadTree } from '../../../../shared/models/videos/video-comment.model'
26 25
27const expect = chai.expect 26const expect = chai.expect
28 27
@@ -232,7 +231,7 @@ describe('Test video comments', function () {
232 expect(res.body.data[0].isDeleted).to.be.true 231 expect(res.body.data[0].isDeleted).to.be.true
233 expect(res.body.data[0].deletedAt).to.not.be.null 232 expect(res.body.data[0].deletedAt).to.not.be.null
234 expect(res.body.data[0].account).to.be.null 233 expect(res.body.data[0].account).to.be.null
235 expect(res.body.data[0].totalReplies).to.equal(3) 234 expect(res.body.data[0].totalReplies).to.equal(2)
236 expect(res.body.data[1].text).to.equal('super thread 2') 235 expect(res.body.data[1].text).to.equal('super thread 2')
237 expect(res.body.data[1].totalReplies).to.equal(0) 236 expect(res.body.data[1].totalReplies).to.equal(0)
238 expect(res.body.data[2].text).to.equal('super thread 3') 237 expect(res.body.data[2].text).to.equal('super thread 3')
diff --git a/server/tests/api/videos/video-playlists.ts b/server/tests/api/videos/video-playlists.ts
index 9dad58c8c..da8de054b 100644
--- a/server/tests/api/videos/video-playlists.ts
+++ b/server/tests/api/videos/video-playlists.ts
@@ -56,7 +56,7 @@ import {
56 removeServerFromServerBlocklist 56 removeServerFromServerBlocklist
57} from '../../../../shared/extra-utils/users/blocklist' 57} from '../../../../shared/extra-utils/users/blocklist'
58import { User } from '../../../../shared/models/users' 58import { User } from '../../../../shared/models/users'
59import { VideoPrivacy } from '../../../../shared/models/videos' 59import { VideoPlaylistCreateResult, VideoPrivacy } from '../../../../shared/models/videos'
60import { VideoExistInPlaylist } from '../../../../shared/models/videos/playlist/video-exist-in-playlist.model' 60import { VideoExistInPlaylist } from '../../../../shared/models/videos/playlist/video-exist-in-playlist.model'
61import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../../shared/models/videos/playlist/video-playlist-element.model' 61import { VideoPlaylistElement, VideoPlaylistElementType } from '../../../../shared/models/videos/playlist/video-playlist-element.model'
62import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model' 62import { VideoPlaylistPrivacy } from '../../../../shared/models/videos/playlist/video-playlist-privacy.model'
@@ -427,31 +427,45 @@ describe('Test video playlists', function () {
427 expect(data).to.have.lengthOf(0) 427 expect(data).to.have.lengthOf(0)
428 } 428 }
429 }) 429 })
430 })
430 431
431 it('Should not list unlisted or private playlists', async function () { 432 describe('Playlist rights', function () {
433 let unlistedPlaylist: VideoPlaylistCreateResult
434 let privatePlaylist: VideoPlaylistCreateResult
435
436 before(async function () {
432 this.timeout(30000) 437 this.timeout(30000)
433 438
434 await createVideoPlaylist({ 439 {
435 url: servers[1].url, 440 const res = await createVideoPlaylist({
436 token: servers[1].accessToken, 441 url: servers[1].url,
437 playlistAttrs: { 442 token: servers[1].accessToken,
438 displayName: 'playlist unlisted', 443 playlistAttrs: {
439 privacy: VideoPlaylistPrivacy.UNLISTED 444 displayName: 'playlist unlisted',
440 } 445 privacy: VideoPlaylistPrivacy.UNLISTED,
441 }) 446 videoChannelId: servers[1].videoChannel.id
447 }
448 })
449 unlistedPlaylist = res.body.videoPlaylist
450 }
442 451
443 await createVideoPlaylist({ 452 {
444 url: servers[1].url, 453 const res = await createVideoPlaylist({
445 token: servers[1].accessToken, 454 url: servers[1].url,
446 playlistAttrs: { 455 token: servers[1].accessToken,
447 displayName: 'playlist private', 456 playlistAttrs: {
448 privacy: VideoPlaylistPrivacy.PRIVATE 457 displayName: 'playlist private',
449 } 458 privacy: VideoPlaylistPrivacy.PRIVATE
450 }) 459 }
460 })
461 privatePlaylist = res.body.videoPlaylist
462 }
451 463
452 await waitJobs(servers) 464 await waitJobs(servers)
453 await wait(3000) 465 await wait(3000)
466 })
454 467
468 it('Should not list unlisted or private playlists', async function () {
455 for (const server of servers) { 469 for (const server of servers) {
456 const results = [ 470 const results = [
457 await getAccountPlaylistsList(server.url, 'root@localhost:' + servers[1].port, 0, 5, '-createdAt'), 471 await getAccountPlaylistsList(server.url, 'root@localhost:' + servers[1].port, 0, 5, '-createdAt'),
@@ -469,6 +483,27 @@ describe('Test video playlists', function () {
469 } 483 }
470 } 484 }
471 }) 485 })
486
487 it('Should not get unlisted playlist using only the id', async function () {
488 await getVideoPlaylist(servers[1].url, unlistedPlaylist.id, 404)
489 })
490
491 it('Should get unlisted plyaylist using uuid or shortUUID', async function () {
492 await getVideoPlaylist(servers[1].url, unlistedPlaylist.uuid)
493 await getVideoPlaylist(servers[1].url, unlistedPlaylist.shortUUID)
494 })
495
496 it('Should not get private playlist without token', async function () {
497 for (const id of [ privatePlaylist.id, privatePlaylist.uuid, privatePlaylist.shortUUID ]) {
498 await getVideoPlaylist(servers[1].url, id, 401)
499 }
500 })
501
502 it('Should get private playlist with a token', async function () {
503 for (const id of [ privatePlaylist.id, privatePlaylist.uuid, privatePlaylist.shortUUID ]) {
504 await getVideoPlaylistWithToken(servers[1].url, servers[1].accessToken, id)
505 }
506 })
472 }) 507 })
473 508
474 describe('Update playlists', function () { 509 describe('Update playlists', function () {
diff --git a/server/tests/api/videos/video-privacy.ts b/server/tests/api/videos/video-privacy.ts
index fed6ca0e0..950aeb7cf 100644
--- a/server/tests/api/videos/video-privacy.ts
+++ b/server/tests/api/videos/video-privacy.ts
@@ -1,8 +1,9 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import * as chai from 'chai'
4import 'mocha' 3import 'mocha'
5import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum' 4import * as chai from 'chai'
5import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
6import { Video, VideoCreateResult } from '@shared/models'
6import { 7import {
7 cleanupTests, 8 cleanupTests,
8 flushAndRunServer, 9 flushAndRunServer,
@@ -13,12 +14,11 @@ import {
13 uploadVideo 14 uploadVideo
14} from '../../../../shared/extra-utils/index' 15} from '../../../../shared/extra-utils/index'
15import { doubleFollow } from '../../../../shared/extra-utils/server/follows' 16import { doubleFollow } from '../../../../shared/extra-utils/server/follows'
17import { waitJobs } from '../../../../shared/extra-utils/server/jobs'
16import { userLogin } from '../../../../shared/extra-utils/users/login' 18import { userLogin } from '../../../../shared/extra-utils/users/login'
17import { createUser } from '../../../../shared/extra-utils/users/users' 19import { createUser } from '../../../../shared/extra-utils/users/users'
18import { getMyVideos, getVideo, getVideoWithToken, updateVideo } from '../../../../shared/extra-utils/videos/videos' 20import { getMyVideos, getVideo, getVideoWithToken, updateVideo } from '../../../../shared/extra-utils/videos/videos'
19import { waitJobs } from '../../../../shared/extra-utils/server/jobs' 21import { VideoPrivacy } from '../../../../shared/models/videos/video-privacy.enum'
20import { Video } from '@shared/models'
21import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
22 22
23const expect = chai.expect 23const expect = chai.expect
24 24
@@ -32,7 +32,7 @@ describe('Test video privacy', function () {
32 let internalVideoId: number 32 let internalVideoId: number
33 let internalVideoUUID: string 33 let internalVideoUUID: string
34 34
35 let unlistedVideoUUID: string 35 let unlistedVideo: VideoCreateResult
36 let nonFederatedUnlistedVideoUUID: string 36 let nonFederatedUnlistedVideoUUID: string
37 37
38 let now: number 38 let now: number
@@ -59,231 +59,246 @@ describe('Test video privacy', function () {
59 await doubleFollow(servers[0], servers[1]) 59 await doubleFollow(servers[0], servers[1])
60 }) 60 })
61 61
62 it('Should upload a private and internal videos on server 1', async function () { 62 describe('Private and internal videos', function () {
63 this.timeout(10000)
64 63
65 for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) { 64 it('Should upload a private and internal videos on server 1', async function () {
66 const attributes = { privacy } 65 this.timeout(10000)
67 await uploadVideo(servers[0].url, servers[0].accessToken, attributes)
68 }
69 66
70 await waitJobs(servers) 67 for (const privacy of [ VideoPrivacy.PRIVATE, VideoPrivacy.INTERNAL ]) {
71 }) 68 const attributes = { privacy }
69 await uploadVideo(servers[0].url, servers[0].accessToken, attributes)
70 }
72 71
73 it('Should not have these private and internal videos on server 2', async function () { 72 await waitJobs(servers)
74 const res = await getVideosList(servers[1].url) 73 })
75 74
76 expect(res.body.total).to.equal(0) 75 it('Should not have these private and internal videos on server 2', async function () {
77 expect(res.body.data).to.have.lengthOf(0) 76 const res = await getVideosList(servers[1].url)
78 })
79 77
80 it('Should not list the private and internal videos for an unauthenticated user on server 1', async function () { 78 expect(res.body.total).to.equal(0)
81 const res = await getVideosList(servers[0].url) 79 expect(res.body.data).to.have.lengthOf(0)
80 })
82 81
83 expect(res.body.total).to.equal(0) 82 it('Should not list the private and internal videos for an unauthenticated user on server 1', async function () {
84 expect(res.body.data).to.have.lengthOf(0) 83 const res = await getVideosList(servers[0].url)
85 }) 84
85 expect(res.body.total).to.equal(0)
86 expect(res.body.data).to.have.lengthOf(0)
87 })
86 88
87 it('Should not list the private video and list the internal video for an authenticated user on server 1', async function () { 89 it('Should not list the private video and list the internal video for an authenticated user on server 1', async function () {
88 const res = await getVideosListWithToken(servers[0].url, servers[0].accessToken) 90 const res = await getVideosListWithToken(servers[0].url, servers[0].accessToken)
89 91
90 expect(res.body.total).to.equal(1) 92 expect(res.body.total).to.equal(1)
91 expect(res.body.data).to.have.lengthOf(1) 93 expect(res.body.data).to.have.lengthOf(1)
92 94
93 expect(res.body.data[0].privacy.id).to.equal(VideoPrivacy.INTERNAL) 95 expect(res.body.data[0].privacy.id).to.equal(VideoPrivacy.INTERNAL)
94 }) 96 })
95 97
96 it('Should list my (private and internal) videos', async function () { 98 it('Should list my (private and internal) videos', async function () {
97 const res = await getMyVideos(servers[0].url, servers[0].accessToken, 0, 10) 99 const res = await getMyVideos(servers[0].url, servers[0].accessToken, 0, 10)
98 100
99 expect(res.body.total).to.equal(2) 101 expect(res.body.total).to.equal(2)
100 expect(res.body.data).to.have.lengthOf(2) 102 expect(res.body.data).to.have.lengthOf(2)
101 103
102 const videos: Video[] = res.body.data 104 const videos: Video[] = res.body.data
103 105
104 const privateVideo = videos.find(v => v.privacy.id === VideoPrivacy.PRIVATE) 106 const privateVideo = videos.find(v => v.privacy.id === VideoPrivacy.PRIVATE)
105 privateVideoId = privateVideo.id 107 privateVideoId = privateVideo.id
106 privateVideoUUID = privateVideo.uuid 108 privateVideoUUID = privateVideo.uuid
107 109
108 const internalVideo = videos.find(v => v.privacy.id === VideoPrivacy.INTERNAL) 110 const internalVideo = videos.find(v => v.privacy.id === VideoPrivacy.INTERNAL)
109 internalVideoId = internalVideo.id 111 internalVideoId = internalVideo.id
110 internalVideoUUID = internalVideo.uuid 112 internalVideoUUID = internalVideo.uuid
111 }) 113 })
112 114
113 it('Should not be able to watch the private/internal video with non authenticated user', async function () { 115 it('Should not be able to watch the private/internal video with non authenticated user', async function () {
114 await getVideo(servers[0].url, privateVideoUUID, HttpStatusCode.UNAUTHORIZED_401) 116 await getVideo(servers[0].url, privateVideoUUID, HttpStatusCode.UNAUTHORIZED_401)
115 await getVideo(servers[0].url, internalVideoUUID, HttpStatusCode.UNAUTHORIZED_401) 117 await getVideo(servers[0].url, internalVideoUUID, HttpStatusCode.UNAUTHORIZED_401)
116 }) 118 })
117 119
118 it('Should not be able to watch the private video with another user', async function () { 120 it('Should not be able to watch the private video with another user', async function () {
119 this.timeout(10000) 121 this.timeout(10000)
120 122
121 const user = { 123 const user = {
122 username: 'hello', 124 username: 'hello',
123 password: 'super password' 125 password: 'super password'
124 } 126 }
125 await createUser({ url: servers[0].url, accessToken: servers[0].accessToken, username: user.username, password: user.password }) 127 await createUser({ url: servers[0].url, accessToken: servers[0].accessToken, username: user.username, password: user.password })
126 128
127 anotherUserToken = await userLogin(servers[0], user) 129 anotherUserToken = await userLogin(servers[0], user)
128 await getVideoWithToken(servers[0].url, anotherUserToken, privateVideoUUID, HttpStatusCode.FORBIDDEN_403) 130 await getVideoWithToken(servers[0].url, anotherUserToken, privateVideoUUID, HttpStatusCode.FORBIDDEN_403)
129 }) 131 })
130 132
131 it('Should be able to watch the internal video with another user', async function () { 133 it('Should be able to watch the internal video with another user', async function () {
132 await getVideoWithToken(servers[0].url, anotherUserToken, internalVideoUUID, HttpStatusCode.OK_200) 134 await getVideoWithToken(servers[0].url, anotherUserToken, internalVideoUUID, HttpStatusCode.OK_200)
133 }) 135 })
134 136
135 it('Should be able to watch the private video with the correct user', async function () { 137 it('Should be able to watch the private video with the correct user', async function () {
136 await getVideoWithToken(servers[0].url, servers[0].accessToken, privateVideoUUID, HttpStatusCode.OK_200) 138 await getVideoWithToken(servers[0].url, servers[0].accessToken, privateVideoUUID, HttpStatusCode.OK_200)
139 })
137 }) 140 })
138 141
139 it('Should upload an unlisted video on server 2', async function () { 142 describe('Unlisted videos', function () {
140 this.timeout(60000)
141 143
142 const attributes = { 144 it('Should upload an unlisted video on server 2', async function () {
143 name: 'unlisted video', 145 this.timeout(60000)
144 privacy: VideoPrivacy.UNLISTED
145 }
146 await uploadVideo(servers[1].url, servers[1].accessToken, attributes)
147 146
148 // Server 2 has transcoding enabled 147 const attributes = {
149 await waitJobs(servers) 148 name: 'unlisted video',
150 }) 149 privacy: VideoPrivacy.UNLISTED
150 }
151 await uploadVideo(servers[1].url, servers[1].accessToken, attributes)
151 152
152 it('Should not have this unlisted video listed on server 1 and 2', async function () { 153 // Server 2 has transcoding enabled
153 for (const server of servers) { 154 await waitJobs(servers)
154 const res = await getVideosList(server.url) 155 })
155 156
156 expect(res.body.total).to.equal(0) 157 it('Should not have this unlisted video listed on server 1 and 2', async function () {
157 expect(res.body.data).to.have.lengthOf(0) 158 for (const server of servers) {
158 } 159 const res = await getVideosList(server.url)
159 })
160 160
161 it('Should list my (unlisted) videos', async function () { 161 expect(res.body.total).to.equal(0)
162 const res = await getMyVideos(servers[1].url, servers[1].accessToken, 0, 1) 162 expect(res.body.data).to.have.lengthOf(0)
163 }
164 })
163 165
164 expect(res.body.total).to.equal(1) 166 it('Should list my (unlisted) videos', async function () {
165 expect(res.body.data).to.have.lengthOf(1) 167 const res = await getMyVideos(servers[1].url, servers[1].accessToken, 0, 1)
166 168
167 unlistedVideoUUID = res.body.data[0].uuid 169 expect(res.body.total).to.equal(1)
168 }) 170 expect(res.body.data).to.have.lengthOf(1)
169 171
170 it('Should be able to get this unlisted video', async function () { 172 unlistedVideo = res.body.data[0]
171 for (const server of servers) { 173 })
172 const res = await getVideo(server.url, unlistedVideoUUID)
173 174
174 expect(res.body.name).to.equal('unlisted video') 175 it('Should not be able to get this unlisted video using its id', async function () {
175 } 176 await getVideo(servers[1].url, unlistedVideo.id, 404)
176 }) 177 })
177 178
178 it('Should upload a non-federating unlisted video to server 1', async function () { 179 it('Should be able to get this unlisted video using its uuid/shortUUID', async function () {
179 this.timeout(30000) 180 for (const server of servers) {
181 for (const id of [ unlistedVideo.uuid, unlistedVideo.shortUUID ]) {
182 const res = await getVideo(server.url, id)
180 183
181 const attributes = { 184 expect(res.body.name).to.equal('unlisted video')
182 name: 'unlisted video', 185 }
183 privacy: VideoPrivacy.UNLISTED 186 }
184 } 187 })
185 await uploadVideo(servers[0].url, servers[0].accessToken, attributes)
186 188
187 await waitJobs(servers) 189 it('Should upload a non-federating unlisted video to server 1', async function () {
188 }) 190 this.timeout(30000)
191
192 const attributes = {
193 name: 'unlisted video',
194 privacy: VideoPrivacy.UNLISTED
195 }
196 await uploadVideo(servers[0].url, servers[0].accessToken, attributes)
189 197
190 it('Should list my new unlisted video', async function () { 198 await waitJobs(servers)
191 const res = await getMyVideos(servers[0].url, servers[0].accessToken, 0, 3) 199 })
192 200
193 expect(res.body.total).to.equal(3) 201 it('Should list my new unlisted video', async function () {
194 expect(res.body.data).to.have.lengthOf(3) 202 const res = await getMyVideos(servers[0].url, servers[0].accessToken, 0, 3)
195 203
196 nonFederatedUnlistedVideoUUID = res.body.data[0].uuid 204 expect(res.body.total).to.equal(3)
197 }) 205 expect(res.body.data).to.have.lengthOf(3)
198 206
199 it('Should be able to get non-federated unlisted video from origin', async function () { 207 nonFederatedUnlistedVideoUUID = res.body.data[0].uuid
200 const res = await getVideo(servers[0].url, nonFederatedUnlistedVideoUUID) 208 })
201 209
202 expect(res.body.name).to.equal('unlisted video') 210 it('Should be able to get non-federated unlisted video from origin', async function () {
203 }) 211 const res = await getVideo(servers[0].url, nonFederatedUnlistedVideoUUID)
204 212
205 it('Should not be able to get non-federated unlisted video from federated server', async function () { 213 expect(res.body.name).to.equal('unlisted video')
206 await getVideo(servers[1].url, nonFederatedUnlistedVideoUUID, HttpStatusCode.NOT_FOUND_404) 214 })
215
216 it('Should not be able to get non-federated unlisted video from federated server', async function () {
217 await getVideo(servers[1].url, nonFederatedUnlistedVideoUUID, HttpStatusCode.NOT_FOUND_404)
218 })
207 }) 219 })
208 220
209 it('Should update the private and internal videos to public on server 1', async function () { 221 describe('Privacy update', function () {
210 this.timeout(10000)
211 222
212 now = Date.now() 223 it('Should update the private and internal videos to public on server 1', async function () {
224 this.timeout(10000)
213 225
214 { 226 now = Date.now()
215 const attribute = {
216 name: 'private video becomes public',
217 privacy: VideoPrivacy.PUBLIC
218 }
219 227
220 await updateVideo(servers[0].url, servers[0].accessToken, privateVideoId, attribute) 228 {
221 } 229 const attribute = {
230 name: 'private video becomes public',
231 privacy: VideoPrivacy.PUBLIC
232 }
222 233
223 { 234 await updateVideo(servers[0].url, servers[0].accessToken, privateVideoId, attribute)
224 const attribute = {
225 name: 'internal video becomes public',
226 privacy: VideoPrivacy.PUBLIC
227 } 235 }
228 await updateVideo(servers[0].url, servers[0].accessToken, internalVideoId, attribute)
229 }
230 236
231 await waitJobs(servers) 237 {
232 }) 238 const attribute = {
239 name: 'internal video becomes public',
240 privacy: VideoPrivacy.PUBLIC
241 }
242 await updateVideo(servers[0].url, servers[0].accessToken, internalVideoId, attribute)
243 }
233 244
234 it('Should have this new public video listed on server 1 and 2', async function () { 245 await waitJobs(servers)
235 for (const server of servers) { 246 })
236 const res = await getVideosList(server.url)
237 expect(res.body.total).to.equal(2)
238 expect(res.body.data).to.have.lengthOf(2)
239 247
240 const videos: Video[] = res.body.data 248 it('Should have this new public video listed on server 1 and 2', async function () {
241 const privateVideo = videos.find(v => v.name === 'private video becomes public') 249 for (const server of servers) {
242 const internalVideo = videos.find(v => v.name === 'internal video becomes public') 250 const res = await getVideosList(server.url)
251 expect(res.body.total).to.equal(2)
252 expect(res.body.data).to.have.lengthOf(2)
243 253
244 expect(privateVideo).to.not.be.undefined 254 const videos: Video[] = res.body.data
245 expect(internalVideo).to.not.be.undefined 255 const privateVideo = videos.find(v => v.name === 'private video becomes public')
256 const internalVideo = videos.find(v => v.name === 'internal video becomes public')
246 257
247 expect(new Date(privateVideo.publishedAt).getTime()).to.be.at.least(now) 258 expect(privateVideo).to.not.be.undefined
248 // We don't change the publish date of internal videos 259 expect(internalVideo).to.not.be.undefined
249 expect(new Date(internalVideo.publishedAt).getTime()).to.be.below(now)
250 260
251 expect(privateVideo.privacy.id).to.equal(VideoPrivacy.PUBLIC) 261 expect(new Date(privateVideo.publishedAt).getTime()).to.be.at.least(now)
252 expect(internalVideo.privacy.id).to.equal(VideoPrivacy.PUBLIC) 262 // We don't change the publish date of internal videos
253 } 263 expect(new Date(internalVideo.publishedAt).getTime()).to.be.below(now)
254 })
255 264
256 it('Should set these videos as private and internal', async function () { 265 expect(privateVideo.privacy.id).to.equal(VideoPrivacy.PUBLIC)
257 this.timeout(10000) 266 expect(internalVideo.privacy.id).to.equal(VideoPrivacy.PUBLIC)
267 }
268 })
258 269
259 await updateVideo(servers[0].url, servers[0].accessToken, internalVideoId, { privacy: VideoPrivacy.PRIVATE }) 270 it('Should set these videos as private and internal', async function () {
260 await updateVideo(servers[0].url, servers[0].accessToken, privateVideoId, { privacy: VideoPrivacy.INTERNAL }) 271 this.timeout(10000)
261 272
262 await waitJobs(servers) 273 await updateVideo(servers[0].url, servers[0].accessToken, internalVideoId, { privacy: VideoPrivacy.PRIVATE })
274 await updateVideo(servers[0].url, servers[0].accessToken, privateVideoId, { privacy: VideoPrivacy.INTERNAL })
263 275
264 for (const server of servers) { 276 await waitJobs(servers)
265 const res = await getVideosList(server.url)
266 277
267 expect(res.body.total).to.equal(0) 278 for (const server of servers) {
268 expect(res.body.data).to.have.lengthOf(0) 279 const res = await getVideosList(server.url)
269 } 280
281 expect(res.body.total).to.equal(0)
282 expect(res.body.data).to.have.lengthOf(0)
283 }
270 284
271 { 285 {
272 const res = await getMyVideos(servers[0].url, servers[0].accessToken, 0, 5) 286 const res = await getMyVideos(servers[0].url, servers[0].accessToken, 0, 5)
273 const videos = res.body.data 287 const videos = res.body.data
274 288
275 expect(res.body.total).to.equal(3) 289 expect(res.body.total).to.equal(3)
276 expect(videos).to.have.lengthOf(3) 290 expect(videos).to.have.lengthOf(3)
277 291
278 const privateVideo = videos.find(v => v.name === 'private video becomes public') 292 const privateVideo = videos.find(v => v.name === 'private video becomes public')
279 const internalVideo = videos.find(v => v.name === 'internal video becomes public') 293 const internalVideo = videos.find(v => v.name === 'internal video becomes public')
280 294
281 expect(privateVideo).to.not.be.undefined 295 expect(privateVideo).to.not.be.undefined
282 expect(internalVideo).to.not.be.undefined 296 expect(internalVideo).to.not.be.undefined
283 297
284 expect(privateVideo.privacy.id).to.equal(VideoPrivacy.INTERNAL) 298 expect(privateVideo.privacy.id).to.equal(VideoPrivacy.INTERNAL)
285 expect(internalVideo.privacy.id).to.equal(VideoPrivacy.PRIVATE) 299 expect(internalVideo.privacy.id).to.equal(VideoPrivacy.PRIVATE)
286 } 300 }
301 })
287 }) 302 })
288 303
289 after(async function () { 304 after(async function () {
diff --git a/server/tests/api/videos/videos-filter.ts b/server/tests/api/videos/videos-filter.ts
index 2961c8e78..7428b82c5 100644
--- a/server/tests/api/videos/videos-filter.ts
+++ b/server/tests/api/videos/videos-filter.ts
@@ -47,13 +47,13 @@ async function getVideosNames (server: ServerInfo, token: string, filter: string
47 return videosResults 47 return videosResults
48} 48}
49 49
50describe('Test videos filter validator', function () { 50describe('Test videos filter', function () {
51 let servers: ServerInfo[] 51 let servers: ServerInfo[]
52 52
53 // --------------------------------------------------------------- 53 // ---------------------------------------------------------------
54 54
55 before(async function () { 55 before(async function () {
56 this.timeout(120000) 56 this.timeout(160000)
57 57
58 servers = await flushAndRunMultipleServers(2) 58 servers = await flushAndRunMultipleServers(2)
59 59
diff --git a/server/tests/api/videos/videos-overview.ts b/server/tests/api/videos/videos-overview.ts
index 7889bcf80..c266a1dc5 100644
--- a/server/tests/api/videos/videos-overview.ts
+++ b/server/tests/api/videos/videos-overview.ts
@@ -45,7 +45,7 @@ describe('Test a videos overview', function () {
45 }) 45 })
46 46
47 it('Should upload 5 videos in a specific category, tag and channel but not include them in overview', async function () { 47 it('Should upload 5 videos in a specific category, tag and channel but not include them in overview', async function () {
48 this.timeout(15000) 48 this.timeout(30000)
49 49
50 await wait(3000) 50 await wait(3000)
51 51
@@ -61,7 +61,7 @@ describe('Test a videos overview', function () {
61 }) 61 })
62 62
63 it('Should upload another video and include all videos in the overview', async function () { 63 it('Should upload another video and include all videos in the overview', async function () {
64 this.timeout(15000) 64 this.timeout(30000)
65 65
66 for (let i = 1; i < 6; i++) { 66 for (let i = 1; i < 6; i++) {
67 await uploadVideo(server.url, server.accessToken, { 67 await uploadVideo(server.url, server.accessToken, {
diff --git a/server/tests/cli/prune-storage.ts b/server/tests/cli/prune-storage.ts
index 591ed217f..a0af09de8 100644
--- a/server/tests/cli/prune-storage.ts
+++ b/server/tests/cli/prune-storage.ts
@@ -2,7 +2,10 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import { waitJobs } from '../../../shared/extra-utils/server/jobs' 5import { createFile, readdir } from 'fs-extra'
6import { join } from 'path'
7import { buildUUID } from '@server/helpers/uuid'
8import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
6import { 9import {
7 buildServerDirectory, 10 buildServerDirectory,
8 cleanupTests, 11 cleanupTests,
@@ -12,6 +15,7 @@ import {
12 flushAndRunMultipleServers, 15 flushAndRunMultipleServers,
13 getAccount, 16 getAccount,
14 getEnvCli, 17 getEnvCli,
18 killallServers,
15 makeGetRequest, 19 makeGetRequest,
16 ServerInfo, 20 ServerInfo,
17 setAccessTokensToServers, 21 setAccessTokensToServers,
@@ -20,11 +24,8 @@ import {
20 uploadVideo, 24 uploadVideo,
21 wait 25 wait
22} from '../../../shared/extra-utils' 26} from '../../../shared/extra-utils'
27import { waitJobs } from '../../../shared/extra-utils/server/jobs'
23import { Account, VideoPlaylistPrivacy } from '../../../shared/models' 28import { Account, VideoPlaylistPrivacy } from '../../../shared/models'
24import { createFile, readdir } from 'fs-extra'
25import { v4 as uuidv4 } from 'uuid'
26import { join } from 'path'
27import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-codes'
28 29
29const expect = chai.expect 30const expect = chai.expect
30 31
@@ -116,6 +117,9 @@ describe('Test prune storage scripts', function () {
116 await wait(1000) 117 await wait(1000)
117 118
118 await waitJobs(servers) 119 await waitJobs(servers)
120 killallServers(servers)
121
122 await wait(1000)
119 }) 123 })
120 124
121 it('Should have the files on the disk', async function () { 125 it('Should have the files on the disk', async function () {
@@ -127,8 +131,8 @@ describe('Test prune storage scripts', function () {
127 { 131 {
128 const base = buildServerDirectory(servers[0], 'videos') 132 const base = buildServerDirectory(servers[0], 'videos')
129 133
130 const n1 = uuidv4() + '.mp4' 134 const n1 = buildUUID() + '.mp4'
131 const n2 = uuidv4() + '.webm' 135 const n2 = buildUUID() + '.webm'
132 136
133 await createFile(join(base, n1)) 137 await createFile(join(base, n1))
134 await createFile(join(base, n2)) 138 await createFile(join(base, n2))
@@ -139,8 +143,8 @@ describe('Test prune storage scripts', function () {
139 { 143 {
140 const base = buildServerDirectory(servers[0], 'torrents') 144 const base = buildServerDirectory(servers[0], 'torrents')
141 145
142 const n1 = uuidv4() + '-240.torrent' 146 const n1 = buildUUID() + '-240.torrent'
143 const n2 = uuidv4() + '-480.torrent' 147 const n2 = buildUUID() + '-480.torrent'
144 148
145 await createFile(join(base, n1)) 149 await createFile(join(base, n1))
146 await createFile(join(base, n2)) 150 await createFile(join(base, n2))
@@ -151,8 +155,8 @@ describe('Test prune storage scripts', function () {
151 { 155 {
152 const base = buildServerDirectory(servers[0], 'thumbnails') 156 const base = buildServerDirectory(servers[0], 'thumbnails')
153 157
154 const n1 = uuidv4() + '.jpg' 158 const n1 = buildUUID() + '.jpg'
155 const n2 = uuidv4() + '.jpg' 159 const n2 = buildUUID() + '.jpg'
156 160
157 await createFile(join(base, n1)) 161 await createFile(join(base, n1))
158 await createFile(join(base, n2)) 162 await createFile(join(base, n2))
@@ -163,8 +167,8 @@ describe('Test prune storage scripts', function () {
163 { 167 {
164 const base = buildServerDirectory(servers[0], 'previews') 168 const base = buildServerDirectory(servers[0], 'previews')
165 169
166 const n1 = uuidv4() + '.jpg' 170 const n1 = buildUUID() + '.jpg'
167 const n2 = uuidv4() + '.jpg' 171 const n2 = buildUUID() + '.jpg'
168 172
169 await createFile(join(base, n1)) 173 await createFile(join(base, n1))
170 await createFile(join(base, n2)) 174 await createFile(join(base, n2))
@@ -175,8 +179,8 @@ describe('Test prune storage scripts', function () {
175 { 179 {
176 const base = buildServerDirectory(servers[0], 'avatars') 180 const base = buildServerDirectory(servers[0], 'avatars')
177 181
178 const n1 = uuidv4() + '.png' 182 const n1 = buildUUID() + '.png'
179 const n2 = uuidv4() + '.jpg' 183 const n2 = buildUUID() + '.jpg'
180 184
181 await createFile(join(base, n1)) 185 await createFile(join(base, n1))
182 await createFile(join(base, n2)) 186 await createFile(join(base, n2))
diff --git a/server/tests/client.ts b/server/tests/client.ts
index 3c99bcd1f..7c4fb4e46 100644
--- a/server/tests/client.ts
+++ b/server/tests/client.ts
@@ -2,8 +2,9 @@
2 2
3import 'mocha' 3import 'mocha'
4import * as chai from 'chai' 4import * as chai from 'chai'
5import * as request from 'supertest' 5import { omit } from 'lodash'
6import { Account, VideoPlaylistPrivacy } from '@shared/models' 6import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
7import { Account, CustomConfig, HTMLServerConfig, ServerConfig, VideoPlaylistCreateResult, VideoPlaylistPrivacy } from '@shared/models'
7import { 8import {
8 addVideoInPlaylist, 9 addVideoInPlaylist,
9 cleanupTests, 10 cleanupTests,
@@ -11,8 +12,10 @@ import {
11 doubleFollow, 12 doubleFollow,
12 flushAndRunMultipleServers, 13 flushAndRunMultipleServers,
13 getAccount, 14 getAccount,
15 getConfig,
14 getCustomConfig, 16 getCustomConfig,
15 getVideosList, 17 getVideosList,
18 makeGetRequest,
16 makeHTMLRequest, 19 makeHTMLRequest,
17 ServerInfo, 20 ServerInfo,
18 setAccessTokensToServers, 21 setAccessTokensToServers,
@@ -24,14 +27,16 @@ import {
24 uploadVideo, 27 uploadVideo,
25 waitJobs 28 waitJobs
26} from '../../shared/extra-utils' 29} from '../../shared/extra-utils'
27import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
28 30
29const expect = chai.expect 31const expect = chai.expect
30 32
31function checkIndexTags (html: string, title: string, description: string, css: string) { 33function checkIndexTags (html: string, title: string, description: string, css: string, config: ServerConfig) {
32 expect(html).to.contain('<title>' + title + '</title>') 34 expect(html).to.contain('<title>' + title + '</title>')
33 expect(html).to.contain('<meta name="description" content="' + description + '" />') 35 expect(html).to.contain('<meta name="description" content="' + description + '" />')
34 expect(html).to.contain('<style class="custom-css-style">' + css + '</style>') 36 expect(html).to.contain('<style class="custom-css-style">' + css + '</style>')
37
38 const htmlConfig: HTMLServerConfig = omit(config, 'signup')
39 expect(html).to.contain(`<script type="application/javascript">window.PeerTubeServerConfig = '${JSON.stringify(htmlConfig)}'</script>`)
35} 40}
36 41
37describe('Test a client controllers', function () { 42describe('Test a client controllers', function () {
@@ -44,10 +49,16 @@ describe('Test a client controllers', function () {
44 49
45 const playlistName = 'super playlist name' 50 const playlistName = 'super playlist name'
46 const playlistDescription = 'super playlist description' 51 const playlistDescription = 'super playlist description'
47 let playlistUUID: string 52 let playlist: VideoPlaylistCreateResult
48 53
49 const channelDescription = 'my super channel description' 54 const channelDescription = 'my super channel description'
50 55
56 const watchVideoBasePaths = [ '/videos/watch/', '/w/' ]
57 const watchPlaylistBasePaths = [ '/videos/watch/playlist/', '/w/p/' ]
58
59 let videoIds: (string | number)[] = []
60 let playlistIds: (string | number)[] = []
61
51 before(async function () { 62 before(async function () {
52 this.timeout(120000) 63 this.timeout(120000)
53 64
@@ -70,7 +81,9 @@ describe('Test a client controllers', function () {
70 const videos = resVideosRequest.body.data 81 const videos = resVideosRequest.body.data
71 expect(videos.length).to.equal(1) 82 expect(videos.length).to.equal(1)
72 83
73 servers[0].video = videos[0] 84 const video = videos[0]
85 servers[0].video = video
86 videoIds = [ video.id, video.uuid, video.shortUUID ]
74 87
75 // Playlist 88 // Playlist
76 89
@@ -82,16 +95,14 @@ describe('Test a client controllers', function () {
82 } 95 }
83 96
84 const resVideoPlaylistRequest = await createVideoPlaylist({ url: servers[0].url, token: servers[0].accessToken, playlistAttrs }) 97 const resVideoPlaylistRequest = await createVideoPlaylist({ url: servers[0].url, token: servers[0].accessToken, playlistAttrs })
85 98 playlist = resVideoPlaylistRequest.body.videoPlaylist
86 const playlist = resVideoPlaylistRequest.body.videoPlaylist 99 playlistIds = [ playlist.id, playlist.shortUUID, playlist.uuid ]
87 const playlistId = playlist.id
88 playlistUUID = playlist.uuid
89 100
90 await addVideoInPlaylist({ 101 await addVideoInPlaylist({
91 url: servers[0].url, 102 url: servers[0].url,
92 token: servers[0].accessToken, 103 token: servers[0].accessToken,
93 playlistId, 104 playlistId: playlist.shortUUID,
94 elementAttrs: { videoId: servers[0].video.id } 105 elementAttrs: { videoId: video.id }
95 }) 106 })
96 107
97 // Account 108 // Account
@@ -105,201 +116,277 @@ describe('Test a client controllers', function () {
105 }) 116 })
106 117
107 describe('oEmbed', function () { 118 describe('oEmbed', function () {
119
108 it('Should have valid oEmbed discovery tags for videos', async function () { 120 it('Should have valid oEmbed discovery tags for videos', async function () {
109 const path = '/videos/watch/' + servers[0].video.uuid 121 for (const basePath of watchVideoBasePaths) {
110 const res = await request(servers[0].url) 122 for (const id of videoIds) {
111 .get(path) 123 const res = await makeGetRequest({
112 .set('Accept', 'text/html') 124 url: servers[0].url,
113 .expect(HttpStatusCode.OK_200) 125 path: basePath + id,
126 accept: 'text/html',
127 statusCodeExpected: HttpStatusCode.OK_200
128 })
129
130 const port = servers[0].port
131
132 const expectedLink = '<link rel="alternate" type="application/json+oembed" href="http://localhost:' + port + '/services/oembed?' +
133 `url=http%3A%2F%2Flocalhost%3A${port}%2Fw%2F${servers[0].video.uuid}" ` +
134 `title="${servers[0].video.name}" />`
135
136 expect(res.text).to.contain(expectedLink)
137 }
138 }
139 })
114 140
115 const port = servers[0].port 141 it('Should have valid oEmbed discovery tags for a playlist', async function () {
142 for (const basePath of watchPlaylistBasePaths) {
143 for (const id of playlistIds) {
144 const res = await makeGetRequest({
145 url: servers[0].url,
146 path: basePath + id,
147 accept: 'text/html',
148 statusCodeExpected: HttpStatusCode.OK_200
149 })
150
151 const port = servers[0].port
152
153 const expectedLink = '<link rel="alternate" type="application/json+oembed" href="http://localhost:' + port + '/services/oembed?' +
154 `url=http%3A%2F%2Flocalhost%3A${port}%2Fw%2Fp%2F${playlist.uuid}" ` +
155 `title="${playlistName}" />`
156
157 expect(res.text).to.contain(expectedLink)
158 }
159 }
160 })
161 })
116 162
117 const expectedLink = '<link rel="alternate" type="application/json+oembed" href="http://localhost:' + port + '/services/oembed?' + 163 describe('Open Graph', function () {
118 `url=http%3A%2F%2Flocalhost%3A${port}%2Fvideos%2Fwatch%2F${servers[0].video.uuid}" ` +
119 `title="${servers[0].video.name}" />`
120 164
121 expect(res.text).to.contain(expectedLink) 165 async function accountPageTest (path: string) {
122 }) 166 const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', statusCodeExpected: HttpStatusCode.OK_200 })
167 const text = res.text
123 168
124 it('Should have valid oEmbed discovery tags for a playlist', async function () { 169 expect(text).to.contain(`<meta property="og:title" content="${account.displayName}" />`)
125 const res = await request(servers[0].url) 170 expect(text).to.contain(`<meta property="og:description" content="${account.description}" />`)
126 .get('/videos/watch/playlist/' + playlistUUID) 171 expect(text).to.contain('<meta property="og:type" content="website" />')
127 .set('Accept', 'text/html') 172 expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/accounts/${servers[0].user.username}" />`)
128 .expect(HttpStatusCode.OK_200) 173 }
129 174
130 const port = servers[0].port 175 async function channelPageTest (path: string) {
176 const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', statusCodeExpected: HttpStatusCode.OK_200 })
177 const text = res.text
131 178
132 const expectedLink = '<link rel="alternate" type="application/json+oembed" href="http://localhost:' + port + '/services/oembed?' + 179 expect(text).to.contain(`<meta property="og:title" content="${servers[0].videoChannel.displayName}" />`)
133 `url=http%3A%2F%2Flocalhost%3A${port}%2Fvideos%2Fwatch%2Fplaylist%2F${playlistUUID}" ` + 180 expect(text).to.contain(`<meta property="og:description" content="${channelDescription}" />`)
134 `title="${playlistName}" />` 181 expect(text).to.contain('<meta property="og:type" content="website" />')
182 expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/video-channels/${servers[0].videoChannel.name}" />`)
183 }
135 184
136 expect(res.text).to.contain(expectedLink) 185 async function watchVideoPageTest (path: string) {
137 }) 186 const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', statusCodeExpected: HttpStatusCode.OK_200 })
138 }) 187 const text = res.text
139 188
140 describe('Open Graph', function () { 189 expect(text).to.contain(`<meta property="og:title" content="${videoName}" />`)
190 expect(text).to.contain(`<meta property="og:description" content="${videoDescriptionPlainText}" />`)
191 expect(text).to.contain('<meta property="og:type" content="video" />')
192 expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/w/${servers[0].video.uuid}" />`)
193 }
194
195 async function watchPlaylistPageTest (path: string) {
196 const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', statusCodeExpected: HttpStatusCode.OK_200 })
197 const text = res.text
198
199 expect(text).to.contain(`<meta property="og:title" content="${playlistName}" />`)
200 expect(text).to.contain(`<meta property="og:description" content="${playlistDescription}" />`)
201 expect(text).to.contain('<meta property="og:type" content="video" />')
202 expect(text).to.contain(`<meta property="og:url" content="${servers[0].url}/w/p/${playlist.uuid}" />`)
203 }
141 204
142 it('Should have valid Open Graph tags on the account page', async function () { 205 it('Should have valid Open Graph tags on the account page', async function () {
143 const res = await request(servers[0].url) 206 await accountPageTest('/accounts/' + servers[0].user.username)
144 .get('/accounts/' + servers[0].user.username) 207 await accountPageTest('/a/' + servers[0].user.username)
145 .set('Accept', 'text/html') 208 await accountPageTest('/@' + servers[0].user.username)
146 .expect(HttpStatusCode.OK_200)
147
148 expect(res.text).to.contain(`<meta property="og:title" content="${account.displayName}" />`)
149 expect(res.text).to.contain(`<meta property="og:description" content="${account.description}" />`)
150 expect(res.text).to.contain('<meta property="og:type" content="website" />')
151 expect(res.text).to.contain(`<meta property="og:url" content="${servers[0].url}/accounts/${servers[0].user.username}" />`)
152 }) 209 })
153 210
154 it('Should have valid Open Graph tags on the channel page', async function () { 211 it('Should have valid Open Graph tags on the channel page', async function () {
155 const res = await request(servers[0].url) 212 await channelPageTest('/video-channels/' + servers[0].videoChannel.name)
156 .get('/video-channels/' + servers[0].videoChannel.name) 213 await channelPageTest('/c/' + servers[0].videoChannel.name)
157 .set('Accept', 'text/html') 214 await channelPageTest('/@' + servers[0].videoChannel.name)
158 .expect(HttpStatusCode.OK_200)
159
160 expect(res.text).to.contain(`<meta property="og:title" content="${servers[0].videoChannel.displayName}" />`)
161 expect(res.text).to.contain(`<meta property="og:description" content="${channelDescription}" />`)
162 expect(res.text).to.contain('<meta property="og:type" content="website" />')
163 expect(res.text).to.contain(`<meta property="og:url" content="${servers[0].url}/video-channels/${servers[0].videoChannel.name}" />`)
164 }) 215 })
165 216
166 it('Should have valid Open Graph tags on the watch page with video id', async function () { 217 it('Should have valid Open Graph tags on the watch page', async function () {
167 const res = await request(servers[0].url) 218 for (const path of watchVideoBasePaths) {
168 .get('/videos/watch/' + servers[0].video.id) 219 for (const id of videoIds) {
169 .set('Accept', 'text/html') 220 await watchVideoPageTest(path + id)
170 .expect(HttpStatusCode.OK_200) 221 }
171 222 }
172 expect(res.text).to.contain(`<meta property="og:title" content="${videoName}" />`)
173 expect(res.text).to.contain(`<meta property="og:description" content="${videoDescriptionPlainText}" />`)
174 expect(res.text).to.contain('<meta property="og:type" content="video" />')
175 expect(res.text).to.contain(`<meta property="og:url" content="${servers[0].url}/videos/watch/${servers[0].video.uuid}" />`)
176 })
177
178 it('Should have valid Open Graph tags on the watch page with video uuid', async function () {
179 const res = await request(servers[0].url)
180 .get('/videos/watch/' + servers[0].video.uuid)
181 .set('Accept', 'text/html')
182 .expect(HttpStatusCode.OK_200)
183
184 expect(res.text).to.contain(`<meta property="og:title" content="${videoName}" />`)
185 expect(res.text).to.contain(`<meta property="og:description" content="${videoDescriptionPlainText}" />`)
186 expect(res.text).to.contain('<meta property="og:type" content="video" />')
187 expect(res.text).to.contain(`<meta property="og:url" content="${servers[0].url}/videos/watch/${servers[0].video.uuid}" />`)
188 }) 223 })
189 224
190 it('Should have valid Open Graph tags on the watch playlist page', async function () { 225 it('Should have valid Open Graph tags on the watch playlist page', async function () {
191 const res = await request(servers[0].url) 226 for (const path of watchPlaylistBasePaths) {
192 .get('/videos/watch/playlist/' + playlistUUID) 227 for (const id of playlistIds) {
193 .set('Accept', 'text/html') 228 await watchPlaylistPageTest(path + id)
194 .expect(HttpStatusCode.OK_200) 229 }
195 230 }
196 expect(res.text).to.contain(`<meta property="og:title" content="${playlistName}" />`)
197 expect(res.text).to.contain(`<meta property="og:description" content="${playlistDescription}" />`)
198 expect(res.text).to.contain('<meta property="og:type" content="video" />')
199 expect(res.text).to.contain(`<meta property="og:url" content="${servers[0].url}/videos/watch/playlist/${playlistUUID}" />`)
200 }) 231 })
201 }) 232 })
202 233
203 describe('Twitter card', async function () { 234 describe('Twitter card', async function () {
204 235
205 it('Should have valid twitter card on the watch video page', async function () { 236 describe('Not whitelisted', function () {
206 const res = await request(servers[0].url)
207 .get('/videos/watch/' + servers[0].video.uuid)
208 .set('Accept', 'text/html')
209 .expect(HttpStatusCode.OK_200)
210 237
211 expect(res.text).to.contain('<meta property="twitter:card" content="summary_large_image" />') 238 async function accountPageTest (path: string) {
212 expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />') 239 const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', statusCodeExpected: HttpStatusCode.OK_200 })
213 expect(res.text).to.contain(`<meta property="twitter:title" content="${videoName}" />`) 240 const text = res.text
214 expect(res.text).to.contain(`<meta property="twitter:description" content="${videoDescriptionPlainText}" />`)
215 })
216 241
217 it('Should have valid twitter card on the watch playlist page', async function () { 242 expect(text).to.contain('<meta property="twitter:card" content="summary" />')
218 const res = await request(servers[0].url) 243 expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
219 .get('/videos/watch/playlist/' + playlistUUID) 244 expect(text).to.contain(`<meta property="twitter:title" content="${account.name}" />`)
220 .set('Accept', 'text/html') 245 expect(text).to.contain(`<meta property="twitter:description" content="${account.description}" />`)
221 .expect(HttpStatusCode.OK_200) 246 }
222 247
223 expect(res.text).to.contain('<meta property="twitter:card" content="summary" />') 248 async function channelPageTest (path: string) {
224 expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />') 249 const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', statusCodeExpected: HttpStatusCode.OK_200 })
225 expect(res.text).to.contain(`<meta property="twitter:title" content="${playlistName}" />`) 250 const text = res.text
226 expect(res.text).to.contain(`<meta property="twitter:description" content="${playlistDescription}" />`)
227 })
228 251
229 it('Should have valid twitter card on the account page', async function () { 252 expect(text).to.contain('<meta property="twitter:card" content="summary" />')
230 const res = await request(servers[0].url) 253 expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
231 .get('/accounts/' + account.name) 254 expect(text).to.contain(`<meta property="twitter:title" content="${servers[0].videoChannel.displayName}" />`)
232 .set('Accept', 'text/html') 255 expect(text).to.contain(`<meta property="twitter:description" content="${channelDescription}" />`)
233 .expect(HttpStatusCode.OK_200) 256 }
234 257
235 expect(res.text).to.contain('<meta property="twitter:card" content="summary" />') 258 async function watchVideoPageTest (path: string) {
236 expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />') 259 const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', statusCodeExpected: HttpStatusCode.OK_200 })
237 expect(res.text).to.contain(`<meta property="twitter:title" content="${account.name}" />`) 260 const text = res.text
238 expect(res.text).to.contain(`<meta property="twitter:description" content="${account.description}" />`) 261
239 }) 262 expect(text).to.contain('<meta property="twitter:card" content="summary_large_image" />')
263 expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
264 expect(text).to.contain(`<meta property="twitter:title" content="${videoName}" />`)
265 expect(text).to.contain(`<meta property="twitter:description" content="${videoDescriptionPlainText}" />`)
266 }
240 267
241 it('Should have valid twitter card on the channel page', async function () { 268 async function watchPlaylistPageTest (path: string) {
242 const res = await request(servers[0].url) 269 const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', statusCodeExpected: HttpStatusCode.OK_200 })
243 .get('/video-channels/' + servers[0].videoChannel.name) 270 const text = res.text
244 .set('Accept', 'text/html') 271
245 .expect(HttpStatusCode.OK_200) 272 expect(text).to.contain('<meta property="twitter:card" content="summary" />')
273 expect(text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />')
274 expect(text).to.contain(`<meta property="twitter:title" content="${playlistName}" />`)
275 expect(text).to.contain(`<meta property="twitter:description" content="${playlistDescription}" />`)
276 }
246 277
247 expect(res.text).to.contain('<meta property="twitter:card" content="summary" />') 278 it('Should have valid twitter card on the watch video page', async function () {
248 expect(res.text).to.contain('<meta property="twitter:site" content="@Chocobozzz" />') 279 for (const path of watchVideoBasePaths) {
249 expect(res.text).to.contain(`<meta property="twitter:title" content="${servers[0].videoChannel.displayName}" />`) 280 for (const id of videoIds) {
250 expect(res.text).to.contain(`<meta property="twitter:description" content="${channelDescription}" />`) 281 await watchVideoPageTest(path + id)
282 }
283 }
284 })
285
286 it('Should have valid twitter card on the watch playlist page', async function () {
287 for (const path of watchPlaylistBasePaths) {
288 for (const id of playlistIds) {
289 await watchPlaylistPageTest(path + id)
290 }
291 }
292 })
293
294 it('Should have valid twitter card on the account page', async function () {
295 await accountPageTest('/accounts/' + account.name)
296 await accountPageTest('/a/' + account.name)
297 await accountPageTest('/@' + account.name)
298 })
299
300 it('Should have valid twitter card on the channel page', async function () {
301 await channelPageTest('/video-channels/' + servers[0].videoChannel.name)
302 await channelPageTest('/c/' + servers[0].videoChannel.name)
303 await channelPageTest('/@' + servers[0].videoChannel.name)
304 })
251 }) 305 })
252 306
253 it('Should have valid twitter card if Twitter is whitelisted', async function () { 307 describe('Whitelisted', function () {
254 const res1 = await getCustomConfig(servers[0].url, servers[0].accessToken) 308
255 const config = res1.body 309 before(async function () {
256 config.services.twitter = { 310 const res = await getCustomConfig(servers[0].url, servers[0].accessToken)
257 username: '@Kuja', 311 const config = res.body as CustomConfig
258 whitelisted: true 312 config.services.twitter = {
313 username: '@Kuja',
314 whitelisted: true
315 }
316
317 await updateCustomConfig(servers[0].url, servers[0].accessToken, config)
318 })
319
320 async function accountPageTest (path: string) {
321 const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', statusCodeExpected: HttpStatusCode.OK_200 })
322 const text = res.text
323
324 expect(text).to.contain('<meta property="twitter:card" content="summary" />')
325 expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
259 } 326 }
260 await updateCustomConfig(servers[0].url, servers[0].accessToken, config)
261 327
262 const resVideoRequest = await request(servers[0].url) 328 async function channelPageTest (path: string) {
263 .get('/videos/watch/' + servers[0].video.uuid) 329 const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', statusCodeExpected: HttpStatusCode.OK_200 })
264 .set('Accept', 'text/html') 330 const text = res.text
265 .expect(HttpStatusCode.OK_200)
266 331
267 expect(resVideoRequest.text).to.contain('<meta property="twitter:card" content="player" />') 332 expect(text).to.contain('<meta property="twitter:card" content="summary" />')
268 expect(resVideoRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />') 333 expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
334 }
269 335
270 const resVideoPlaylistRequest = await request(servers[0].url) 336 async function watchVideoPageTest (path: string) {
271 .get('/videos/watch/playlist/' + playlistUUID) 337 const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', statusCodeExpected: HttpStatusCode.OK_200 })
272 .set('Accept', 'text/html') 338 const text = res.text
273 .expect(HttpStatusCode.OK_200)
274 339
275 expect(resVideoPlaylistRequest.text).to.contain('<meta property="twitter:card" content="player" />') 340 expect(text).to.contain('<meta property="twitter:card" content="player" />')
276 expect(resVideoPlaylistRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />') 341 expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
342 }
277 343
278 const resAccountRequest = await request(servers[0].url) 344 async function watchPlaylistPageTest (path: string) {
279 .get('/accounts/' + account.name) 345 const res = await makeGetRequest({ url: servers[0].url, path, accept: 'text/html', statusCodeExpected: HttpStatusCode.OK_200 })
280 .set('Accept', 'text/html') 346 const text = res.text
281 .expect(HttpStatusCode.OK_200)
282 347
283 expect(resAccountRequest.text).to.contain('<meta property="twitter:card" content="summary" />') 348 expect(text).to.contain('<meta property="twitter:card" content="player" />')
284 expect(resAccountRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />') 349 expect(text).to.contain('<meta property="twitter:site" content="@Kuja" />')
350 }
285 351
286 const resChannelRequest = await request(servers[0].url) 352 it('Should have valid twitter card on the watch video page', async function () {
287 .get('/video-channels/' + servers[0].videoChannel.name) 353 for (const path of watchVideoBasePaths) {
288 .set('Accept', 'text/html') 354 for (const id of videoIds) {
289 .expect(HttpStatusCode.OK_200) 355 await watchVideoPageTest(path + id)
356 }
357 }
358 })
359
360 it('Should have valid twitter card on the watch playlist page', async function () {
361 for (const path of watchPlaylistBasePaths) {
362 for (const id of playlistIds) {
363 await watchPlaylistPageTest(path + id)
364 }
365 }
366 })
290 367
291 expect(resChannelRequest.text).to.contain('<meta property="twitter:card" content="summary" />') 368 it('Should have valid twitter card on the account page', async function () {
292 expect(resChannelRequest.text).to.contain('<meta property="twitter:site" content="@Kuja" />') 369 await accountPageTest('/accounts/' + account.name)
370 await accountPageTest('/a/' + account.name)
371 await accountPageTest('/@' + account.name)
372 })
373
374 it('Should have valid twitter card on the channel page', async function () {
375 await channelPageTest('/video-channels/' + servers[0].videoChannel.name)
376 await channelPageTest('/c/' + servers[0].videoChannel.name)
377 await channelPageTest('/@' + servers[0].videoChannel.name)
378 })
293 }) 379 })
294 }) 380 })
295 381
296 describe('Index HTML', function () { 382 describe('Index HTML', function () {
297 383
298 it('Should have valid index html tags (title, description...)', async function () { 384 it('Should have valid index html tags (title, description...)', async function () {
385 const resConfig = await getConfig(servers[0].url)
299 const res = await makeHTMLRequest(servers[0].url, '/videos/trending') 386 const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
300 387
301 const description = 'PeerTube, an ActivityPub-federated video streaming platform using P2P directly in your web browser.' 388 const description = 'PeerTube, an ActivityPub-federated video streaming platform using P2P directly in your web browser.'
302 checkIndexTags(res.text, 'PeerTube', description, '') 389 checkIndexTags(res.text, 'PeerTube', description, '', resConfig.body)
303 }) 390 })
304 391
305 it('Should update the customized configuration and have the correct index html tags', async function () { 392 it('Should update the customized configuration and have the correct index html tags', async function () {
@@ -318,35 +405,65 @@ describe('Test a client controllers', function () {
318 } 405 }
319 }) 406 })
320 407
408 const resConfig = await getConfig(servers[0].url)
321 const res = await makeHTMLRequest(servers[0].url, '/videos/trending') 409 const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
322 410
323 checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }') 411 checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', resConfig.body)
324 }) 412 })
325 413
326 it('Should have valid index html updated tags (title, description...)', async function () { 414 it('Should have valid index html updated tags (title, description...)', async function () {
415 const resConfig = await getConfig(servers[0].url)
327 const res = await makeHTMLRequest(servers[0].url, '/videos/trending') 416 const res = await makeHTMLRequest(servers[0].url, '/videos/trending')
328 417
329 checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }') 418 checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', resConfig.body)
330 }) 419 })
331 420
332 it('Should use the original video URL for the canonical tag', async function () { 421 it('Should use the original video URL for the canonical tag', async function () {
333 const res = await makeHTMLRequest(servers[1].url, '/videos/watch/' + servers[0].video.uuid) 422 for (const basePath of watchVideoBasePaths) {
334 expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/videos/watch/${servers[0].video.uuid}" />`) 423 for (const id of videoIds) {
424 const res = await makeHTMLRequest(servers[1].url, basePath + id)
425 expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/videos/watch/${servers[0].video.uuid}" />`)
426 }
427 }
335 }) 428 })
336 429
337 it('Should use the original account URL for the canonical tag', async function () { 430 it('Should use the original account URL for the canonical tag', async function () {
338 const res = await makeHTMLRequest(servers[1].url, '/accounts/root@' + servers[0].host) 431 const accountURLtest = (res) => {
339 expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/accounts/root" />`) 432 expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/accounts/root" />`)
433 }
434
435 accountURLtest(await makeHTMLRequest(servers[1].url, '/accounts/root@' + servers[0].host))
436 accountURLtest(await makeHTMLRequest(servers[1].url, '/a/root@' + servers[0].host))
437 accountURLtest(await makeHTMLRequest(servers[1].url, '/@root@' + servers[0].host))
340 }) 438 })
341 439
342 it('Should use the original channel URL for the canonical tag', async function () { 440 it('Should use the original channel URL for the canonical tag', async function () {
343 const res = await makeHTMLRequest(servers[1].url, '/video-channels/root_channel@' + servers[0].host) 441 const channelURLtests = (res) => {
344 expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/video-channels/root_channel" />`) 442 expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/video-channels/root_channel" />`)
443 }
444
445 channelURLtests(await makeHTMLRequest(servers[1].url, '/video-channels/root_channel@' + servers[0].host))
446 channelURLtests(await makeHTMLRequest(servers[1].url, '/c/root_channel@' + servers[0].host))
447 channelURLtests(await makeHTMLRequest(servers[1].url, '/@root_channel@' + servers[0].host))
345 }) 448 })
346 449
347 it('Should use the original playlist URL for the canonical tag', async function () { 450 it('Should use the original playlist URL for the canonical tag', async function () {
348 const res = await makeHTMLRequest(servers[1].url, '/videos/watch/playlist/' + playlistUUID) 451 for (const basePath of watchPlaylistBasePaths) {
349 expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/video-playlists/${playlistUUID}" />`) 452 for (const id of playlistIds) {
453 const res = await makeHTMLRequest(servers[1].url, basePath + id)
454 expect(res.text).to.contain(`<link rel="canonical" href="${servers[0].url}/video-playlists/${playlist.uuid}" />`)
455 }
456 }
457 })
458 })
459
460 describe('Embed HTML', function () {
461
462 it('Should have the correct embed html tags', async function () {
463 const resConfig = await getConfig(servers[0].url)
464 const res = await makeHTMLRequest(servers[0].url, servers[0].video.embedPath)
465
466 checkIndexTags(res.text, 'PeerTube updated', 'my short description', 'body { background-color: red; }', resConfig.body)
350 }) 467 })
351 }) 468 })
352 469
diff --git a/server/tests/external-plugins/auto-block-videos.ts b/server/tests/external-plugins/auto-block-videos.ts
index 1b91d141e..18ea17d78 100644
--- a/server/tests/external-plugins/auto-block-videos.ts
+++ b/server/tests/external-plugins/auto-block-videos.ts
@@ -40,6 +40,7 @@ describe('Official plugin auto-block videos', function () {
40 let blocklistServer: MockBlocklist 40 let blocklistServer: MockBlocklist
41 let server1Videos: Video[] = [] 41 let server1Videos: Video[] = []
42 let server2Videos: Video[] = [] 42 let server2Videos: Video[] = []
43 let port: number
43 44
44 before(async function () { 45 before(async function () {
45 this.timeout(60000) 46 this.timeout(60000)
@@ -56,7 +57,7 @@ describe('Official plugin auto-block videos', function () {
56 } 57 }
57 58
58 blocklistServer = new MockBlocklist() 59 blocklistServer = new MockBlocklist()
59 await blocklistServer.initialize() 60 port = await blocklistServer.initialize()
60 61
61 await uploadVideoAndGetId({ server: servers[0], videoName: 'video server 1' }) 62 await uploadVideoAndGetId({ server: servers[0], videoName: 'video server 1' })
62 await uploadVideoAndGetId({ server: servers[1], videoName: 'video server 2' }) 63 await uploadVideoAndGetId({ server: servers[1], videoName: 'video server 2' })
@@ -82,7 +83,7 @@ describe('Official plugin auto-block videos', function () {
82 accessToken: servers[0].accessToken, 83 accessToken: servers[0].accessToken,
83 npmName: 'peertube-plugin-auto-block-videos', 84 npmName: 'peertube-plugin-auto-block-videos',
84 settings: { 85 settings: {
85 'blocklist-urls': 'http://localhost:42100/blocklist', 86 'blocklist-urls': `http://localhost:${port}/blocklist`,
86 'check-seconds-interval': 1 87 'check-seconds-interval': 1
87 } 88 }
88 }) 89 })
diff --git a/server/tests/external-plugins/auto-mute.ts b/server/tests/external-plugins/auto-mute.ts
index 687f56e98..09355d932 100644
--- a/server/tests/external-plugins/auto-mute.ts
+++ b/server/tests/external-plugins/auto-mute.ts
@@ -31,6 +31,7 @@ describe('Official plugin auto-mute', function () {
31 const autoMuteListPath = '/plugins/auto-mute/router/api/v1/mute-list' 31 const autoMuteListPath = '/plugins/auto-mute/router/api/v1/mute-list'
32 let servers: ServerInfo[] 32 let servers: ServerInfo[]
33 let blocklistServer: MockBlocklist 33 let blocklistServer: MockBlocklist
34 let port: number
34 35
35 before(async function () { 36 before(async function () {
36 this.timeout(30000) 37 this.timeout(30000)
@@ -47,7 +48,7 @@ describe('Official plugin auto-mute', function () {
47 } 48 }
48 49
49 blocklistServer = new MockBlocklist() 50 blocklistServer = new MockBlocklist()
50 await blocklistServer.initialize() 51 port = await blocklistServer.initialize()
51 52
52 await uploadVideoAndGetId({ server: servers[0], videoName: 'video server 1' }) 53 await uploadVideoAndGetId({ server: servers[0], videoName: 'video server 1' })
53 await uploadVideoAndGetId({ server: servers[1], videoName: 'video server 2' }) 54 await uploadVideoAndGetId({ server: servers[1], videoName: 'video server 2' })
@@ -61,7 +62,7 @@ describe('Official plugin auto-mute', function () {
61 accessToken: servers[0].accessToken, 62 accessToken: servers[0].accessToken,
62 npmName: 'peertube-plugin-auto-mute', 63 npmName: 'peertube-plugin-auto-mute',
63 settings: { 64 settings: {
64 'blocklist-urls': 'http://localhost:42100/blocklist', 65 'blocklist-urls': `http://localhost:${port}/blocklist`,
65 'check-seconds-interval': 1 66 'check-seconds-interval': 1
66 } 67 }
67 }) 68 })
diff --git a/server/tests/api/activitypub/json/mastodon/bad-body-http-signature.json b/server/tests/fixtures/ap-json/mastodon/bad-body-http-signature.json
index 4e7bc3af5..4e7bc3af5 100644
--- a/server/tests/api/activitypub/json/mastodon/bad-body-http-signature.json
+++ b/server/tests/fixtures/ap-json/mastodon/bad-body-http-signature.json
diff --git a/server/tests/api/activitypub/json/mastodon/bad-http-signature.json b/server/tests/fixtures/ap-json/mastodon/bad-http-signature.json
index 098597db0..098597db0 100644
--- a/server/tests/api/activitypub/json/mastodon/bad-http-signature.json
+++ b/server/tests/fixtures/ap-json/mastodon/bad-http-signature.json
diff --git a/server/tests/api/activitypub/json/mastodon/bad-public-key.json b/server/tests/fixtures/ap-json/mastodon/bad-public-key.json
index 73d18b3ad..73d18b3ad 100644
--- a/server/tests/api/activitypub/json/mastodon/bad-public-key.json
+++ b/server/tests/fixtures/ap-json/mastodon/bad-public-key.json
diff --git a/server/tests/api/activitypub/json/mastodon/create-bad-signature.json b/server/tests/fixtures/ap-json/mastodon/create-bad-signature.json
index 2cd037241..2cd037241 100644
--- a/server/tests/api/activitypub/json/mastodon/create-bad-signature.json
+++ b/server/tests/fixtures/ap-json/mastodon/create-bad-signature.json
diff --git a/server/tests/api/activitypub/json/mastodon/create.json b/server/tests/fixtures/ap-json/mastodon/create.json
index 0be271bb8..0be271bb8 100644
--- a/server/tests/api/activitypub/json/mastodon/create.json
+++ b/server/tests/fixtures/ap-json/mastodon/create.json
diff --git a/server/tests/api/activitypub/json/mastodon/http-signature.json b/server/tests/fixtures/ap-json/mastodon/http-signature.json
index 4e7bc3af5..4e7bc3af5 100644
--- a/server/tests/api/activitypub/json/mastodon/http-signature.json
+++ b/server/tests/fixtures/ap-json/mastodon/http-signature.json
diff --git a/server/tests/api/activitypub/json/mastodon/public-key.json b/server/tests/fixtures/ap-json/mastodon/public-key.json
index b7b9b8308..b7b9b8308 100644
--- a/server/tests/api/activitypub/json/mastodon/public-key.json
+++ b/server/tests/fixtures/ap-json/mastodon/public-key.json
diff --git a/server/tests/api/activitypub/json/peertube/announce-without-context.json b/server/tests/fixtures/ap-json/peertube/announce-without-context.json
index 5f2af0cde..5f2af0cde 100644
--- a/server/tests/api/activitypub/json/peertube/announce-without-context.json
+++ b/server/tests/fixtures/ap-json/peertube/announce-without-context.json
diff --git a/server/tests/api/activitypub/json/peertube/invalid-keys.json b/server/tests/fixtures/ap-json/peertube/invalid-keys.json
index 0544e96b9..0544e96b9 100644
--- a/server/tests/api/activitypub/json/peertube/invalid-keys.json
+++ b/server/tests/fixtures/ap-json/peertube/invalid-keys.json
diff --git a/server/tests/api/activitypub/json/peertube/keys.json b/server/tests/fixtures/ap-json/peertube/keys.json
index 1a7700865..1a7700865 100644
--- a/server/tests/api/activitypub/json/peertube/keys.json
+++ b/server/tests/fixtures/ap-json/peertube/keys.json
diff --git a/server/tests/fixtures/peertube-plugin-test-broken/main.js b/server/tests/fixtures/peertube-plugin-test-broken/main.js
new file mode 100644
index 000000000..afdb6f7a0
--- /dev/null
+++ b/server/tests/fixtures/peertube-plugin-test-broken/main.js
@@ -0,0 +1,12 @@
1async function register (options) {
2 options.unknownFunction()
3}
4
5async function unregister () {
6 return
7}
8
9module.exports = {
10 register,
11 unregister
12}
diff --git a/server/tests/fixtures/peertube-plugin-test-three/package.json b/server/tests/fixtures/peertube-plugin-test-broken/package.json
index 41d4c93fe..fd03df216 100644
--- a/server/tests/fixtures/peertube-plugin-test-three/package.json
+++ b/server/tests/fixtures/peertube-plugin-test-broken/package.json
@@ -1,7 +1,7 @@
1{ 1{
2 "name": "peertube-plugin-test-three", 2 "name": "peertube-plugin-test-broken",
3 "version": "0.0.1", 3 "version": "0.0.1",
4 "description": "Plugin test 3", 4 "description": "Plugin test broken",
5 "engine": { 5 "engine": {
6 "peertube": ">=1.3.0" 6 "peertube": ">=1.3.0"
7 }, 7 },
diff --git a/server/tests/fixtures/peertube-plugin-test-two/languages/fr.json b/server/tests/fixtures/peertube-plugin-test-filter-translations/languages/fr.json
index 52d8313df..52d8313df 100644
--- a/server/tests/fixtures/peertube-plugin-test-two/languages/fr.json
+++ b/server/tests/fixtures/peertube-plugin-test-filter-translations/languages/fr.json
diff --git a/server/tests/fixtures/peertube-plugin-test-two/languages/it.json b/server/tests/fixtures/peertube-plugin-test-filter-translations/languages/it.json
index 9e187d83b..9e187d83b 100644
--- a/server/tests/fixtures/peertube-plugin-test-two/languages/it.json
+++ b/server/tests/fixtures/peertube-plugin-test-filter-translations/languages/it.json
diff --git a/server/tests/fixtures/peertube-plugin-test-two/main.js b/server/tests/fixtures/peertube-plugin-test-filter-translations/main.js
index 71c11b2ba..71c11b2ba 100644
--- a/server/tests/fixtures/peertube-plugin-test-two/main.js
+++ b/server/tests/fixtures/peertube-plugin-test-filter-translations/main.js
diff --git a/server/tests/fixtures/peertube-plugin-test-two/package.json b/server/tests/fixtures/peertube-plugin-test-filter-translations/package.json
index 926f2d69b..2adce4743 100644
--- a/server/tests/fixtures/peertube-plugin-test-two/package.json
+++ b/server/tests/fixtures/peertube-plugin-test-filter-translations/package.json
@@ -1,7 +1,7 @@
1{ 1{
2 "name": "peertube-plugin-test-two", 2 "name": "peertube-plugin-test-filter-translations",
3 "version": "0.0.1", 3 "version": "0.0.1",
4 "description": "Plugin test 2", 4 "description": "Plugin test filter and translations",
5 "engine": { 5 "engine": {
6 "peertube": ">=1.3.0" 6 "peertube": ">=1.3.0"
7 }, 7 },
diff --git a/server/tests/fixtures/peertube-plugin-test-three/main.js b/server/tests/fixtures/peertube-plugin-test-video-constants/main.js
index f2b89bcf0..3e650e0a1 100644
--- a/server/tests/fixtures/peertube-plugin-test-three/main.js
+++ b/server/tests/fixtures/peertube-plugin-test-video-constants/main.js
@@ -11,8 +11,10 @@ async function register ({
11}) { 11}) {
12 videoLanguageManager.addLanguage('al_bhed', 'Al Bhed') 12 videoLanguageManager.addLanguage('al_bhed', 'Al Bhed')
13 videoLanguageManager.addLanguage('al_bhed2', 'Al Bhed 2') 13 videoLanguageManager.addLanguage('al_bhed2', 'Al Bhed 2')
14 videoLanguageManager.addLanguage('al_bhed3', 'Al Bhed 3')
14 videoLanguageManager.deleteLanguage('en') 15 videoLanguageManager.deleteLanguage('en')
15 videoLanguageManager.deleteLanguage('fr') 16 videoLanguageManager.deleteLanguage('fr')
17 videoLanguageManager.deleteLanguage('al_bhed3')
16 18
17 videoCategoryManager.addCategory(42, 'Best category') 19 videoCategoryManager.addCategory(42, 'Best category')
18 videoCategoryManager.addCategory(43, 'High best category') 20 videoCategoryManager.addCategory(43, 'High best category')
diff --git a/server/tests/fixtures/peertube-plugin-test-video-constants/package.json b/server/tests/fixtures/peertube-plugin-test-video-constants/package.json
new file mode 100644
index 000000000..0fcf39933
--- /dev/null
+++ b/server/tests/fixtures/peertube-plugin-test-video-constants/package.json
@@ -0,0 +1,20 @@
1{
2 "name": "peertube-plugin-test-video-constants",
3 "version": "0.0.1",
4 "description": "Plugin test video constants",
5 "engine": {
6 "peertube": ">=1.3.0"
7 },
8 "keywords": [
9 "peertube",
10 "plugin"
11 ],
12 "homepage": "https://github.com/Chocobozzz/PeerTube",
13 "author": "Chocobozzz",
14 "bugs": "https://github.com/Chocobozzz/PeerTube/issues",
15 "library": "./main.js",
16 "staticDirs": {},
17 "css": [],
18 "clientScripts": [],
19 "translations": {}
20}
diff --git a/server/tests/fixtures/peertube-plugin-test/main.js b/server/tests/fixtures/peertube-plugin-test/main.js
index ee0bc39f3..f8e6f0b98 100644
--- a/server/tests/fixtures/peertube-plugin-test/main.js
+++ b/server/tests/fixtures/peertube-plugin-test/main.js
@@ -19,7 +19,9 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
19 'action:api.user.created', 19 'action:api.user.created',
20 'action:api.user.deleted', 20 'action:api.user.deleted',
21 'action:api.user.updated', 21 'action:api.user.updated',
22 'action:api.user.oauth2-got-token' 22 'action:api.user.oauth2-got-token',
23
24 'action:api.video-playlist-element.created'
23 ] 25 ]
24 26
25 for (const h of actionHooks) { 27 for (const h of actionHooks) {
@@ -241,6 +243,10 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
241 'filter:api.search.video-channels.local.list.result', 243 'filter:api.search.video-channels.local.list.result',
242 'filter:api.search.video-channels.index.list.params', 244 'filter:api.search.video-channels.index.list.params',
243 'filter:api.search.video-channels.index.list.result', 245 'filter:api.search.video-channels.index.list.result',
246 'filter:api.search.video-playlists.local.list.params',
247 'filter:api.search.video-playlists.local.list.result',
248 'filter:api.search.video-playlists.index.list.params',
249 'filter:api.search.video-playlists.index.list.result'
244 ] 250 ]
245 251
246 for (const h of searchHooks) { 252 for (const h of searchHooks) {
diff --git a/server/tests/plugins/action-hooks.ts b/server/tests/plugins/action-hooks.ts
index ac9f2cea5..0f57ef7fe 100644
--- a/server/tests/plugins/action-hooks.ts
+++ b/server/tests/plugins/action-hooks.ts
@@ -1,13 +1,15 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ 1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2 2
3import 'mocha' 3import 'mocha'
4import { ServerHookName, VideoPrivacy } from '@shared/models' 4import { ServerHookName, VideoPlaylistPrivacy, VideoPrivacy } from '@shared/models'
5import { 5import {
6 addVideoCommentReply, 6 addVideoCommentReply,
7 addVideoCommentThread, 7 addVideoCommentThread,
8 addVideoInPlaylist,
8 blockUser, 9 blockUser,
9 createLive, 10 createLive,
10 createUser, 11 createUser,
12 createVideoPlaylist,
11 deleteVideoComment, 13 deleteVideoComment,
12 getPluginTestPath, 14 getPluginTestPath,
13 installPlugin, 15 installPlugin,
@@ -69,6 +71,7 @@ describe('Test plugin action hooks', function () {
69 }) 71 })
70 72
71 describe('Videos hooks', function () { 73 describe('Videos hooks', function () {
74
72 it('Should run action:api.video.uploaded', async function () { 75 it('Should run action:api.video.uploaded', async function () {
73 const res = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'video' }) 76 const res = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'video' })
74 videoUUID = res.body.video.uuid 77 videoUUID = res.body.video.uuid
@@ -177,6 +180,41 @@ describe('Test plugin action hooks', function () {
177 }) 180 })
178 }) 181 })
179 182
183 describe('Playlist hooks', function () {
184 let playlistId: number
185 let videoId: number
186
187 before(async function () {
188 {
189 const res = await createVideoPlaylist({
190 url: servers[0].url,
191 token: servers[0].accessToken,
192 playlistAttrs: {
193 displayName: 'My playlist',
194 privacy: VideoPlaylistPrivacy.PRIVATE
195 }
196 })
197 playlistId = res.body.videoPlaylist.id
198 }
199
200 {
201 const res = await uploadVideo(servers[0].url, servers[0].accessToken, { name: 'my super name' })
202 videoId = res.body.video.id
203 }
204 })
205
206 it('Should run action:api.video-playlist-element.created', async function () {
207 await addVideoInPlaylist({
208 url: servers[0].url,
209 token: servers[0].accessToken,
210 playlistId,
211 elementAttrs: { videoId }
212 })
213
214 await checkHook('action:api.video-playlist-element.created')
215 })
216 })
217
180 after(async function () { 218 after(async function () {
181 await cleanupTests(servers) 219 await cleanupTests(servers)
182 }) 220 })
diff --git a/server/tests/plugins/filter-hooks.ts b/server/tests/plugins/filter-hooks.ts
index 7d4f7abb4..644b41dea 100644
--- a/server/tests/plugins/filter-hooks.ts
+++ b/server/tests/plugins/filter-hooks.ts
@@ -8,6 +8,7 @@ import { HttpStatusCode } from '../../../shared/core-utils/miscs/http-error-code
8import { 8import {
9 addVideoCommentReply, 9 addVideoCommentReply,
10 addVideoCommentThread, 10 addVideoCommentThread,
11 advancedVideoPlaylistSearch,
11 advancedVideosSearch, 12 advancedVideosSearch,
12 createLive, 13 createLive,
13 createVideoPlaylist, 14 createVideoPlaylist,
@@ -38,6 +39,7 @@ import {
38import { cleanupTests, flushAndRunMultipleServers, ServerInfo, waitUntilLog } from '../../../shared/extra-utils/server/servers' 39import { cleanupTests, flushAndRunMultipleServers, ServerInfo, waitUntilLog } from '../../../shared/extra-utils/server/servers'
39import { getGoodVideoUrl, getMyVideoImports, importVideo } from '../../../shared/extra-utils/videos/video-imports' 40import { getGoodVideoUrl, getMyVideoImports, importVideo } from '../../../shared/extra-utils/videos/video-imports'
40import { 41import {
42 VideoCommentThreadTree,
41 VideoDetails, 43 VideoDetails,
42 VideoImport, 44 VideoImport,
43 VideoImportState, 45 VideoImportState,
@@ -45,7 +47,6 @@ import {
45 VideoPlaylistPrivacy, 47 VideoPlaylistPrivacy,
46 VideoPrivacy 48 VideoPrivacy
47} from '../../../shared/models/videos' 49} from '../../../shared/models/videos'
48import { VideoCommentThreadTree } from '../../../shared/models/videos/video-comment.model'
49 50
50const expect = chai.expect 51const expect = chai.expect
51 52
@@ -71,7 +72,7 @@ describe('Test plugin filter hooks', function () {
71 await installPlugin({ 72 await installPlugin({
72 url: servers[0].url, 73 url: servers[0].url,
73 accessToken: servers[0].accessToken, 74 accessToken: servers[0].accessToken,
74 path: getPluginTestPath('-two') 75 path: getPluginTestPath('-filter-translations')
75 }) 76 })
76 77
77 for (let i = 0; i < 10; i++) { 78 for (let i = 0; i < 10; i++) {
@@ -326,7 +327,7 @@ describe('Test plugin filter hooks', function () {
326 }) 327 })
327 328
328 it('Should blacklist on remote upload', async function () { 329 it('Should blacklist on remote upload', async function () {
329 this.timeout(60000) 330 this.timeout(120000)
330 331
331 const res = await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'remote please blacklist me' }) 332 const res = await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'remote please blacklist me' })
332 await waitJobs(servers) 333 await waitJobs(servers)
@@ -335,7 +336,7 @@ describe('Test plugin filter hooks', function () {
335 }) 336 })
336 337
337 it('Should blacklist on remote update', async function () { 338 it('Should blacklist on remote update', async function () {
338 this.timeout(60000) 339 this.timeout(120000)
339 340
340 const res = await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video' }) 341 const res = await uploadVideo(servers[1].url, servers[1].accessToken, { name: 'video' })
341 await waitJobs(servers) 342 await waitJobs(servers)
@@ -372,7 +373,7 @@ describe('Test plugin filter hooks', function () {
372 const downloadVideos: VideoDetails[] = [] 373 const downloadVideos: VideoDetails[] = []
373 374
374 before(async function () { 375 before(async function () {
375 this.timeout(60000) 376 this.timeout(120000)
376 377
377 await updateCustomSubConfig(servers[0].url, servers[0].accessToken, { 378 await updateCustomSubConfig(servers[0].url, servers[0].accessToken, {
378 transcoding: { 379 transcoding: {
@@ -525,6 +526,27 @@ describe('Test plugin filter hooks', function () {
525 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.params', 1) 526 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.params', 1)
526 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.result', 1) 527 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-channels.index.list.result', 1)
527 }) 528 })
529
530 it('Should run filter:api.search.video-playlists.local.list.{params,result}', async function () {
531 await advancedVideoPlaylistSearch(servers[0].url, {
532 search: 'Sun Jian'
533 })
534
535 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.params', 1)
536 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.result', 1)
537 })
538
539 it('Should run filter:api.search.video-playlists.index.list.{params,result}', async function () {
540 await advancedVideoPlaylistSearch(servers[0].url, {
541 search: 'Sun Jian',
542 searchTarget: 'search-index'
543 })
544
545 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.params', 1)
546 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.local.list.result', 1)
547 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.index.list.params', 1)
548 await waitUntilLog(servers[0], 'Run hook filter:api.search.video-playlists.index.list.result', 1)
549 })
528 }) 550 })
529 551
530 after(async function () { 552 after(async function () {
diff --git a/server/tests/plugins/html-injection.ts b/server/tests/plugins/html-injection.ts
index 293c1df21..4fa8caa3a 100644
--- a/server/tests/plugins/html-injection.ts
+++ b/server/tests/plugins/html-injection.ts
@@ -15,7 +15,7 @@ import {
15 15
16const expect = chai.expect 16const expect = chai.expect
17 17
18describe('Test plugins HTML inection', function () { 18describe('Test plugins HTML injection', function () {
19 let server: ServerInfo = null 19 let server: ServerInfo = null
20 20
21 before(async function () { 21 before(async function () {
diff --git a/server/tests/plugins/plugin-helpers.ts b/server/tests/plugins/plugin-helpers.ts
index f72de8229..0296d6eb7 100644
--- a/server/tests/plugins/plugin-helpers.ts
+++ b/server/tests/plugins/plugin-helpers.ts
@@ -144,7 +144,7 @@ describe('Test plugin helpers', function () {
144 let videoUUIDServer1: string 144 let videoUUIDServer1: string
145 145
146 before(async function () { 146 before(async function () {
147 this.timeout(30000) 147 this.timeout(60000)
148 148
149 { 149 {
150 const res = await uploadVideoAndGetId({ server: servers[0], videoName: 'video server 1' }) 150 const res = await uploadVideoAndGetId({ server: servers[0], videoName: 'video server 1' })
diff --git a/server/tests/plugins/translations.ts b/server/tests/plugins/translations.ts
index 8dc2043b8..9fd2ba1c5 100644
--- a/server/tests/plugins/translations.ts
+++ b/server/tests/plugins/translations.ts
@@ -31,7 +31,7 @@ describe('Test plugin translations', function () {
31 await installPlugin({ 31 await installPlugin({
32 url: server.url, 32 url: server.url,
33 accessToken: server.accessToken, 33 accessToken: server.accessToken,
34 path: getPluginTestPath('-two') 34 path: getPluginTestPath('-filter-translations')
35 }) 35 })
36 }) 36 })
37 37
@@ -48,7 +48,7 @@ describe('Test plugin translations', function () {
48 'peertube-plugin-test': { 48 'peertube-plugin-test': {
49 Hi: 'Coucou' 49 Hi: 'Coucou'
50 }, 50 },
51 'peertube-plugin-test-two': { 51 'peertube-plugin-test-filter-translations': {
52 'Hello world': 'Bonjour le monde' 52 'Hello world': 'Bonjour le monde'
53 } 53 }
54 }) 54 })
@@ -58,14 +58,14 @@ describe('Test plugin translations', function () {
58 const res = await getPluginTranslations({ url: server.url, locale: 'it-IT' }) 58 const res = await getPluginTranslations({ url: server.url, locale: 'it-IT' })
59 59
60 expect(res.body).to.deep.equal({ 60 expect(res.body).to.deep.equal({
61 'peertube-plugin-test-two': { 61 'peertube-plugin-test-filter-translations': {
62 'Hello world': 'Ciao, mondo!' 62 'Hello world': 'Ciao, mondo!'
63 } 63 }
64 }) 64 })
65 }) 65 })
66 66
67 it('Should remove the plugin and remove the locales', async function () { 67 it('Should remove the plugin and remove the locales', async function () {
68 await uninstallPlugin({ url: server.url, accessToken: server.accessToken, npmName: 'peertube-plugin-test-two' }) 68 await uninstallPlugin({ url: server.url, accessToken: server.accessToken, npmName: 'peertube-plugin-test-filter-translations' })
69 69
70 { 70 {
71 const res = await getPluginTranslations({ url: server.url, locale: 'fr-FR' }) 71 const res = await getPluginTranslations({ url: server.url, locale: 'fr-FR' })
diff --git a/server/tests/plugins/video-constants.ts b/server/tests/plugins/video-constants.ts
index 5ee41fee1..eb014c596 100644
--- a/server/tests/plugins/video-constants.ts
+++ b/server/tests/plugins/video-constants.ts
@@ -32,7 +32,7 @@ describe('Test plugin altering video constants', function () {
32 await installPlugin({ 32 await installPlugin({
33 url: server.url, 33 url: server.url,
34 accessToken: server.accessToken, 34 accessToken: server.accessToken,
35 path: getPluginTestPath('-three') 35 path: getPluginTestPath('-video-constants')
36 }) 36 })
37 }) 37 })
38 38
@@ -45,6 +45,7 @@ describe('Test plugin altering video constants', function () {
45 45
46 expect(languages['al_bhed']).to.equal('Al Bhed') 46 expect(languages['al_bhed']).to.equal('Al Bhed')
47 expect(languages['al_bhed2']).to.equal('Al Bhed 2') 47 expect(languages['al_bhed2']).to.equal('Al Bhed 2')
48 expect(languages['al_bhed3']).to.not.exist
48 }) 49 })
49 50
50 it('Should have updated categories', async function () { 51 it('Should have updated categories', async function () {
@@ -116,7 +117,7 @@ describe('Test plugin altering video constants', function () {
116 }) 117 })
117 118
118 it('Should uninstall the plugin and reset languages, categories, licences and privacies', async function () { 119 it('Should uninstall the plugin and reset languages, categories, licences and privacies', async function () {
119 await uninstallPlugin({ url: server.url, accessToken: server.accessToken, npmName: 'peertube-plugin-test-three' }) 120 await uninstallPlugin({ url: server.url, accessToken: server.accessToken, npmName: 'peertube-plugin-test-video-constants' })
120 121
121 { 122 {
122 const res = await getVideoLanguages(server.url) 123 const res = await getVideoLanguages(server.url)
@@ -127,6 +128,7 @@ describe('Test plugin altering video constants', function () {
127 128
128 expect(languages['al_bhed']).to.not.exist 129 expect(languages['al_bhed']).to.not.exist
129 expect(languages['al_bhed2']).to.not.exist 130 expect(languages['al_bhed2']).to.not.exist
131 expect(languages['al_bhed3']).to.not.exist
130 } 132 }
131 133
132 { 134 {
diff --git a/server/tests/register.ts b/server/tests/register.ts
new file mode 100644
index 000000000..af6c8c644
--- /dev/null
+++ b/server/tests/register.ts
@@ -0,0 +1,3 @@
1import { registerTSPaths } from '../helpers/register-ts-paths'
2
3registerTSPaths()
diff --git a/server/tools/cli.ts b/server/tools/cli.ts
index cc89fe46e..7b94306cd 100644
--- a/server/tools/cli.ts
+++ b/server/tools/cli.ts
@@ -3,12 +3,12 @@ import { getAppNumber, isTestInstance } from '../helpers/core-utils'
3import { join } from 'path' 3import { join } from 'path'
4import { root } from '../../shared/extra-utils/miscs/miscs' 4import { root } from '../../shared/extra-utils/miscs/miscs'
5import { getVideoChannel } from '../../shared/extra-utils/videos/video-channels' 5import { getVideoChannel } from '../../shared/extra-utils/videos/video-channels'
6import { CommanderStatic } from 'commander'
7import { VideoChannel, VideoPrivacy } from '../../shared/models/videos' 6import { VideoChannel, VideoPrivacy } from '../../shared/models/videos'
8import { createLogger, format, transports } from 'winston' 7import { createLogger, format, transports } from 'winston'
9import { getMyUserInformation } from '@shared/extra-utils/users/users' 8import { getMyUserInformation } from '@shared/extra-utils/users/users'
10import { User, UserRole } from '@shared/models' 9import { User, UserRole } from '@shared/models'
11import { getAccessToken } from '@shared/extra-utils/users/login' 10import { getAccessToken } from '@shared/extra-utils/users/login'
11import { Command } from 'commander'
12 12
13let configName = 'PeerTube/CLI' 13let configName = 'PeerTube/CLI'
14if (isTestInstance()) configName += `-${getAppNumber()}` 14if (isTestInstance()) configName += `-${getAppNumber()}`
@@ -69,7 +69,7 @@ function deleteSettings () {
69} 69}
70 70
71function getRemoteObjectOrDie ( 71function getRemoteObjectOrDie (
72 program: CommanderStatic, 72 program: Command,
73 settings: Settings, 73 settings: Settings,
74 netrc: Netrc 74 netrc: Netrc
75): { url: string, username: string, password: string } { 75): { url: string, username: string, password: string } {
@@ -106,7 +106,7 @@ function getRemoteObjectOrDie (
106 } 106 }
107} 107}
108 108
109function buildCommonVideoOptions (command: CommanderStatic) { 109function buildCommonVideoOptions (command: Command) {
110 function list (val) { 110 function list (val) {
111 return val.split(',') 111 return val.split(',')
112 } 112 }
@@ -128,7 +128,7 @@ function buildCommonVideoOptions (command: CommanderStatic) {
128 .option('-v, --verbose <verbose>', 'Verbosity, from 0/\'error\' to 4/\'debug\'', 'info') 128 .option('-v, --verbose <verbose>', 'Verbosity, from 0/\'error\' to 4/\'debug\'', 'info')
129} 129}
130 130
131async function buildVideoAttributesFromCommander (url: string, command: CommanderStatic, defaultAttributes: any = {}) { 131async function buildVideoAttributesFromCommander (url: string, command: Command, defaultAttributes: any = {}) {
132 const options = command.opts() 132 const options = command.opts()
133 133
134 const defaultBooleanAttributes = { 134 const defaultBooleanAttributes = {
@@ -177,7 +177,7 @@ async function buildVideoAttributesFromCommander (url: string, command: Commande
177 return videoAttributes 177 return videoAttributes
178} 178}
179 179
180function getServerCredentials (program: CommanderStatic) { 180function getServerCredentials (program: Command) {
181 return Promise.all([ getSettings(), getNetrc() ]) 181 return Promise.all([ getSettings(), getNetrc() ])
182 .then(([ settings, netrc ]) => { 182 .then(([ settings, netrc ]) => {
183 return getRemoteObjectOrDie(program, settings, netrc) 183 return getRemoteObjectOrDie(program, settings, netrc)
diff --git a/server/tools/peertube-auth.ts b/server/tools/peertube-auth.ts
index e54649002..1934e7986 100644
--- a/server/tools/peertube-auth.ts
+++ b/server/tools/peertube-auth.ts
@@ -3,7 +3,7 @@
3import { registerTSPaths } from '../helpers/register-ts-paths' 3import { registerTSPaths } from '../helpers/register-ts-paths'
4registerTSPaths() 4registerTSPaths()
5 5
6import * as program from 'commander' 6import { OptionValues, program } from 'commander'
7import * as prompt from 'prompt' 7import * as prompt from 'prompt'
8import { getNetrc, getSettings, writeSettings } from './cli' 8import { getNetrc, getSettings, writeSettings } from './cli'
9import { isUserUsernameValid } from '../helpers/custom-validators/users' 9import { isUserUsernameValid } from '../helpers/custom-validators/users'
@@ -66,7 +66,7 @@ program
66 .option('-U, --username <username>', 'Username') 66 .option('-U, --username <username>', 'Username')
67 .option('-p, --password <token>', 'Password') 67 .option('-p, --password <token>', 'Password')
68 .option('--default', 'add the entry as the new default') 68 .option('--default', 'add the entry as the new default')
69 .action((options: program.OptionValues) => { 69 .action((options: OptionValues) => {
70 /* eslint-disable no-import-assign */ 70 /* eslint-disable no-import-assign */
71 prompt.override = options 71 prompt.override = options
72 prompt.start() 72 prompt.start()
diff --git a/server/tools/peertube-get-access-token.ts b/server/tools/peertube-get-access-token.ts
index b2c278c48..9488eba0e 100644
--- a/server/tools/peertube-get-access-token.ts
+++ b/server/tools/peertube-get-access-token.ts
@@ -1,7 +1,7 @@
1import { registerTSPaths } from '../helpers/register-ts-paths' 1import { registerTSPaths } from '../helpers/register-ts-paths'
2registerTSPaths() 2registerTSPaths()
3 3
4import * as program from 'commander' 4import { program } from 'commander'
5import { getClient, Server, serverLogin } from '../../shared/extra-utils' 5import { getClient, Server, serverLogin } from '../../shared/extra-utils'
6 6
7program 7program
diff --git a/server/tools/peertube-import-videos.ts b/server/tools/peertube-import-videos.ts
index 915995031..101a95b2a 100644
--- a/server/tools/peertube-import-videos.ts
+++ b/server/tools/peertube-import-videos.ts
@@ -1,7 +1,7 @@
1import { registerTSPaths } from '../helpers/register-ts-paths' 1import { registerTSPaths } from '../helpers/register-ts-paths'
2registerTSPaths() 2registerTSPaths()
3 3
4import * as program from 'commander' 4import { program } from 'commander'
5import { accessSync, constants } from 'fs' 5import { accessSync, constants } from 'fs'
6import { remove } from 'fs-extra' 6import { remove } from 'fs-extra'
7import { truncate } from 'lodash' 7import { truncate } from 'lodash'
@@ -11,9 +11,9 @@ import { promisify } from 'util'
11import { advancedVideosSearch, getClient, getVideoCategories, login, uploadVideo } from '../../shared/extra-utils/index' 11import { advancedVideosSearch, getClient, getVideoCategories, login, uploadVideo } from '../../shared/extra-utils/index'
12import { sha256 } from '../helpers/core-utils' 12import { sha256 } from '../helpers/core-utils'
13import { doRequestAndSaveToFile } from '../helpers/requests' 13import { doRequestAndSaveToFile } from '../helpers/requests'
14import { buildOriginallyPublishedAt, getYoutubeDLVideoFormat, safeGetYoutubeDL } from '../helpers/youtube-dl'
15import { CONSTRAINTS_FIELDS } from '../initializers/constants' 14import { CONSTRAINTS_FIELDS } from '../initializers/constants'
16import { buildCommonVideoOptions, buildVideoAttributesFromCommander, getLogger, getServerCredentials } from './cli' 15import { buildCommonVideoOptions, buildVideoAttributesFromCommander, getLogger, getServerCredentials } from './cli'
16import { YoutubeDL } from '@server/helpers/youtube-dl'
17 17
18type UserInfo = { 18type UserInfo = {
19 username: string 19 username: string
@@ -74,9 +74,9 @@ async function run (url: string, user: UserInfo) {
74 user.password = await promptPassword() 74 user.password = await promptPassword()
75 } 75 }
76 76
77 const youtubeDL = await safeGetYoutubeDL() 77 const youtubeDLBinary = await YoutubeDL.safeGetYoutubeDL()
78 78
79 let info = await getYoutubeDLInfo(youtubeDL, options.targetUrl, command.args) 79 let info = await getYoutubeDLInfo(youtubeDLBinary, options.targetUrl, command.args)
80 80
81 if (!Array.isArray(info)) info = [ info ] 81 if (!Array.isArray(info)) info = [ info ]
82 82
@@ -86,7 +86,7 @@ async function run (url: string, user: UserInfo) {
86 if (uploadsObject) { 86 if (uploadsObject) {
87 console.log('Fixing URL to %s.', uploadsObject.url) 87 console.log('Fixing URL to %s.', uploadsObject.url)
88 88
89 info = await getYoutubeDLInfo(youtubeDL, uploadsObject.url, command.args) 89 info = await getYoutubeDLInfo(youtubeDLBinary, uploadsObject.url, command.args)
90 } 90 }
91 91
92 let infoArray: any[] 92 let infoArray: any[]
@@ -130,13 +130,14 @@ async function processVideo (parameters: {
130 youtubeInfo: any 130 youtubeInfo: any
131}) { 131}) {
132 const { youtubeInfo, cwd, url, user } = parameters 132 const { youtubeInfo, cwd, url, user } = parameters
133 const youtubeDL = new YoutubeDL('', [])
133 134
134 log.debug('Fetching object.', youtubeInfo) 135 log.debug('Fetching object.', youtubeInfo)
135 136
136 const videoInfo = await fetchObject(youtubeInfo) 137 const videoInfo = await fetchObject(youtubeInfo)
137 log.debug('Fetched object.', videoInfo) 138 log.debug('Fetched object.', videoInfo)
138 139
139 const originallyPublishedAt = buildOriginallyPublishedAt(videoInfo) 140 const originallyPublishedAt = youtubeDL.buildOriginallyPublishedAt(videoInfo)
140 if (options.since && originallyPublishedAt && originallyPublishedAt.getTime() < options.since.getTime()) { 141 if (options.since && originallyPublishedAt && originallyPublishedAt.getTime() < options.since.getTime()) {
141 log.info('Video "%s" has been published before "%s", don\'t upload it.\n', 142 log.info('Video "%s" has been published before "%s", don\'t upload it.\n',
142 videoInfo.title, formatDate(options.since)) 143 videoInfo.title, formatDate(options.since))
@@ -161,13 +162,14 @@ async function processVideo (parameters: {
161 162
162 log.info('Downloading video "%s"...', videoInfo.title) 163 log.info('Downloading video "%s"...', videoInfo.title)
163 164
164 const youtubeDLOptions = [ '-f', getYoutubeDLVideoFormat(), ...command.args, '-o', path ] 165 const youtubeDLOptions = [ '-f', youtubeDL.getYoutubeDLVideoFormat(), ...command.args, '-o', path ]
165 try { 166 try {
166 const youtubeDL = await safeGetYoutubeDL() 167 const youtubeDLBinary = await YoutubeDL.safeGetYoutubeDL()
167 const youtubeDLExec = promisify(youtubeDL.exec).bind(youtubeDL) 168 const youtubeDLExec = promisify(youtubeDLBinary.exec).bind(youtubeDLBinary)
168 const output = await youtubeDLExec(videoInfo.url, youtubeDLOptions, processOptions) 169 const output = await youtubeDLExec(videoInfo.url, youtubeDLOptions, processOptions)
169 log.info(output.join('\n')) 170 log.info(output.join('\n'))
170 await uploadVideoOnPeerTube({ 171 await uploadVideoOnPeerTube({
172 youtubeDL,
171 cwd, 173 cwd,
172 url, 174 url,
173 user, 175 user,
@@ -180,13 +182,14 @@ async function processVideo (parameters: {
180} 182}
181 183
182async function uploadVideoOnPeerTube (parameters: { 184async function uploadVideoOnPeerTube (parameters: {
185 youtubeDL: YoutubeDL
183 videoInfo: any 186 videoInfo: any
184 videoPath: string 187 videoPath: string
185 cwd: string 188 cwd: string
186 url: string 189 url: string
187 user: { username: string, password: string } 190 user: { username: string, password: string }
188}) { 191}) {
189 const { videoInfo, videoPath, cwd, url, user } = parameters 192 const { youtubeDL, videoInfo, videoPath, cwd, url, user } = parameters
190 193
191 const category = await getCategory(videoInfo.categories, url) 194 const category = await getCategory(videoInfo.categories, url)
192 const licence = getLicence(videoInfo.license) 195 const licence = getLicence(videoInfo.license)
@@ -205,7 +208,7 @@ async function uploadVideoOnPeerTube (parameters: {
205 await doRequestAndSaveToFile(videoInfo.thumbnail, thumbnailfile) 208 await doRequestAndSaveToFile(videoInfo.thumbnail, thumbnailfile)
206 } 209 }
207 210
208 const originallyPublishedAt = buildOriginallyPublishedAt(videoInfo) 211 const originallyPublishedAt = youtubeDL.buildOriginallyPublishedAt(videoInfo)
209 212
210 const defaultAttributes = { 213 const defaultAttributes = {
211 name: truncate(videoInfo.title, { 214 name: truncate(videoInfo.title, {
@@ -304,7 +307,7 @@ function fetchObject (info: any) {
304 const url = buildUrl(info) 307 const url = buildUrl(info)
305 308
306 return new Promise<any>(async (res, rej) => { 309 return new Promise<any>(async (res, rej) => {
307 const youtubeDL = await safeGetYoutubeDL() 310 const youtubeDL = await YoutubeDL.safeGetYoutubeDL()
308 youtubeDL.getInfo(url, undefined, processOptions, (err, videoInfo) => { 311 youtubeDL.getInfo(url, undefined, processOptions, (err, videoInfo) => {
309 if (err) return rej(err) 312 if (err) return rej(err)
310 313
diff --git a/server/tools/peertube-plugins.ts b/server/tools/peertube-plugins.ts
index c8a576844..54ea1264d 100644
--- a/server/tools/peertube-plugins.ts
+++ b/server/tools/peertube-plugins.ts
@@ -3,14 +3,12 @@
3import { registerTSPaths } from '../helpers/register-ts-paths' 3import { registerTSPaths } from '../helpers/register-ts-paths'
4registerTSPaths() 4registerTSPaths()
5 5
6import * as program from 'commander' 6import { program, Command, OptionValues } from 'commander'
7import { PluginType } from '../../shared/models/plugins/plugin.type'
8import { installPlugin, listPlugins, uninstallPlugin, updatePlugin } from '../../shared/extra-utils/server/plugins' 7import { installPlugin, listPlugins, uninstallPlugin, updatePlugin } from '../../shared/extra-utils/server/plugins'
9import { getAdminTokenOrDie, getServerCredentials } from './cli' 8import { getAdminTokenOrDie, getServerCredentials } from './cli'
10import { PeerTubePlugin } from '../../shared/models/plugins/peertube-plugin.model' 9import { PeerTubePlugin, PluginType } from '../../shared/models'
11import { isAbsolute } from 'path' 10import { isAbsolute } from 'path'
12import * as CliTable3 from 'cli-table3' 11import * as CliTable3 from 'cli-table3'
13import commander = require('commander')
14 12
15program 13program
16 .name('plugins') 14 .name('plugins')
@@ -63,7 +61,7 @@ program.parse(process.argv)
63 61
64// ---------------------------------------------------------------------------- 62// ----------------------------------------------------------------------------
65 63
66async function pluginsListCLI (command: commander.CommanderStatic, options: commander.OptionValues) { 64async function pluginsListCLI (command: Command, options: OptionValues) {
67 const { url, username, password } = await getServerCredentials(command) 65 const { url, username, password } = await getServerCredentials(command)
68 const accessToken = await getAdminTokenOrDie(url, username, password) 66 const accessToken = await getAdminTokenOrDie(url, username, password)
69 67
@@ -102,7 +100,7 @@ async function pluginsListCLI (command: commander.CommanderStatic, options: comm
102 process.exit(0) 100 process.exit(0)
103} 101}
104 102
105async function installPluginCLI (command: commander.CommanderStatic, options: commander.OptionValues) { 103async function installPluginCLI (command: Command, options: OptionValues) {
106 if (!options.path && !options.npmName) { 104 if (!options.path && !options.npmName) {
107 console.error('You need to specify the npm name or the path of the plugin you want to install.\n') 105 console.error('You need to specify the npm name or the path of the plugin you want to install.\n')
108 program.outputHelp() 106 program.outputHelp()
@@ -133,7 +131,7 @@ async function installPluginCLI (command: commander.CommanderStatic, options: co
133 process.exit(0) 131 process.exit(0)
134} 132}
135 133
136async function updatePluginCLI (command: commander.CommanderStatic, options: commander.OptionValues) { 134async function updatePluginCLI (command: Command, options: OptionValues) {
137 if (!options.path && !options.npmName) { 135 if (!options.path && !options.npmName) {
138 console.error('You need to specify the npm name or the path of the plugin you want to update.\n') 136 console.error('You need to specify the npm name or the path of the plugin you want to update.\n')
139 program.outputHelp() 137 program.outputHelp()
@@ -164,7 +162,7 @@ async function updatePluginCLI (command: commander.CommanderStatic, options: com
164 process.exit(0) 162 process.exit(0)
165} 163}
166 164
167async function uninstallPluginCLI (command: commander.CommanderStatic, options: commander.OptionValues) { 165async function uninstallPluginCLI (command: Command, options: OptionValues) {
168 if (!options.npmName) { 166 if (!options.npmName) {
169 console.error('You need to specify the npm name of the plugin/theme you want to uninstall.\n') 167 console.error('You need to specify the npm name of the plugin/theme you want to uninstall.\n')
170 program.outputHelp() 168 program.outputHelp()
diff --git a/server/tools/peertube-redundancy.ts b/server/tools/peertube-redundancy.ts
index 5bc80ddb9..4810deee0 100644
--- a/server/tools/peertube-redundancy.ts
+++ b/server/tools/peertube-redundancy.ts
@@ -3,7 +3,7 @@
3import { registerTSPaths } from '../helpers/register-ts-paths' 3import { registerTSPaths } from '../helpers/register-ts-paths'
4registerTSPaths() 4registerTSPaths()
5 5
6import * as program from 'commander' 6import { program, Command } from 'commander'
7import { getAdminTokenOrDie, getServerCredentials } from './cli' 7import { getAdminTokenOrDie, getServerCredentials } from './cli'
8import { VideoRedundanciesTarget, VideoRedundancy } from '@shared/models' 8import { VideoRedundanciesTarget, VideoRedundancy } from '@shared/models'
9import { addVideoRedundancy, listVideoRedundancies, removeVideoRedundancy } from '@shared/extra-utils/server/redundancy' 9import { addVideoRedundancy, listVideoRedundancies, removeVideoRedundancy } from '@shared/extra-utils/server/redundancy'
@@ -14,7 +14,6 @@ import { URL } from 'url'
14import { uniq } from 'lodash' 14import { uniq } from 'lodash'
15 15
16import bytes = require('bytes') 16import bytes = require('bytes')
17import commander = require('commander')
18 17
19program 18program
20 .name('plugins') 19 .name('plugins')
@@ -105,7 +104,7 @@ async function listRedundanciesCLI (target: VideoRedundanciesTarget) {
105 process.exit(0) 104 process.exit(0)
106} 105}
107 106
108async function addRedundancyCLI (options: { video: number }, command: commander.CommanderStatic) { 107async function addRedundancyCLI (options: { video: number }, command: Command) {
109 const { url, username, password } = await getServerCredentials(command) 108 const { url, username, password } = await getServerCredentials(command)
110 const accessToken = await getAdminTokenOrDie(url, username, password) 109 const accessToken = await getAdminTokenOrDie(url, username, password)
111 110
@@ -138,7 +137,7 @@ async function addRedundancyCLI (options: { video: number }, command: commander.
138 } 137 }
139} 138}
140 139
141async function removeRedundancyCLI (options: { video: number }, command: commander.CommanderStatic) { 140async function removeRedundancyCLI (options: { video: number }, command: Command) {
142 const { url, username, password } = await getServerCredentials(command) 141 const { url, username, password } = await getServerCredentials(command)
143 const accessToken = await getAdminTokenOrDie(url, username, password) 142 const accessToken = await getAdminTokenOrDie(url, username, password)
144 143
diff --git a/server/tools/peertube-repl.ts b/server/tools/peertube-repl.ts
index a38d51801..eb0a776b8 100644
--- a/server/tools/peertube-repl.ts
+++ b/server/tools/peertube-repl.ts
@@ -4,7 +4,6 @@ registerTSPaths()
4import * as repl from 'repl' 4import * as repl from 'repl'
5import * as path from 'path' 5import * as path from 'path'
6import * as _ from 'lodash' 6import * as _ from 'lodash'
7import { uuidv1, uuidv3, uuidv4, uuidv5 } from 'uuid'
8import * as Sequelize from 'sequelize' 7import * as Sequelize from 'sequelize'
9import * as YoutubeDL from 'youtube-dl' 8import * as YoutubeDL from 'youtube-dl'
10import { initDatabaseModels, sequelizeTypescript } from '../initializers/database' 9import { initDatabaseModels, sequelizeTypescript } from '../initializers/database'
@@ -15,7 +14,6 @@ import * as modelsUtils from '../models/utils'
15import * as coreUtils from '../helpers/core-utils' 14import * as coreUtils from '../helpers/core-utils'
16import * as ffmpegUtils from '../helpers/ffmpeg-utils' 15import * as ffmpegUtils from '../helpers/ffmpeg-utils'
17import * as peertubeCryptoUtils from '../helpers/peertube-crypto' 16import * as peertubeCryptoUtils from '../helpers/peertube-crypto'
18import * as signupUtils from '../helpers/signup'
19import * as utils from '../helpers/utils' 17import * as utils from '../helpers/utils'
20import * as YoutubeDLUtils from '../helpers/youtube-dl' 18import * as YoutubeDLUtils from '../helpers/youtube-dl'
21 19
@@ -32,10 +30,6 @@ const start = async () => {
32 env: process.env, 30 env: process.env,
33 lodash: _, 31 lodash: _,
34 path, 32 path,
35 uuidv1,
36 uuidv3,
37 uuidv4,
38 uuidv5,
39 cli, 33 cli,
40 logger, 34 logger,
41 constants, 35 constants,
@@ -50,7 +44,6 @@ const start = async () => {
50 coreUtils, 44 coreUtils,
51 ffmpegUtils, 45 ffmpegUtils,
52 peertubeCryptoUtils, 46 peertubeCryptoUtils,
53 signupUtils,
54 utils, 47 utils,
55 YoutubeDLUtils 48 YoutubeDLUtils
56 } 49 }
diff --git a/server/tools/peertube-upload.ts b/server/tools/peertube-upload.ts
index 86c7f3d91..02edbd809 100644
--- a/server/tools/peertube-upload.ts
+++ b/server/tools/peertube-upload.ts
@@ -1,7 +1,7 @@
1import { registerTSPaths } from '../helpers/register-ts-paths' 1import { registerTSPaths } from '../helpers/register-ts-paths'
2registerTSPaths() 2registerTSPaths()
3 3
4import * as program from 'commander' 4import { program } from 'commander'
5import { access, constants } from 'fs-extra' 5import { access, constants } from 'fs-extra'
6import { isAbsolute } from 'path' 6import { isAbsolute } from 'path'
7import { getAccessToken } from '../../shared/extra-utils' 7import { getAccessToken } from '../../shared/extra-utils'
diff --git a/server/tools/peertube-watch.ts b/server/tools/peertube-watch.ts
index 6d9cfa3b7..892c9e7a6 100644
--- a/server/tools/peertube-watch.ts
+++ b/server/tools/peertube-watch.ts
@@ -1,7 +1,7 @@
1import { registerTSPaths } from '../helpers/register-ts-paths' 1import { registerTSPaths } from '../helpers/register-ts-paths'
2registerTSPaths() 2registerTSPaths()
3 3
4import * as program from 'commander' 4import { program, Option, OptionValues } from 'commander'
5import { join } from 'path' 5import { join } from 'path'
6import { execSync } from 'child_process' 6import { execSync } from 'child_process'
7 7
@@ -9,7 +9,7 @@ program
9 .name('watch') 9 .name('watch')
10 .arguments('<url>') 10 .arguments('<url>')
11 .addOption( 11 .addOption(
12 new program.Option('-g, --gui <player>', 'player type') 12 new Option('-g, --gui <player>', 'player type')
13 .default('vlc') 13 .default('vlc')
14 .choices([ 'airplay', 'stdout', 'chromecast', 'mpv', 'vlc', 'mplayer', 'xbmc' ]) 14 .choices([ 'airplay', 'stdout', 'chromecast', 'mpv', 'vlc', 'mplayer', 'xbmc' ])
15 ) 15 )
@@ -22,7 +22,7 @@ program
22 .action((url, options) => run(url, options)) 22 .action((url, options) => run(url, options))
23 .parse(process.argv) 23 .parse(process.argv)
24 24
25function run (url: string, options: program.OptionValues) { 25function run (url: string, options: OptionValues) {
26 if (!url) { 26 if (!url) {
27 console.error('<url> positional argument is required.') 27 console.error('<url> positional argument is required.')
28 process.exit(-1) 28 process.exit(-1)
@@ -30,7 +30,7 @@ function run (url: string, options: program.OptionValues) {
30 30
31 const cmd = 'node ' + join(__dirname, 'node_modules', 'webtorrent-hybrid', 'bin', 'cmd.js') 31 const cmd = 'node ' + join(__dirname, 'node_modules', 'webtorrent-hybrid', 'bin', 'cmd.js')
32 const args = ` --${options.gui} ` + 32 const args = ` --${options.gui} ` +
33 url.replace('videos/watch', 'download/torrents') + 33 url.replace(/(\/videos\/watch\/)|\/w\//, '/download/torrents/') +
34 `-${options.resolution}.torrent` 34 `-${options.resolution}.torrent`
35 35
36 try { 36 try {
diff --git a/server/tools/peertube.ts b/server/tools/peertube.ts
index 655f07f0c..a40c1332e 100644
--- a/server/tools/peertube.ts
+++ b/server/tools/peertube.ts
@@ -5,7 +5,7 @@
5import { registerTSPaths } from '../helpers/register-ts-paths' 5import { registerTSPaths } from '../helpers/register-ts-paths'
6registerTSPaths() 6registerTSPaths()
7 7
8import * as program from 'commander' 8import { CommandOptions, program } from 'commander'
9import { getSettings, version } from './cli' 9import { getSettings, version } from './cli'
10 10
11program 11program
@@ -28,11 +28,11 @@ program
28 .command( 28 .command(
29 'diagnostic [action]', 29 'diagnostic [action]',
30 'like couple therapy, but for your instance', 30 'like couple therapy, but for your instance',
31 { noHelp: true } as program.CommandOptions 31 { noHelp: true } as CommandOptions
32 ).alias('d') 32 ).alias('d')
33 .command('admin', 33 .command('admin',
34 'manage an instance where you have elevated rights', 34 'manage an instance where you have elevated rights',
35 { noHelp: true } as program.CommandOptions 35 { noHelp: true } as CommandOptions
36 ).alias('a') 36 ).alias('a')
37 37
38// help on no command 38// help on no command
diff --git a/server/tools/test.ts b/server/tools/test.ts
index fc7f8d769..fbdbae0b0 100644
--- a/server/tools/test.ts
+++ b/server/tools/test.ts
@@ -2,7 +2,7 @@ import { registerTSPaths } from '../helpers/register-ts-paths'
2registerTSPaths() 2registerTSPaths()
3 3
4import { LiveVideo, LiveVideoCreate, VideoPrivacy } from '@shared/models' 4import { LiveVideo, LiveVideoCreate, VideoPrivacy } from '@shared/models'
5import * as program from 'commander' 5import { program } from 'commander'
6import { 6import {
7 createLive, 7 createLive,
8 flushAndRunServer, 8 flushAndRunServer,
diff --git a/server/tools/yarn.lock b/server/tools/yarn.lock
index 065e32e3d..dceacb223 100644
--- a/server/tools/yarn.lock
+++ b/server/tools/yarn.lock
@@ -3,23 +3,23 @@
3 3
4 4
5"@babel/code-frame@^7.0.0": 5"@babel/code-frame@^7.0.0":
6 version "7.12.13" 6 version "7.14.5"
7 resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.13.tgz#dcfc826beef65e75c50e21d3837d7d95798dd658" 7 resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb"
8 integrity sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g== 8 integrity sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==
9 dependencies: 9 dependencies:
10 "@babel/highlight" "^7.12.13" 10 "@babel/highlight" "^7.14.5"
11 11
12"@babel/helper-validator-identifier@^7.12.11": 12"@babel/helper-validator-identifier@^7.14.5":
13 version "7.12.11" 13 version "7.14.5"
14 resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" 14 resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.5.tgz#d0f0e277c512e0c938277faa85a3968c9a44c0e8"
15 integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw== 15 integrity sha512-5lsetuxCLilmVGyiLEfoHBRX8UCFD+1m2x3Rj97WrW3V7H3u4RWRXA4evMjImCsin2J2YT0QaVDGf+z8ondbAg==
16 16
17"@babel/highlight@^7.12.13": 17"@babel/highlight@^7.14.5":
18 version "7.13.10" 18 version "7.14.5"
19 resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.13.10.tgz#a8b2a66148f5b27d666b15d81774347a731d52d1" 19 resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.14.5.tgz#6861a52f03966405001f6aa534a01a24d99e8cd9"
20 integrity sha512-5aPpe5XQPzflQrFwL1/QoeHkP2MsA4JCntcXHRhEsdsfPVkvPi2w7Qix4iV7t5S/oC9OodGrggd8aco1g3SZFg== 20 integrity sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==
21 dependencies: 21 dependencies:
22 "@babel/helper-validator-identifier" "^7.12.11" 22 "@babel/helper-validator-identifier" "^7.14.5"
23 chalk "^2.0.0" 23 chalk "^2.0.0"
24 js-tokens "^4.0.0" 24 js-tokens "^4.0.0"
25 25
@@ -81,10 +81,10 @@
81 resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9" 81 resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
82 integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w== 82 integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==
83 83
84"@types/node@^13.7.0": 84"@types/node@>=13.7.0":
85 version "13.13.48" 85 version "15.12.2"
86 resolved "https://registry.yarnpkg.com/@types/node/-/node-13.13.48.tgz#46a3df718aed5217277f2395a682e055a487e341" 86 resolved "https://registry.yarnpkg.com/@types/node/-/node-15.12.2.tgz#1f2b42c4be7156ff4a6f914b2fb03d05fa84e38d"
87 integrity sha512-z8wvSsgWQzkr4sVuMEEOvwMdOQjiRY2Y/ZW4fDfjfe3+TfQrZqFKOthBgk2RnVEmtOKrkwdZ7uTvsxTBLjKGDQ== 87 integrity sha512-zjQ69G564OCIWIOHSXyQEEDpdpGl+G348RAKY0XXy9Z5kU9Vzv1GMNnkar/ZJ8dzXB3COzD9Mo9NtRZ4xfgUww==
88 88
89abbrev@1: 89abbrev@1:
90 version "1.1.1" 90 version "1.1.1"
@@ -170,7 +170,7 @@ bencode@^2.0.0, bencode@^2.0.1:
170 dependencies: 170 dependencies:
171 safe-buffer "^5.1.1" 171 safe-buffer "^5.1.1"
172 172
173bep53-range@^1.0.0: 173bep53-range@^1.1.0:
174 version "1.1.0" 174 version "1.1.0"
175 resolved "https://registry.yarnpkg.com/bep53-range/-/bep53-range-1.1.0.tgz#a009311710c955d27eb3a30cf329e8c139693d27" 175 resolved "https://registry.yarnpkg.com/bep53-range/-/bep53-range-1.1.0.tgz#a009311710c955d27eb3a30cf329e8c139693d27"
176 integrity sha512-yGQTG4NtwTciX0Bkgk1FqQL4p+NiCQKpTSFho2lrxvUkXIlzyJDwraj8aYxAxRZMnnOhRr7QlIBoMRPEnIR34Q== 176 integrity sha512-yGQTG4NtwTciX0Bkgk1FqQL4p+NiCQKpTSFho2lrxvUkXIlzyJDwraj8aYxAxRZMnnOhRr7QlIBoMRPEnIR34Q==
@@ -208,55 +208,58 @@ bittorrent-lsd@^1.0.0:
208 chrome-dgram "^3.0.6" 208 chrome-dgram "^3.0.6"
209 debug "^4.2.0" 209 debug "^4.2.0"
210 210
211bittorrent-peerid@^1.3.2: 211bittorrent-peerid@^1.3.3:
212 version "1.3.3" 212 version "1.3.3"
213 resolved "https://registry.yarnpkg.com/bittorrent-peerid/-/bittorrent-peerid-1.3.3.tgz#b8dc79e421f8136d2ffd0b163a18e9d70da09949" 213 resolved "https://registry.yarnpkg.com/bittorrent-peerid/-/bittorrent-peerid-1.3.3.tgz#b8dc79e421f8136d2ffd0b163a18e9d70da09949"
214 integrity sha512-tSh9HdQgwyEAfo1jzoGEis6o/zs4CcdRTchG93XVl5jct+DCAN90M5MVUV76k2vJ9Xg3GAzLB5NLsY/vnVTh6w== 214 integrity sha512-tSh9HdQgwyEAfo1jzoGEis6o/zs4CcdRTchG93XVl5jct+DCAN90M5MVUV76k2vJ9Xg3GAzLB5NLsY/vnVTh6w==
215 215
216bittorrent-protocol@^3.2.0: 216bittorrent-protocol@^3.3.1:
217 version "3.3.1" 217 version "3.4.1"
218 resolved "https://registry.yarnpkg.com/bittorrent-protocol/-/bittorrent-protocol-3.3.1.tgz#b7a8e66babc423c1eb8e379c1cf7ded26a400a73" 218 resolved "https://registry.yarnpkg.com/bittorrent-protocol/-/bittorrent-protocol-3.4.1.tgz#b481d09dbf910fa7fcca5f06a7c1c4246151d4d1"
219 integrity sha512-DJy0/jjqJD62PPJY79duCccmPMihp3KPowlmd7BLEU8FTtnDsYjso6BAx+pWwCKOeDORdc9RiJ7L72x3taCh6g== 219 integrity sha512-3qBW4ZZrUZKN7HzHbX4+kbiphrTNeraMp3i9n3wobicysjibAV8SBDY+sGiBN4SgXV6WvEW4kyRPIjoSqW+khw==
220 dependencies: 220 dependencies:
221 bencode "^2.0.1" 221 bencode "^2.0.1"
222 bitfield "^4.0.0" 222 bitfield "^4.0.0"
223 buffer-xor "^2.0.2"
223 debug "^4.3.1" 224 debug "^4.3.1"
224 randombytes "^2.1.0" 225 randombytes "^2.1.0"
226 rc4 "^0.1.5"
225 readable-stream "^3.6.0" 227 readable-stream "^3.6.0"
228 simple-sha1 "^3.0.0"
226 speedometer "^1.1.0" 229 speedometer "^1.1.0"
227 unordered-array-remove "^1.0.2" 230 unordered-array-remove "^1.0.2"
228 231
229bittorrent-tracker@^9.0.0: 232bittorrent-tracker@^9.0.0:
230 version "9.17.0" 233 version "9.17.2"
231 resolved "https://registry.yarnpkg.com/bittorrent-tracker/-/bittorrent-tracker-9.17.0.tgz#8b4b6f6a49efa9023267c3ca22e1a5f63216fc1f" 234 resolved "https://registry.yarnpkg.com/bittorrent-tracker/-/bittorrent-tracker-9.17.2.tgz#1afb02d3d2fb474c13389c45e8a2b6919bff40bd"
232 integrity sha512-ErpOx8AAUW8eLwxnEHp15vs0LDJECLADHISEBM+HXclG3J2/9kMBJ31IjwlB8kUNigknSwm8odAThjJEeyL1yA== 235 integrity sha512-hXjed0OnB16da+ScJUZnrAZbf9gMgSLKqh5rJebtYnTRgN4o1mX0DOPH3Nf5RFCs935ibhSmZN5nwbkh+3MdEA==
233 dependencies: 236 dependencies:
234 bencode "^2.0.1" 237 bencode "^2.0.1"
235 bittorrent-peerid "^1.3.2" 238 bittorrent-peerid "^1.3.3"
236 bn.js "^5.1.1" 239 bn.js "^5.2.0"
237 chrome-dgram "^3.0.4" 240 chrome-dgram "^3.0.6"
238 compact2string "^1.4.1" 241 compact2string "^1.4.1"
239 debug "^4.1.1" 242 debug "^4.1.1"
240 ip "^1.1.5" 243 ip "^1.1.5"
241 lru "^3.1.0" 244 lru "^3.1.0"
242 minimist "^1.2.5" 245 minimist "^1.2.5"
243 once "^1.4.0" 246 once "^1.4.0"
244 queue-microtask "^1.2.2" 247 queue-microtask "^1.2.3"
245 random-iterate "^1.0.1" 248 random-iterate "^1.0.1"
246 randombytes "^2.1.0" 249 randombytes "^2.1.0"
247 run-parallel "^1.1.9" 250 run-parallel "^1.2.0"
248 run-series "^1.1.8" 251 run-series "^1.1.9"
249 simple-get "^4.0.0" 252 simple-get "^4.0.0"
250 simple-peer "^9.7.1" 253 simple-peer "^9.11.0"
251 simple-websocket "^9.0.0" 254 simple-websocket "^9.1.0"
252 string2compact "^1.3.0" 255 string2compact "^1.3.0"
253 unordered-array-remove "^1.0.2" 256 unordered-array-remove "^1.0.2"
254 ws "^7.3.0" 257 ws "^7.4.5"
255 optionalDependencies: 258 optionalDependencies:
256 bufferutil "^4.0.1" 259 bufferutil "^4.0.3"
257 utf-8-validate "^5.0.2" 260 utf-8-validate "^5.0.5"
258 261
259blob-to-buffer@^1.2.6, blob-to-buffer@^1.2.9: 262blob-to-buffer@^1.2.9:
260 version "1.2.9" 263 version "1.2.9"
261 resolved "https://registry.yarnpkg.com/blob-to-buffer/-/blob-to-buffer-1.2.9.tgz#a17fd6c1c564011408f8971e451544245daaa84a" 264 resolved "https://registry.yarnpkg.com/blob-to-buffer/-/blob-to-buffer-1.2.9.tgz#a17fd6c1c564011408f8971e451544245daaa84a"
262 integrity sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA== 265 integrity sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA==
@@ -268,7 +271,7 @@ block-stream2@^2.0.0, block-stream2@^2.1.0:
268 dependencies: 271 dependencies:
269 readable-stream "^3.4.0" 272 readable-stream "^3.4.0"
270 273
271bn.js@^5.1.1: 274bn.js@^5.2.0:
272 version "5.2.0" 275 version "5.2.0"
273 resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.0.tgz#358860674396c6997771a9d051fcc1b57d4ae002" 276 resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-5.2.0.tgz#358860674396c6997771a9d051fcc1b57d4ae002"
274 integrity sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw== 277 integrity sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw==
@@ -314,6 +317,13 @@ buffer-indexof@^1.0.0:
314 resolved "https://registry.yarnpkg.com/buffer-indexof/-/buffer-indexof-1.1.1.tgz#52fabcc6a606d1a00302802648ef68f639da268c" 317 resolved "https://registry.yarnpkg.com/buffer-indexof/-/buffer-indexof-1.1.1.tgz#52fabcc6a606d1a00302802648ef68f639da268c"
315 integrity sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g== 318 integrity sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g==
316 319
320buffer-xor@^2.0.2:
321 version "2.0.2"
322 resolved "https://registry.yarnpkg.com/buffer-xor/-/buffer-xor-2.0.2.tgz#34f7c64f04c777a1f8aac5e661273bb9dd320289"
323 integrity sha512-eHslX0bin3GB+Lx2p7lEYRShRewuNZL3fUl4qlVJGGiwoPGftmt8JQgk2Y9Ji5/01TnVDo33E5b5O3vUB1HdqQ==
324 dependencies:
325 safe-buffer "^5.1.1"
326
317buffer@^6.0.3: 327buffer@^6.0.3:
318 version "6.0.3" 328 version "6.0.3"
319 resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" 329 resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6"
@@ -322,7 +332,7 @@ buffer@^6.0.3:
322 base64-js "^1.3.1" 332 base64-js "^1.3.1"
323 ieee754 "^1.2.1" 333 ieee754 "^1.2.1"
324 334
325bufferutil@^4.0.1: 335bufferutil@^4.0.3:
326 version "4.0.3" 336 version "4.0.3"
327 resolved "https://registry.yarnpkg.com/bufferutil/-/bufferutil-4.0.3.tgz#66724b756bed23cd7c28c4d306d7994f9943cc6b" 337 resolved "https://registry.yarnpkg.com/bufferutil/-/bufferutil-4.0.3.tgz#66724b756bed23cd7c28c4d306d7994f9943cc6b"
328 integrity sha512-yEYTwGndELGvfXsImMBLop58eaGW+YdONi1fNjTINSY98tmMmFijBG6WXgdkfuLNt4imzQNtIE+eBp1PVpMCSw== 338 integrity sha512-yEYTwGndELGvfXsImMBLop58eaGW+YdONi1fNjTINSY98tmMmFijBG6WXgdkfuLNt4imzQNtIE+eBp1PVpMCSw==
@@ -364,7 +374,7 @@ chownr@^1.1.1:
364 resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" 374 resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
365 integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== 375 integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
366 376
367chrome-dgram@^3.0.2, chrome-dgram@^3.0.4, chrome-dgram@^3.0.6: 377chrome-dgram@^3.0.2, chrome-dgram@^3.0.6:
368 version "3.0.6" 378 version "3.0.6"
369 resolved "https://registry.yarnpkg.com/chrome-dgram/-/chrome-dgram-3.0.6.tgz#2288b5c7471f66f073691206d36319dda713cf55" 379 resolved "https://registry.yarnpkg.com/chrome-dgram/-/chrome-dgram-3.0.6.tgz#2288b5c7471f66f073691206d36319dda713cf55"
370 integrity sha512-bqBsUuaOiXiqxXt/zA/jukNJJ4oaOtc7ciwqJpZVEaaXwwxqgI2/ZdG02vXYWUhHGziDlvGMQWk0qObgJwVYKA== 380 integrity sha512-bqBsUuaOiXiqxXt/zA/jukNJJ4oaOtc7ciwqJpZVEaaXwwxqgI2/ZdG02vXYWUhHGziDlvGMQWk0qObgJwVYKA==
@@ -387,22 +397,22 @@ chrome-net@^3.3.2, chrome-net@^3.3.3, chrome-net@^3.3.4:
387 inherits "^2.0.1" 397 inherits "^2.0.1"
388 398
389chromecasts@^1.9.1: 399chromecasts@^1.9.1:
390 version "1.9.1" 400 version "1.10.0"
391 resolved "https://registry.yarnpkg.com/chromecasts/-/chromecasts-1.9.1.tgz#67b162e8414d57d6106c49fe4a0e9b08f20bbd12" 401 resolved "https://registry.yarnpkg.com/chromecasts/-/chromecasts-1.10.0.tgz#7016c9f31b99e40636b21a75976c9364e3fbabbb"
392 integrity sha512-nsXv7ufgrpC8s5DUm6FJEa2XJ2VvE9FmbTVi6r4zGreTFTTSRSJjvqVEqLUFX/fGo/zbSre3zdoV+Pu9DGLz0A== 402 integrity sha512-vrOiuHxqLb0bWRBlvyL18cHU8PcbZ7iJvwDB6aHdbtdIDVWuzWWZwDyAWHu54j4JNqyaAyYBJiJ6bbHInVcqBQ==
393 dependencies: 403 dependencies:
394 castv2-client "^1.1.0" 404 castv2-client "^1.1.0"
395 debug "^2.1.3" 405 debug "^2.1.3"
396 dns-txt "^2.0.2" 406 dns-txt "^2.0.2"
397 mime "^1.3.4" 407 mime "^1.3.4"
398 multicast-dns "^6.0.1" 408 multicast-dns "^7.2.2"
399 simple-get "^2.0.0" 409 simple-get "^2.0.0"
400 thunky "^0.1.0" 410 thunky "^0.1.0"
401 xml2js "^0.4.8" 411 xml2js "^0.4.8"
402 optionalDependencies: 412 optionalDependencies:
403 node-ssdp "^2.2.0" 413 node-ssdp "^2.2.0"
404 414
405chunk-store-stream@^4.2.0: 415chunk-store-stream@^4.3.0:
406 version "4.3.0" 416 version "4.3.0"
407 resolved "https://registry.yarnpkg.com/chunk-store-stream/-/chunk-store-stream-4.3.0.tgz#3de5f4dfe19729366c29bb7ed52d139f9af29f0e" 417 resolved "https://registry.yarnpkg.com/chunk-store-stream/-/chunk-store-stream-4.3.0.tgz#3de5f4dfe19729366c29bb7ed52d139f9af29f0e"
408 integrity sha512-qby+/RXoiMoTVtPiylWZt7KFF1jy6M829TzMi2hxZtBIH9ptV19wxcft6zGiXLokJgCbuZPGNGab6DWHqiSEKw== 418 integrity sha512-qby+/RXoiMoTVtPiylWZt7KFF1jy6M829TzMi2hxZtBIH9ptV19wxcft6zGiXLokJgCbuZPGNGab6DWHqiSEKw==
@@ -452,7 +462,7 @@ common-tags@^1.8.0:
452 resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937" 462 resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937"
453 integrity sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw== 463 integrity sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw==
454 464
455compact2string@^1.2.0, compact2string@^1.4.1: 465compact2string@^1.4.1:
456 version "1.4.1" 466 version "1.4.1"
457 resolved "https://registry.yarnpkg.com/compact2string/-/compact2string-1.4.1.tgz#8d34929055f8300a13cfc030ad1832e2e53c2e25" 467 resolved "https://registry.yarnpkg.com/compact2string/-/compact2string-1.4.1.tgz#8d34929055f8300a13cfc030ad1832e2e53c2e25"
458 integrity sha512-3D+EY5nsRhqnOwDxveBv5T8wGo4DEvYxjDtPGmdOX+gfr5gE92c2RC0w2wa+xEefm07QuVqqcF3nZJUZ92l/og== 468 integrity sha512-3D+EY5nsRhqnOwDxveBv5T8wGo4DEvYxjDtPGmdOX+gfr5gE92c2RC0w2wa+xEefm07QuVqqcF3nZJUZ92l/og==
@@ -489,7 +499,7 @@ cpus@^1.0.3:
489 resolved "https://registry.yarnpkg.com/cpus/-/cpus-1.0.3.tgz#4ef6deea461968d6329d07dd01205685df2934a2" 499 resolved "https://registry.yarnpkg.com/cpus/-/cpus-1.0.3.tgz#4ef6deea461968d6329d07dd01205685df2934a2"
490 integrity sha512-PXHBvGLuL69u55IkLa5e5838fLhIMHxmkV4ge42a8alGyn7BtawYgI0hQ849EedvtHIOLNNH3i6eQU1BiE9SUA== 500 integrity sha512-PXHBvGLuL69u55IkLa5e5838fLhIMHxmkV4ge42a8alGyn7BtawYgI0hQ849EedvtHIOLNNH3i6eQU1BiE9SUA==
491 501
492create-torrent@^4.4.2, create-torrent@^4.4.4: 502create-torrent@^4.4.2, create-torrent@^4.7.0:
493 version "4.7.0" 503 version "4.7.0"
494 resolved "https://registry.yarnpkg.com/create-torrent/-/create-torrent-4.7.0.tgz#ba5d52d41e7621d0d61c895c8026d3fb22aa4333" 504 resolved "https://registry.yarnpkg.com/create-torrent/-/create-torrent-4.7.0.tgz#ba5d52d41e7621d0d61c895c8026d3fb22aa4333"
495 integrity sha512-Pb3XjZNKdCs0Nk46yFKb82y+a3xRQeMvGi1AlJfIV40y/iwkgBqzS5EfqdnakEOvh2jzTOx3v8QxZpkz4hPzyw== 505 integrity sha512-Pb3XjZNKdCs0Nk46yFKb82y+a3xRQeMvGi1AlJfIV40y/iwkgBqzS5EfqdnakEOvh2jzTOx3v8QxZpkz4hPzyw==
@@ -547,13 +557,6 @@ decompress-response@^3.3.0:
547 dependencies: 557 dependencies:
548 mimic-response "^1.0.0" 558 mimic-response "^1.0.0"
549 559
550decompress-response@^4.2.0:
551 version "4.2.1"
552 resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986"
553 integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==
554 dependencies:
555 mimic-response "^2.0.0"
556
557decompress-response@^6.0.0: 560decompress-response@^6.0.0:
558 version "6.0.0" 561 version "6.0.0"
559 resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" 562 resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc"
@@ -566,15 +569,20 @@ deep-extend@^0.6.0:
566 resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" 569 resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
567 integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== 570 integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
568 571
572define-lazy-prop@^2.0.0:
573 version "2.0.0"
574 resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f"
575 integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==
576
569delegates@^1.0.0: 577delegates@^1.0.0:
570 version "1.0.0" 578 version "1.0.0"
571 resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" 579 resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
572 integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= 580 integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
573 581
574detect-indent@^6.0.0: 582detect-indent@^6.0.0:
575 version "6.0.0" 583 version "6.1.0"
576 resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.0.0.tgz#0abd0f549f69fc6659a254fe96786186b6f528fd" 584 resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.1.0.tgz#592485ebbbf6b3b1ab2be175c8393d04ca0d57e6"
577 integrity sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA== 585 integrity sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==
578 586
579detect-libc@^1.0.2: 587detect-libc@^1.0.2:
580 version "1.0.3" 588 version "1.0.3"
@@ -595,13 +603,12 @@ dlnacasts@^0.1.0:
595 upnp-mediarenderer-client "^1.2.2" 603 upnp-mediarenderer-client "^1.2.2"
596 xml2js "^0.4.8" 604 xml2js "^0.4.8"
597 605
598dns-packet@^1.3.1: 606dns-packet@^5.2.2:
599 version "1.3.1" 607 version "5.2.4"
600 resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-1.3.1.tgz#12aa426981075be500b910eedcd0b47dd7deda5a" 608 resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-5.2.4.tgz#e004f409eadfa8ec861964dcb9eb395884fcf67d"
601 integrity sha512-0UxfQkMhYAUaZI+xrNZOz/as5KgDU0M/fQ9b6SpkyLbk3GEswDi6PADJVaYJradtRVsRIlF1zLyOodbcTCDzUg== 609 integrity sha512-vgu5Bx5IV8mXmh/9cn1lzn+J7okFlXe1vBRp+kCBJXg1nBED6Z/Q4e+QaDxQRSozMr14p/VQmdXwsf/I2wGjUA==
602 dependencies: 610 dependencies:
603 ip "^1.1.0" 611 ip "^1.1.5"
604 safe-buffer "^5.0.1"
605 612
606dns-txt@^2.0.2: 613dns-txt@^2.0.2:
607 version "2.0.2" 614 version "2.0.2"
@@ -708,7 +715,7 @@ freelist@^1.0.3:
708 resolved "https://registry.yarnpkg.com/freelist/-/freelist-1.0.3.tgz#006775509f3935701784d3ed2fc9f12c9df1bab2" 715 resolved "https://registry.yarnpkg.com/freelist/-/freelist-1.0.3.tgz#006775509f3935701784d3ed2fc9f12c9df1bab2"
709 integrity sha1-AGd1UJ85NXAXhNPtL8nxLJ3xurI= 716 integrity sha1-AGd1UJ85NXAXhNPtL8nxLJ3xurI=
710 717
711fs-chunk-store@^2.0.2: 718fs-chunk-store@^2.0.3:
712 version "2.0.3" 719 version "2.0.3"
713 resolved "https://registry.yarnpkg.com/fs-chunk-store/-/fs-chunk-store-2.0.3.tgz#21e51f1833a84a07cb5e911d058dae084030375a" 720 resolved "https://registry.yarnpkg.com/fs-chunk-store/-/fs-chunk-store-2.0.3.tgz#21e51f1833a84a07cb5e911d058dae084030375a"
714 integrity sha512-qQi93nHX3880gtoQPt1hKQcuYBNVfCbMk8OVRDqR0cJ0riheELW25ry9yl7pII8E9gOAONTGKBD5N/zGHFSVQg== 721 integrity sha512-qQi93nHX3880gtoQPt1hKQcuYBNVfCbMk8OVRDqR0cJ0riheELW25ry9yl7pII8E9gOAONTGKBD5N/zGHFSVQg==
@@ -751,11 +758,6 @@ get-browser-rtc@^1.1.0:
751 resolved "https://registry.yarnpkg.com/get-browser-rtc/-/get-browser-rtc-1.1.0.tgz#d1494e299b00f33fc8e9d6d3343ba4ba99711a2c" 758 resolved "https://registry.yarnpkg.com/get-browser-rtc/-/get-browser-rtc-1.1.0.tgz#d1494e299b00f33fc8e9d6d3343ba4ba99711a2c"
752 integrity sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ== 759 integrity sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==
753 760
754get-stdin@^7.0.0:
755 version "7.0.0"
756 resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-7.0.0.tgz#8d5de98f15171a125c5e516643c7a6d0ea8a96f6"
757 integrity sha512-zRKcywvrXlXsA0v0i9Io4KDRaAw7+a1ZpjRwl9Wox8PFlVCCHra7E9c4kqXCoCM9nR5tBkaTTZRBoCm60bFqTQ==
758
759get-stdin@^8.0.0: 761get-stdin@^8.0.0:
760 version "8.0.0" 762 version "8.0.0"
761 resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-8.0.0.tgz#cbad6a73feb75f6eeb22ba9e01f89aa28aa97a53" 763 resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-8.0.0.tgz#cbad6a73feb75f6eeb22ba9e01f89aa28aa97a53"
@@ -767,9 +769,9 @@ get-stream@^3.0.0:
767 integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ= 769 integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=
768 770
769glob@^7.1.3: 771glob@^7.1.3:
770 version "7.1.6" 772 version "7.1.7"
771 resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" 773 resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
772 integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== 774 integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
773 dependencies: 775 dependencies:
774 fs.realpath "^1.0.0" 776 fs.realpath "^1.0.0"
775 inflight "^1.0.4" 777 inflight "^1.0.4"
@@ -824,18 +826,18 @@ ieee754@^1.2.1:
824 integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== 826 integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
825 827
826ignore-walk@^3.0.1: 828ignore-walk@^3.0.1:
827 version "3.0.3" 829 version "3.0.4"
828 resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.3.tgz#017e2447184bfeade7c238e4aefdd1e8f95b1e37" 830 resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.4.tgz#c9a09f69b7c7b479a5d74ac1a3c0d4236d2a6335"
829 integrity sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw== 831 integrity sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==
830 dependencies: 832 dependencies:
831 minimatch "^3.0.4" 833 minimatch "^3.0.4"
832 834
833immediate-chunk-store@^2.1.1: 835immediate-chunk-store@^2.2.0:
834 version "2.1.1" 836 version "2.2.0"
835 resolved "https://registry.yarnpkg.com/immediate-chunk-store/-/immediate-chunk-store-2.1.1.tgz#4b9f001beaab38d62e4aae630ec7ffb98be805ce" 837 resolved "https://registry.yarnpkg.com/immediate-chunk-store/-/immediate-chunk-store-2.2.0.tgz#f56d30ecc7171f6cfcf632b0eb8395a89f92c03c"
836 integrity sha512-y5AxkxqpPTj2dkaAEkDnrMuSX4JNicXHD6yTpLfFnflVejL6yJpzf27obrnlf2PSSQiWUf3735Y9tJEjxvqnoA== 838 integrity sha512-1bHBna0hCa6arRXicu91IiL9RvvkbNYLVq+mzWdaLGZC3hXvX4doh8e1dLhMKez5siu63CYgO5NrGJbRX5lbPA==
837 dependencies: 839 dependencies:
838 queue-microtask "^1.2.0" 840 queue-microtask "^1.2.3"
839 841
840imurmurhash@^0.1.4: 842imurmurhash@^0.1.4:
841 version "0.1.4" 843 version "0.1.4"
@@ -867,15 +869,15 @@ ip-set@^2.1.0:
867 dependencies: 869 dependencies:
868 ip "^1.1.5" 870 ip "^1.1.5"
869 871
870ip@^1.0.1, ip@^1.1.0, ip@^1.1.5: 872ip@^1.0.1, ip@^1.1.5:
871 version "1.1.5" 873 version "1.1.5"
872 resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a" 874 resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
873 integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo= 875 integrity sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=
874 876
875"ipaddr.js@>= 0.1.5": 877"ipaddr.js@>= 0.1.5":
876 version "2.0.0" 878 version "2.0.1"
877 resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.0.tgz#77ccccc8063ae71ab65c55f21b090698e763fc6e" 879 resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0"
878 integrity sha512-S54H9mIj0rbxRIyrDMEuuER86LdlgUg9FSeZ8duQb6CUG2iRrA36MYVQBSprTF/ZeAwvyQ5mDGuNvIPM0BIl3w== 880 integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng==
879 881
880ipaddr.js@^1.0.1: 882ipaddr.js@^1.0.1:
881 version "1.9.1" 883 version "1.9.1"
@@ -892,7 +894,7 @@ is-ascii@^1.0.0:
892 resolved "https://registry.yarnpkg.com/is-ascii/-/is-ascii-1.0.0.tgz#f02ad0259a0921cd199ff21ce1b09e0f6b4e3929" 894 resolved "https://registry.yarnpkg.com/is-ascii/-/is-ascii-1.0.0.tgz#f02ad0259a0921cd199ff21ce1b09e0f6b4e3929"
893 integrity sha1-8CrQJZoJIc0Zn/Ic4bCeD2tOOSk= 895 integrity sha1-8CrQJZoJIc0Zn/Ic4bCeD2tOOSk=
894 896
895is-docker@^2.0.0: 897is-docker@^2.0.0, is-docker@^2.1.1:
896 version "2.2.1" 898 version "2.2.1"
897 resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" 899 resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
898 integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== 900 integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==
@@ -934,7 +936,7 @@ is-typedarray@^1.0.0:
934 resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" 936 resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
935 integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= 937 integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
936 938
937is-wsl@^2.1.1: 939is-wsl@^2.2.0:
938 version "2.2.0" 940 version "2.2.0"
939 resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" 941 resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271"
940 integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== 942 integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==
@@ -1002,7 +1004,7 @@ lines-and-columns@^1.1.6:
1002 resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" 1004 resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
1003 integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= 1005 integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
1004 1006
1005load-ip-set@^2.1.2: 1007load-ip-set@^2.2.1:
1006 version "2.2.1" 1008 version "2.2.1"
1007 resolved "https://registry.yarnpkg.com/load-ip-set/-/load-ip-set-2.2.1.tgz#9496ab8aa14ebf81aeb7c8bb38e7abdf50af3563" 1009 resolved "https://registry.yarnpkg.com/load-ip-set/-/load-ip-set-2.2.1.tgz#9496ab8aa14ebf81aeb7c8bb38e7abdf50af3563"
1008 integrity sha512-G3hQXehU2LTOp52e+lPffpK4EvidfjwbvHaGqmFcp4ptiZagR4xFdL+D08kMX906dxeqZyWhfonEjdUxrWcldg== 1010 integrity sha512-G3hQXehU2LTOp52e+lPffpK4EvidfjwbvHaGqmFcp4ptiZagR4xFdL+D08kMX906dxeqZyWhfonEjdUxrWcldg==
@@ -1035,20 +1037,20 @@ lru@^3.1.0:
1035 dependencies: 1037 dependencies:
1036 inherits "^2.0.1" 1038 inherits "^2.0.1"
1037 1039
1038magnet-uri@^5.1.3: 1040lt_donthave@^1.0.1:
1039 version "5.4.0" 1041 version "1.0.1"
1040 resolved "https://registry.yarnpkg.com/magnet-uri/-/magnet-uri-5.4.0.tgz#5c64d3b8853eafb2f31840df09fbfc90c00f0e1d" 1042 resolved "https://registry.yarnpkg.com/lt_donthave/-/lt_donthave-1.0.1.tgz#a160e08bdf15b9e092172063688855a6c031d8b3"
1041 integrity sha512-ZpqciThlbvE6KkyT5oxAup/6CwjePw1hdtR8NU5+vq2hn9Sp5b7w3bRiJRvo9fMHUj2dWSuVCdkqt9p4ed1V9Q== 1043 integrity sha512-PfOXfDN9GnUjlNHjjxKQuMxPC8s12iSrnmg+Ff1BU1uLn7S1BFAKzpZCu6Gwg3WsCUvTZrZoDSHvy6B/j+N4/Q==
1042 dependencies: 1044 dependencies:
1043 bep53-range "^1.0.0" 1045 debug "^4.2.0"
1044 thirty-two "^1.0.2" 1046 unordered-array-remove "^1.0.2"
1045 1047
1046magnet-uri@^6.0.0: 1048magnet-uri@^6.0.0:
1047 version "6.1.0" 1049 version "6.2.0"
1048 resolved "https://registry.yarnpkg.com/magnet-uri/-/magnet-uri-6.1.0.tgz#fe73026ba1ee77c955097a4979d1003f4fb7ecf7" 1050 resolved "https://registry.yarnpkg.com/magnet-uri/-/magnet-uri-6.2.0.tgz#10f7be050bf23452df210838239b118463c3eeff"
1049 integrity sha512-731qLviHaqN/Ni96wm6gNKuvoip+QHWTznjHNz/4qDlsHh3/CWJoL8fZ18IIRhGJgnWoKJp8RVE5lZvQ60Khhw== 1051 integrity sha512-O9AgdDwT771fnUj0giPYu/rACpz8173y8UXCSOdLITjOVfBenZ9H9q3FqQmveK+ORUMuD+BkKNSZP8C3+IMAKQ==
1050 dependencies: 1052 dependencies:
1051 bep53-range "^1.0.0" 1053 bep53-range "^1.1.0"
1052 thirty-two "^1.0.2" 1054 thirty-two "^1.0.2"
1053 1055
1054make-dir@^3.0.0: 1056make-dir@^3.0.0:
@@ -1084,19 +1086,19 @@ mediasource@^2.2.2, mediasource@^2.4.0:
1084 readable-stream "^3.6.0" 1086 readable-stream "^3.6.0"
1085 to-arraybuffer "^1.0.1" 1087 to-arraybuffer "^1.0.1"
1086 1088
1087memory-chunk-store@^1.3.1: 1089memory-chunk-store@^1.3.5:
1088 version "1.3.2" 1090 version "1.3.5"
1089 resolved "https://registry.yarnpkg.com/memory-chunk-store/-/memory-chunk-store-1.3.2.tgz#3bde573c957c0260d8116e6e2c0ce62ff2032894" 1091 resolved "https://registry.yarnpkg.com/memory-chunk-store/-/memory-chunk-store-1.3.5.tgz#700f712415895600bc5466007333efa19f1de07c"
1090 integrity sha512-EBcbwpdQlzT5aNV0FTT+RAfh1cGEssjiCcRGcTk57mKsnZlRMOtH4Cfk/AqQnkz8xP2dUF+/lgpmErSGwwE1FA== 1092 integrity sha512-E1Xc1U4ifk/FkC2ZsWhCaW1xg9HbE/OBmQTLe2Tr9c27YPSLbW7kw1cnb3kQWD1rDtErFJHa7mB9EVrs7aTx9g==
1091 dependencies: 1093 dependencies:
1092 queue-microtask "^1.2.2" 1094 queue-microtask "^1.2.3"
1093 1095
1094mime@^1.3.4: 1096mime@^1.3.4:
1095 version "1.6.0" 1097 version "1.6.0"
1096 resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" 1098 resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
1097 integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== 1099 integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
1098 1100
1099mime@^2.4.1, mime@^2.4.6, mime@^2.5.0: 1101mime@^2.4.1, mime@^2.4.6, mime@^2.5.2:
1100 version "2.5.2" 1102 version "2.5.2"
1101 resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe" 1103 resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe"
1102 integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg== 1104 integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==
@@ -1106,11 +1108,6 @@ mimic-response@^1.0.0:
1106 resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" 1108 resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
1107 integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== 1109 integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==
1108 1110
1109mimic-response@^2.0.0:
1110 version "2.1.0"
1111 resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43"
1112 integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==
1113
1114mimic-response@^3.1.0: 1111mimic-response@^3.1.0:
1115 version "3.1.0" 1112 version "3.1.0"
1116 resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" 1113 resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9"
@@ -1192,12 +1189,12 @@ ms@^2.1.1:
1192 resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" 1189 resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
1193 integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== 1190 integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
1194 1191
1195multicast-dns@^6.0.1: 1192multicast-dns@^7.2.2:
1196 version "6.2.3" 1193 version "7.2.3"
1197 resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-6.2.3.tgz#a0ec7bd9055c4282f790c3c82f4e28db3b31b229" 1194 resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-7.2.3.tgz#cbd07571dda41807b36f71067681f19e85ccc2cd"
1198 integrity sha512-ji6J5enbMyGRHIAkAOu3WdV8nggqviKCEKtXcOqfphZZtQrmHKycfynJ2V7eVPUA4NhJ6V7Wf4TmGbTwKE9B6g== 1195 integrity sha512-TzxgGSLRLB7tqAlzjgd2x2ZE0cDsGFq4rs9W4yE5xp+7hlRXeUQGtXZsTGfGw2FwWB45rfe8DtXMYBpZGMLUng==
1199 dependencies: 1196 dependencies:
1200 dns-packet "^1.3.1" 1197 dns-packet "^5.2.2"
1201 thunky "^1.0.2" 1198 thunky "^1.0.2"
1202 1199
1203multistream@^4.0.1, multistream@^4.1.0: 1200multistream@^4.0.1, multistream@^4.1.0:
@@ -1295,9 +1292,9 @@ nopt@^4.0.1:
1295 osenv "^0.1.4" 1292 osenv "^0.1.4"
1296 1293
1297npm-bundled@^1.0.1: 1294npm-bundled@^1.0.1:
1298 version "1.1.1" 1295 version "1.1.2"
1299 resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.1.tgz#1edd570865a94cdb1bc8220775e29466c9fb234b" 1296 resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.2.tgz#944c78789bd739035b70baa2ca5cc32b8d860bc1"
1300 integrity sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA== 1297 integrity sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ==
1301 dependencies: 1298 dependencies:
1302 npm-normalize-package-bin "^1.0.1" 1299 npm-normalize-package-bin "^1.0.1"
1303 1300
@@ -1356,13 +1353,14 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0:
1356 dependencies: 1353 dependencies:
1357 wrappy "1" 1354 wrappy "1"
1358 1355
1359open@^7.1.0: 1356open@^8.0.0:
1360 version "7.4.2" 1357 version "8.2.0"
1361 resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" 1358 resolved "https://registry.yarnpkg.com/open/-/open-8.2.0.tgz#d6a4788b00009a9d60df471ecb89842a15fdcfc1"
1362 integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q== 1359 integrity sha512-O8uInONB4asyY3qUcEytpgwxQG3O0fJ/hlssoUHsBboOIRVZzT6Wq+Rwj5nffbeUhOdMjpXeISpDDzHCMRDuOQ==
1363 dependencies: 1360 dependencies:
1364 is-docker "^2.0.0" 1361 define-lazy-prop "^2.0.0"
1365 is-wsl "^2.1.1" 1362 is-docker "^2.1.1"
1363 is-wsl "^2.2.0"
1366 1364
1367os-homedir@^1.0.0: 1365os-homedir@^1.0.0:
1368 version "1.0.2" 1366 version "1.0.2"
@@ -1404,19 +1402,7 @@ parse-json@^5.0.0:
1404 json-parse-even-better-errors "^2.3.0" 1402 json-parse-even-better-errors "^2.3.0"
1405 lines-and-columns "^1.1.6" 1403 lines-and-columns "^1.1.6"
1406 1404
1407parse-torrent@^7.1.3: 1405parse-torrent@^9.0.0, parse-torrent@^9.1.3:
1408 version "7.1.3"
1409 resolved "https://registry.yarnpkg.com/parse-torrent/-/parse-torrent-7.1.3.tgz#5981a0d9381b16297a7af053763068e8da5c9610"
1410 integrity sha512-to8zT7+o6bVTyP35r2QgG1svuFGMFO1mE6ri1oWHgL9tlgnOtfjmfHmA3mOuP9HTIU/8OiZw0NG5zbyqVLxhvA==
1411 dependencies:
1412 bencode "^2.0.0"
1413 blob-to-buffer "^1.2.6"
1414 get-stdin "^7.0.0"
1415 magnet-uri "^5.1.3"
1416 simple-get "^3.0.1"
1417 simple-sha1 "^3.0.0"
1418
1419parse-torrent@^9.1.1:
1420 version "9.1.3" 1406 version "9.1.3"
1421 resolved "https://registry.yarnpkg.com/parse-torrent/-/parse-torrent-9.1.3.tgz#9b4bc8dca243b356bf449938d6d38a259a2a707c" 1407 resolved "https://registry.yarnpkg.com/parse-torrent/-/parse-torrent-9.1.3.tgz#9b4bc8dca243b356bf449938d6d38a259a2a707c"
1422 integrity sha512-/Yr951CvJM8S6TjMaqrsmMxeQEAjDeCX+MZ3hGXXc7DG2wqzp/rzOsHtDzIVqN6NsFRCqy6wYLF/W7Sgvq7bXw== 1408 integrity sha512-/Yr951CvJM8S6TjMaqrsmMxeQEAjDeCX+MZ3hGXXc7DG2wqzp/rzOsHtDzIVqN6NsFRCqy6wYLF/W7Sgvq7bXw==
@@ -1468,9 +1454,9 @@ process-nextick-args@~2.0.0:
1468 integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== 1454 integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
1469 1455
1470protobufjs@^6.8.8: 1456protobufjs@^6.8.8:
1471 version "6.10.2" 1457 version "6.11.2"
1472 resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.10.2.tgz#b9cb6bd8ec8f87514592ba3fdfd28e93f33a469b" 1458 resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.11.2.tgz#de39fabd4ed32beaa08e9bb1e30d08544c1edf8b"
1473 integrity sha512-27yj+04uF6ya9l+qfpH187aqEzfCF4+Uit0I9ZBQVqK09hk/SQzKa2MUqUpXaVa7LOFRg1TSSr3lVxGOk6c0SQ== 1459 integrity sha512-4BQJoPooKJl2G9j3XftkIXjoC9C0Av2NOrWmbLWT1vH32GcSUHjM0Arra6UfTsVyfMAuFzaLucXn1sadxJydAw==
1474 dependencies: 1460 dependencies:
1475 "@protobufjs/aspromise" "^1.1.2" 1461 "@protobufjs/aspromise" "^1.1.2"
1476 "@protobufjs/base64" "^1.1.2" 1462 "@protobufjs/base64" "^1.1.2"
@@ -1483,7 +1469,7 @@ protobufjs@^6.8.8:
1483 "@protobufjs/pool" "^1.1.0" 1469 "@protobufjs/pool" "^1.1.0"
1484 "@protobufjs/utf8" "^1.1.0" 1470 "@protobufjs/utf8" "^1.1.0"
1485 "@types/long" "^4.0.1" 1471 "@types/long" "^4.0.1"
1486 "@types/node" "^13.7.0" 1472 "@types/node" ">=13.7.0"
1487 long "^4.0.0" 1473 long "^4.0.0"
1488 1474
1489pump@^3.0.0: 1475pump@^3.0.0:
@@ -1499,7 +1485,7 @@ qap@^3.1.2:
1499 resolved "https://registry.yarnpkg.com/qap/-/qap-3.3.1.tgz#11f9e8fa8890fe7cb99210c0f44d0613b7372cac" 1485 resolved "https://registry.yarnpkg.com/qap/-/qap-3.3.1.tgz#11f9e8fa8890fe7cb99210c0f44d0613b7372cac"
1500 integrity sha1-Efno+oiQ/ny5khDA9E0GE7c3LKw= 1486 integrity sha1-Efno+oiQ/ny5khDA9E0GE7c3LKw=
1501 1487
1502queue-microtask@^1.2.0, queue-microtask@^1.2.2, queue-microtask@^1.2.3: 1488queue-microtask@^1.2.2, queue-microtask@^1.2.3:
1503 version "1.2.3" 1489 version "1.2.3"
1504 resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" 1490 resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
1505 integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== 1491 integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
@@ -1543,6 +1529,11 @@ range-slice-stream@^2.0.0:
1543 dependencies: 1529 dependencies:
1544 readable-stream "^3.0.2" 1530 readable-stream "^3.0.2"
1545 1531
1532rc4@^0.1.5:
1533 version "0.1.5"
1534 resolved "https://registry.yarnpkg.com/rc4/-/rc4-0.1.5.tgz#08c6e04a0168f6eb621c22ab6cb1151bd9f4a64d"
1535 integrity sha1-CMbgSgFo9utiHCKrbLEVG9n0pk0=
1536
1546rc@^1.2.7: 1537rc@^1.2.7:
1547 version "1.2.8" 1538 version "1.2.8"
1548 resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" 1539 resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
@@ -1576,9 +1567,9 @@ readable-stream@^3.0.2, readable-stream@^3.0.6, readable-stream@^3.4.0, readable
1576 util-deprecate "^1.0.1" 1567 util-deprecate "^1.0.1"
1577 1568
1578record-cache@^1.0.2: 1569record-cache@^1.0.2:
1579 version "1.1.0" 1570 version "1.1.1"
1580 resolved "https://registry.yarnpkg.com/record-cache/-/record-cache-1.1.0.tgz#f8a467a691a469584b26e88d36b18afdb3932037" 1571 resolved "https://registry.yarnpkg.com/record-cache/-/record-cache-1.1.1.tgz#ba3088a489f50491a4af7b14d410822c394fb811"
1581 integrity sha512-u8rbtLEJV7HRacl/ZYwSBFD8NFyB3PfTTfGLP37IW3hftQCwu6z4Q2RLyxo1YJUNRTEzJfpLpGwVuEYdaIkG9Q== 1572 integrity sha512-L5hZlgWc7CmGbztnemQoKE1bLu9rtI2skOB0ttE4C5+TVszLE8Rd0YLTROSgvXKLAqPumS/soyN5tJW5wJLmJQ==
1582 1573
1583render-media@^4.1.0: 1574render-media@^4.1.0:
1584 version "4.1.0" 1575 version "4.1.0"
@@ -1605,31 +1596,31 @@ rimraf@^3.0.0:
1605 dependencies: 1596 dependencies:
1606 glob "^7.1.3" 1597 glob "^7.1.3"
1607 1598
1608run-parallel-limit@^1.0.6: 1599run-parallel-limit@^1.1.0:
1609 version "1.1.0" 1600 version "1.1.0"
1610 resolved "https://registry.yarnpkg.com/run-parallel-limit/-/run-parallel-limit-1.1.0.tgz#be80e936f5768623a38a963262d6bef8ff11e7ba" 1601 resolved "https://registry.yarnpkg.com/run-parallel-limit/-/run-parallel-limit-1.1.0.tgz#be80e936f5768623a38a963262d6bef8ff11e7ba"
1611 integrity sha512-jJA7irRNM91jaKc3Hcl1npHsFLOXOoTkPCUL1JEa1R82O2miplXXRaGdjW/KM/98YQWDhJLiSs793CnXfblJUw== 1602 integrity sha512-jJA7irRNM91jaKc3Hcl1npHsFLOXOoTkPCUL1JEa1R82O2miplXXRaGdjW/KM/98YQWDhJLiSs793CnXfblJUw==
1612 dependencies: 1603 dependencies:
1613 queue-microtask "^1.2.2" 1604 queue-microtask "^1.2.2"
1614 1605
1615run-parallel@^1.1.10, run-parallel@^1.1.2, run-parallel@^1.1.6, run-parallel@^1.1.9: 1606run-parallel@^1.1.10, run-parallel@^1.1.2, run-parallel@^1.1.6, run-parallel@^1.2.0:
1616 version "1.2.0" 1607 version "1.2.0"
1617 resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" 1608 resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
1618 integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== 1609 integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
1619 dependencies: 1610 dependencies:
1620 queue-microtask "^1.2.2" 1611 queue-microtask "^1.2.2"
1621 1612
1622run-series@^1.1.8, run-series@^1.1.9: 1613run-series@^1.1.9:
1623 version "1.1.9" 1614 version "1.1.9"
1624 resolved "https://registry.yarnpkg.com/run-series/-/run-series-1.1.9.tgz#15ba9cb90e6a6c054e67c98e1dc063df0ecc113a" 1615 resolved "https://registry.yarnpkg.com/run-series/-/run-series-1.1.9.tgz#15ba9cb90e6a6c054e67c98e1dc063df0ecc113a"
1625 integrity sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g== 1616 integrity sha512-Arc4hUN896vjkqCYrUXquBFtRZdv1PfLbTYP71efP6butxyQ0kWpiNJyAgsxscmQg1cqvHY32/UCBzXedTpU2g==
1626 1617
1627rusha@^0.8.13: 1618rusha@^0.8.13:
1628 version "0.8.13" 1619 version "0.8.14"
1629 resolved "https://registry.yarnpkg.com/rusha/-/rusha-0.8.13.tgz#9a084e7b860b17bff3015b92c67a6a336191513a" 1620 resolved "https://registry.yarnpkg.com/rusha/-/rusha-0.8.14.tgz#a977d0de9428406138b7bb90d3de5dcd024e2f68"
1630 integrity sha1-mghOe4YLF7/zAVuSxnpqM2GRUTo= 1621 integrity sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA==
1631 1622
1632safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: 1623safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
1633 version "5.2.1" 1624 version "5.2.1"
1634 resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" 1625 resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
1635 integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== 1626 integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -1705,15 +1696,6 @@ simple-get@^2.0.0, simple-get@^2.1.0:
1705 once "^1.3.1" 1696 once "^1.3.1"
1706 simple-concat "^1.0.0" 1697 simple-concat "^1.0.0"
1707 1698
1708simple-get@^3.0.1:
1709 version "3.1.0"
1710 resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.0.tgz#b45be062435e50d159540b576202ceec40b9c6b3"
1711 integrity sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA==
1712 dependencies:
1713 decompress-response "^4.2.0"
1714 once "^1.3.1"
1715 simple-concat "^1.0.0"
1716
1717simple-get@^4.0.0: 1699simple-get@^4.0.0:
1718 version "4.0.0" 1700 version "4.0.0"
1719 resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.0.tgz#73fa628278d21de83dadd5512d2cc1f4872bd675" 1701 resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-4.0.0.tgz#73fa628278d21de83dadd5512d2cc1f4872bd675"
@@ -1723,7 +1705,7 @@ simple-get@^4.0.0:
1723 once "^1.3.1" 1705 once "^1.3.1"
1724 simple-concat "^1.0.0" 1706 simple-concat "^1.0.0"
1725 1707
1726simple-peer@^9.7.1, simple-peer@^9.9.3: 1708simple-peer@^9.11.0:
1727 version "9.11.0" 1709 version "9.11.0"
1728 resolved "https://registry.yarnpkg.com/simple-peer/-/simple-peer-9.11.0.tgz#e8d27609c7a610c3ddd75767da868e8daab67571" 1710 resolved "https://registry.yarnpkg.com/simple-peer/-/simple-peer-9.11.0.tgz#e8d27609c7a610c3ddd75767da868e8daab67571"
1729 integrity sha512-qvdNu/dGMHBm2uQ7oLhQBMhYlrOZC1ywXNCH/i8I4etxR1vrjCnU6ZSQBptndB1gcakjo2+w4OHo7Sjza1SHxg== 1711 integrity sha512-qvdNu/dGMHBm2uQ7oLhQBMhYlrOZC1ywXNCH/i8I4etxR1vrjCnU6ZSQBptndB1gcakjo2+w4OHo7Sjza1SHxg==
@@ -1736,7 +1718,7 @@ simple-peer@^9.7.1, simple-peer@^9.9.3:
1736 randombytes "^2.1.0" 1718 randombytes "^2.1.0"
1737 readable-stream "^3.6.0" 1719 readable-stream "^3.6.0"
1738 1720
1739simple-sha1@^3.0.0, simple-sha1@^3.0.1: 1721simple-sha1@^3.0.0, simple-sha1@^3.0.1, simple-sha1@^3.1.0:
1740 version "3.1.0" 1722 version "3.1.0"
1741 resolved "https://registry.yarnpkg.com/simple-sha1/-/simple-sha1-3.1.0.tgz#40cac8436dfaf9924332fc46a5c7bca45f656131" 1723 resolved "https://registry.yarnpkg.com/simple-sha1/-/simple-sha1-3.1.0.tgz#40cac8436dfaf9924332fc46a5c7bca45f656131"
1742 integrity sha512-ArTptMRC1v08H8ihPD6l0wesKvMfF9e8XL5rIHPanI7kGOsSsbY514MwVu6X1PITHCTB2F08zB7cyEbfc4wQjg== 1724 integrity sha512-ArTptMRC1v08H8ihPD6l0wesKvMfF9e8XL5rIHPanI7kGOsSsbY514MwVu6X1PITHCTB2F08zB7cyEbfc4wQjg==
@@ -1744,7 +1726,7 @@ simple-sha1@^3.0.0, simple-sha1@^3.0.1:
1744 queue-microtask "^1.2.2" 1726 queue-microtask "^1.2.2"
1745 rusha "^0.8.13" 1727 rusha "^0.8.13"
1746 1728
1747simple-websocket@^9.0.0: 1729simple-websocket@^9.1.0:
1748 version "9.1.0" 1730 version "9.1.0"
1749 resolved "https://registry.yarnpkg.com/simple-websocket/-/simple-websocket-9.1.0.tgz#91cbb39eafefbe7e66979da6c639109352786a7f" 1731 resolved "https://registry.yarnpkg.com/simple-websocket/-/simple-websocket-9.1.0.tgz#91cbb39eafefbe7e66979da6c639109352786a7f"
1750 integrity sha512-8MJPnjRN6A8UCp1I+H/dSFyjwJhp6wta4hsVRhjf8w9qBHRzxYt14RaOcjvQnhD1N4yKOddEjflwMnQM4VtXjQ== 1732 integrity sha512-8MJPnjRN6A8UCp1I+H/dSFyjwJhp6wta4hsVRhjf8w9qBHRzxYt14RaOcjvQnhD1N4yKOddEjflwMnQM4VtXjQ==
@@ -1819,7 +1801,7 @@ string-width@^4.2.0:
1819 is-fullwidth-code-point "^3.0.0" 1801 is-fullwidth-code-point "^3.0.0"
1820 strip-ansi "^6.0.0" 1802 strip-ansi "^6.0.0"
1821 1803
1822string2compact@^1.2.5, string2compact@^1.3.0: 1804string2compact@^1.3.0:
1823 version "1.3.0" 1805 version "1.3.0"
1824 resolved "https://registry.yarnpkg.com/string2compact/-/string2compact-1.3.0.tgz#22d946127b082d1203c51316af60117a337423c3" 1806 resolved "https://registry.yarnpkg.com/string2compact/-/string2compact-1.3.0.tgz#22d946127b082d1203c51316af60117a337423c3"
1825 integrity sha512-004ulKKANDuQilQsNxy2lisrpMG0qUJxBU+2YCEF7KziRyNR0Nredm2qk0f1V82nva59H3y9GWeHXE63HzGRFw== 1807 integrity sha512-004ulKKANDuQilQsNxy2lisrpMG0qUJxBU+2YCEF7KziRyNR0Nredm2qk0f1V82nva59H3y9GWeHXE63HzGRFw==
@@ -1938,7 +1920,7 @@ torrent-discovery@^9.4.0:
1938 debug "^4.0.0" 1920 debug "^4.0.0"
1939 run-parallel "^1.1.2" 1921 run-parallel "^1.1.2"
1940 1922
1941torrent-piece@^2.0.0: 1923torrent-piece@^2.0.1:
1942 version "2.0.1" 1924 version "2.0.1"
1943 resolved "https://registry.yarnpkg.com/torrent-piece/-/torrent-piece-2.0.1.tgz#a1a50fffa589d9bf9560e38837230708bc3afdc6" 1925 resolved "https://registry.yarnpkg.com/torrent-piece/-/torrent-piece-2.0.1.tgz#a1a50fffa589d9bf9560e38837230708bc3afdc6"
1944 integrity sha512-JLSOyvQVLI6JTWqioY4vFL0JkEUKQcaHQsU3loxkCvPTSttw8ePs2tFwsP4XIjw99Fz8EdOzt/4faykcbnPbCQ== 1926 integrity sha512-JLSOyvQVLI6JTWqioY4vFL0JkEUKQcaHQsU3loxkCvPTSttw8ePs2tFwsP4XIjw99Fz8EdOzt/4faykcbnPbCQ==
@@ -2011,19 +1993,19 @@ ut_metadata@^3.5.2:
2011 debug "^4.2.0" 1993 debug "^4.2.0"
2012 simple-sha1 "^3.0.1" 1994 simple-sha1 "^3.0.1"
2013 1995
2014ut_pex@^2.0.1: 1996ut_pex@^3.0.0:
2015 version "2.0.1" 1997 version "3.0.1"
2016 resolved "https://registry.yarnpkg.com/ut_pex/-/ut_pex-2.0.1.tgz#30d3cc19ee32f9513b06ed2b03851ba508566da1" 1998 resolved "https://registry.yarnpkg.com/ut_pex/-/ut_pex-3.0.1.tgz#fb8b6e066f8f6f6de3e6b3e28e7d18e697be5854"
2017 integrity sha512-kI1/y1IhbuTqjyVqekSZCd3afPQTpdIRCrON1WXc9jGdcIAaze3FAoZ1ssYJmGBuJbdg7LQO42daJGCaoRXl+A== 1999 integrity sha512-t1MHIDHSISgOJcmq8UM6Qv9/hRQYVaUvzqSNnXa5ATDbS9hXfhBpyBo2HcSyJtwPSHsmMtNui8G6yKirwJ8vow==
2018 dependencies: 2000 dependencies:
2019 bencode "^2.0.0" 2001 bencode "^2.0.1"
2020 compact2string "^1.2.0" 2002 compact2string "^1.4.1"
2021 string2compact "^1.2.5" 2003 string2compact "^1.3.0"
2022 2004
2023utf-8-validate@^5.0.2: 2005utf-8-validate@^5.0.5:
2024 version "5.0.4" 2006 version "5.0.5"
2025 resolved "https://registry.yarnpkg.com/utf-8-validate/-/utf-8-validate-5.0.4.tgz#72a1735983ddf7a05a43a9c6b67c5ce1c910f9b8" 2007 resolved "https://registry.yarnpkg.com/utf-8-validate/-/utf-8-validate-5.0.5.tgz#dd32c2e82c72002dc9f02eb67ba6761f43456ca1"
2026 integrity sha512-MEF05cPSq3AwJ2C7B7sHAA6i53vONoZbMGX8My5auEVm6W+dJ2Jd/TZPyGJ5CH42V2XtbI5FD28HeHeqlPzZ3Q== 2008 integrity sha512-+pnxRYsS/axEpkrrEpzYfNZGXp0IjC/9RIxwM5gntY4Koi8SHmUGSfxfWqxZdRxrtaoVstuOzUp/rbs3JSPELQ==
2027 dependencies: 2009 dependencies:
2028 node-gyp-build "^4.2.0" 2010 node-gyp-build "^4.2.0"
2029 2011
@@ -2032,10 +2014,10 @@ util-deprecate@^1.0.1, util-deprecate@~1.0.1:
2032 resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" 2014 resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
2033 integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= 2015 integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
2034 2016
2035utp-native@^2.3.0: 2017utp-native@^2.4.0:
2036 version "2.4.0" 2018 version "2.5.0"
2037 resolved "https://registry.yarnpkg.com/utp-native/-/utp-native-2.4.0.tgz#7010de2134e9d767be0ec34e817c3300592befc0" 2019 resolved "https://registry.yarnpkg.com/utp-native/-/utp-native-2.5.0.tgz#3d8321760108b30cb15391196c8cc93db85b61ce"
2038 integrity sha512-jKwpFiEaDUuNH5S4vVk/+waAX+yA6f3Lw4flqOROH1ZE/jcT4mh0/hjIGSuPP9j9RbQcsBG6Fu6LaFk4ojXFxw== 2020 integrity sha512-HoHPE6gwLxC0xlpYJUl+Xw2sh809lhXx3TexHsb2/xY8vEd6NwuvAxOI/X27dBTc/TOT5diWUpCJWDaunkcVvA==
2039 dependencies: 2021 dependencies:
2040 napi-macros "^2.0.0" 2022 napi-macros "^2.0.0"
2041 node-gyp-build "^4.2.0" 2023 node-gyp-build "^4.2.0"
@@ -2069,9 +2051,9 @@ webidl-conversions@^4.0.2:
2069 integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg== 2051 integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
2070 2052
2071webtorrent-cli@^3.2.0: 2053webtorrent-cli@^3.2.0:
2072 version "3.2.1" 2054 version "3.3.0"
2073 resolved "https://registry.yarnpkg.com/webtorrent-cli/-/webtorrent-cli-3.2.1.tgz#a39e49fe7d8a4e3c00fe34d22f113f1e021c9bec" 2055 resolved "https://registry.yarnpkg.com/webtorrent-cli/-/webtorrent-cli-3.3.0.tgz#465e9fb82373c6b279904b5876d4745cb8ac6b43"
2074 integrity sha512-DHUtDymD5ZGv/h35FY4n9YdlHoNOy07X7ibexi+19AL/+MFdGuIIQEEnJQT/wA05mrzR6ubeJmcLXXqZISwLFQ== 2056 integrity sha512-E0gb1fXb8xNScgewQmvAmNLrnEto6MOaKlfzITVzR+bbU80LeV/YdrLw536ffcwiUv0arKQyfR/WIweBFrKWbg==
2075 dependencies: 2057 dependencies:
2076 clivas "^0.2.0" 2058 clivas "^0.2.0"
2077 common-tags "^1.8.0" 2059 common-tags "^1.8.0"
@@ -2083,11 +2065,11 @@ webtorrent-cli@^3.2.0:
2083 minimist "^1.2.5" 2065 minimist "^1.2.5"
2084 moment "^2.27.0" 2066 moment "^2.27.0"
2085 network-address "^1.1.2" 2067 network-address "^1.1.2"
2086 open "^7.1.0" 2068 open "^8.0.0"
2087 parse-torrent "^7.1.3" 2069 parse-torrent "^9.0.0"
2088 prettier-bytes "^1.0.4" 2070 prettier-bytes "^1.0.4"
2089 vlc-command "^1.2.0" 2071 vlc-command "^1.2.0"
2090 webtorrent ">=0.108.6" 2072 webtorrent "^1.0.0"
2091 winreg "^1.2.4" 2073 winreg "^1.2.4"
2092 optionalDependencies: 2074 optionalDependencies:
2093 airplay-js "^0.3.0" 2075 airplay-js "^0.3.0"
@@ -2095,63 +2077,64 @@ webtorrent-cli@^3.2.0:
2095 nodebmc "0.0.7" 2077 nodebmc "0.0.7"
2096 2078
2097webtorrent-hybrid@^4.0.3: 2079webtorrent-hybrid@^4.0.3:
2098 version "4.0.3" 2080 version "4.1.0"
2099 resolved "https://registry.yarnpkg.com/webtorrent-hybrid/-/webtorrent-hybrid-4.0.3.tgz#d47d47824e3f8c7a5a5a70cc1b5398059c7fbcc0" 2081 resolved "https://registry.yarnpkg.com/webtorrent-hybrid/-/webtorrent-hybrid-4.1.0.tgz#86e397a8f051de225c60ce751f47d28a906cbfdc"
2100 integrity sha512-D8/Fmxt/xWUwrA/qJ5SrLDucOYE9B4AhWzuLgfP1y6ZlHI+Sl0PXnqblO1yT97odW0mPO12Qy+7fQM6vxXJMqA== 2082 integrity sha512-IqRWVI+gXHjv/ybj3YK6Q4gJM1OaIgy3Nw3ec0iPS7TeBrG6R3yhfHupF39DIwyKFERZDnYoxVnxHOt8TAaucw==
2101 dependencies: 2083 dependencies:
2102 create-torrent "^4.4.2" 2084 create-torrent "^4.4.2"
2103 webtorrent ">=0.111.0" 2085 webtorrent "^1.0.0"
2104 webtorrent-cli "^3.2.0" 2086 webtorrent-cli "^3.2.0"
2105 wrtc "^0.4.6" 2087 wrtc "^0.4.6"
2106 2088
2107webtorrent@>=0.108.6, webtorrent@>=0.111.0: 2089webtorrent@^1.0.0:
2108 version "0.116.2" 2090 version "1.0.0"
2109 resolved "https://registry.yarnpkg.com/webtorrent/-/webtorrent-0.116.2.tgz#5f7a851443947cf72ea09c4bca68371ea442a952" 2091 resolved "https://registry.yarnpkg.com/webtorrent/-/webtorrent-1.0.0.tgz#1d8fd388726ca013feb17d2437b1b9df2d24b5e8"
2110 integrity sha512-u6ctyPEwUvbFKZsT9HRU1Q+SSqKWoNMlXWbaPTUlGsPrNZ3mCCeHtn8Hcf61jr1e4hna5oQBtVjg2N5/2V8d9g== 2092 integrity sha512-htwcY5OBOWL/OMwaw3xi1Mp2gE9k5UmGTKeO3n1ixQDH9QgeqqRlBJz2ZLEyOL8yN1FdS/D9z+ijm06bZ3oW5w==
2111 dependencies: 2093 dependencies:
2112 addr-to-ip-port "^1.5.1" 2094 addr-to-ip-port "^1.5.1"
2113 bitfield "^4.0.0" 2095 bitfield "^4.0.0"
2114 bittorrent-dht "^10.0.0" 2096 bittorrent-dht "^10.0.0"
2115 bittorrent-protocol "^3.2.0" 2097 bittorrent-protocol "^3.3.1"
2116 chrome-net "^3.3.4" 2098 chrome-net "^3.3.4"
2117 chunk-store-stream "^4.2.0" 2099 chunk-store-stream "^4.3.0"
2118 cpus "^1.0.3" 2100 cpus "^1.0.3"
2119 create-torrent "^4.4.4" 2101 create-torrent "^4.7.0"
2120 debug "^4.3.1" 2102 debug "^4.3.1"
2121 end-of-stream "^1.4.4" 2103 end-of-stream "^1.4.4"
2122 escape-html "^1.0.3" 2104 escape-html "^1.0.3"
2123 fs-chunk-store "^2.0.2" 2105 fs-chunk-store "^2.0.3"
2124 http-node "github:feross/http-node#webtorrent" 2106 http-node "github:feross/http-node#webtorrent"
2125 immediate-chunk-store "^2.1.1" 2107 immediate-chunk-store "^2.2.0"
2126 load-ip-set "^2.1.2" 2108 load-ip-set "^2.2.1"
2127 memory-chunk-store "^1.3.1" 2109 lt_donthave "^1.0.1"
2128 mime "^2.5.0" 2110 memory-chunk-store "^1.3.5"
2111 mime "^2.5.2"
2129 multistream "^4.1.0" 2112 multistream "^4.1.0"
2130 package-json-versionify "^1.0.4" 2113 package-json-versionify "^1.0.4"
2131 parse-torrent "^9.1.1" 2114 parse-torrent "^9.1.3"
2132 pump "^3.0.0" 2115 pump "^3.0.0"
2133 queue-microtask "^1.2.2" 2116 queue-microtask "^1.2.3"
2134 random-iterate "^1.0.1" 2117 random-iterate "^1.0.1"
2135 randombytes "^2.1.0" 2118 randombytes "^2.1.0"
2136 range-parser "^1.2.1" 2119 range-parser "^1.2.1"
2137 readable-stream "^3.6.0" 2120 readable-stream "^3.6.0"
2138 render-media "^4.1.0" 2121 render-media "^4.1.0"
2139 run-parallel "^1.1.10" 2122 run-parallel "^1.2.0"
2140 run-parallel-limit "^1.0.6" 2123 run-parallel-limit "^1.1.0"
2141 simple-concat "^1.0.1" 2124 simple-concat "^1.0.1"
2142 simple-get "^4.0.0" 2125 simple-get "^4.0.0"
2143 simple-peer "^9.9.3" 2126 simple-peer "^9.11.0"
2144 simple-sha1 "^3.0.1" 2127 simple-sha1 "^3.1.0"
2145 speedometer "^1.1.0" 2128 speedometer "^1.1.0"
2146 stream-to-blob "^2.0.1" 2129 stream-to-blob "^2.0.1"
2147 stream-to-blob-url "^3.0.2" 2130 stream-to-blob-url "^3.0.2"
2148 stream-with-known-length-to-buffer "^1.0.4" 2131 stream-with-known-length-to-buffer "^1.0.4"
2149 torrent-discovery "^9.4.0" 2132 torrent-discovery "^9.4.0"
2150 torrent-piece "^2.0.0" 2133 torrent-piece "^2.0.1"
2151 unordered-array-remove "^1.0.2" 2134 unordered-array-remove "^1.0.2"
2152 ut_metadata "^3.5.2" 2135 ut_metadata "^3.5.2"
2153 ut_pex "^2.0.1" 2136 ut_pex "^3.0.0"
2154 utp-native "^2.3.0" 2137 utp-native "^2.4.0"
2155 2138
2156which@^1.2.9: 2139which@^1.2.9:
2157 version "1.3.1" 2140 version "1.3.1"
@@ -2208,10 +2191,10 @@ wrtc@^0.4.6:
2208 optionalDependencies: 2191 optionalDependencies:
2209 domexception "^1.0.1" 2192 domexception "^1.0.1"
2210 2193
2211ws@^7.3.0, ws@^7.4.2: 2194ws@^7.4.2, ws@^7.4.5:
2212 version "7.4.4" 2195 version "7.4.6"
2213 resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.4.tgz#383bc9742cb202292c9077ceab6f6047b17f2d59" 2196 resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c"
2214 integrity sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw== 2197 integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==
2215 2198
2216xml2js@^0.4.8: 2199xml2js@^0.4.8:
2217 version "0.4.23" 2200 version "0.4.23"
diff --git a/server/types/models/moderation/abuse-message.ts b/server/types/models/abuse/abuse-message.ts
index 565eca706..565eca706 100644
--- a/server/types/models/moderation/abuse-message.ts
+++ b/server/types/models/abuse/abuse-message.ts
diff --git a/server/types/models/moderation/abuse.ts b/server/types/models/abuse/abuse.ts
index 6fd83684c..6fd83684c 100644
--- a/server/types/models/moderation/abuse.ts
+++ b/server/types/models/abuse/abuse.ts
diff --git a/server/types/models/moderation/index.ts b/server/types/models/abuse/index.ts
index 1ed91b249..1ed91b249 100644
--- a/server/types/models/moderation/index.ts
+++ b/server/types/models/abuse/index.ts
diff --git a/server/types/models/account/account.ts b/server/types/models/account/account.ts
index 9513acad8..984841291 100644
--- a/server/types/models/account/account.ts
+++ b/server/types/models/account/account.ts
@@ -1,7 +1,5 @@
1import { FunctionProperties, PickWith } from '@shared/core-utils' 1import { FunctionProperties, PickWith } from '@shared/core-utils'
2import { AccountModel } from '../../../models/account/account' 2import { AccountModel } from '../../../models/account/account'
3import { MChannelDefault } from '../video/video-channels'
4import { MAccountBlocklistId } from './account-blocklist'
5import { 3import {
6 MActor, 4 MActor,
7 MActorAPAccount, 5 MActorAPAccount,
@@ -15,7 +13,9 @@ import {
15 MActorSummary, 13 MActorSummary,
16 MActorSummaryFormattable, 14 MActorSummaryFormattable,
17 MActorUrl 15 MActorUrl
18} from './actor' 16} from '../actor'
17import { MChannelDefault } from '../video/video-channels'
18import { MAccountBlocklistId } from './account-blocklist'
19 19
20type Use<K extends keyof AccountModel, M> = PickWith<AccountModel, K, M> 20type Use<K extends keyof AccountModel, M> = PickWith<AccountModel, K, M>
21 21
diff --git a/server/types/models/account/actor-custom-page.ts b/server/types/models/account/actor-custom-page.ts
new file mode 100644
index 000000000..2cb8aa7e4
--- /dev/null
+++ b/server/types/models/account/actor-custom-page.ts
@@ -0,0 +1,4 @@
1
2import { ActorCustomPageModel } from '../../../models/account/actor-custom-page'
3
4export type MActorCustomPage = Omit<ActorCustomPageModel, 'Actor'>
diff --git a/server/types/models/account/index.ts b/server/types/models/account/index.ts
index e3fc00f94..9679c01e4 100644
--- a/server/types/models/account/index.ts
+++ b/server/types/models/account/index.ts
@@ -1,5 +1,3 @@
1export * from './account' 1export * from './account'
2export * from './actor-custom-page'
2export * from './account-blocklist' 3export * from './account-blocklist'
3export * from './actor-follow'
4export * from './actor-image'
5export * from './actor'
diff --git a/server/types/models/account/actor-follow.ts b/server/types/models/actor/actor-follow.ts
index 8e19c6140..98a6ca8a5 100644
--- a/server/types/models/account/actor-follow.ts
+++ b/server/types/models/actor/actor-follow.ts
@@ -1,5 +1,5 @@
1import { PickWith } from '@shared/core-utils' 1import { PickWith } from '@shared/core-utils'
2import { ActorFollowModel } from '../../../models/activitypub/actor-follow' 2import { ActorFollowModel } from '../../../models/actor/actor-follow'
3import { 3import {
4 MActor, 4 MActor,
5 MActorChannelAccountActor, 5 MActorChannelAccountActor,
diff --git a/server/types/models/account/actor-image.ts b/server/types/models/actor/actor-image.ts
index e59f8b141..89adb01ae 100644
--- a/server/types/models/account/actor-image.ts
+++ b/server/types/models/actor/actor-image.ts
@@ -1,5 +1,5 @@
1import { ActorImageModel } from '../../../models/account/actor-image'
2import { FunctionProperties } from '@shared/core-utils' 1import { FunctionProperties } from '@shared/core-utils'
2import { ActorImageModel } from '../../../models/actor/actor-image'
3 3
4export type MActorImage = ActorImageModel 4export type MActorImage = ActorImageModel
5 5
diff --git a/server/types/models/account/actor.ts b/server/types/models/actor/actor.ts
index 0b620872e..b3a70cbce 100644
--- a/server/types/models/account/actor.ts
+++ b/server/types/models/actor/actor.ts
@@ -1,9 +1,8 @@
1
2import { FunctionProperties, PickWith, PickWithOpt } from '@shared/core-utils' 1import { FunctionProperties, PickWith, PickWithOpt } from '@shared/core-utils'
3import { ActorModel } from '../../../models/activitypub/actor' 2import { ActorModel } from '../../../models/actor/actor'
3import { MAccount, MAccountDefault, MAccountId, MAccountIdActor } from '../account'
4import { MServer, MServerHost, MServerHostBlocks, MServerRedundancyAllowed } from '../server' 4import { MServer, MServerHost, MServerHostBlocks, MServerRedundancyAllowed } from '../server'
5import { MChannel, MChannelAccountActor, MChannelAccountDefault, MChannelId, MChannelIdActor } from '../video' 5import { MChannel, MChannelAccountActor, MChannelAccountDefault, MChannelId, MChannelIdActor } from '../video'
6import { MAccount, MAccountDefault, MAccountId, MAccountIdActor } from './account'
7import { MActorImage, MActorImageFormattable } from './actor-image' 6import { MActorImage, MActorImageFormattable } from './actor-image'
8 7
9type Use<K extends keyof ActorModel, M> = PickWith<ActorModel, K, M> 8type Use<K extends keyof ActorModel, M> = PickWith<ActorModel, K, M>
diff --git a/server/types/models/actor/index.ts b/server/types/models/actor/index.ts
new file mode 100644
index 000000000..b27815255
--- /dev/null
+++ b/server/types/models/actor/index.ts
@@ -0,0 +1,3 @@
1export * from './actor-follow'
2export * from './actor-image'
3export * from './actor'
diff --git a/server/types/models/index.ts b/server/types/models/index.ts
index b4fdb1ff3..704cb9844 100644
--- a/server/types/models/index.ts
+++ b/server/types/models/index.ts
@@ -1,6 +1,7 @@
1export * from './abuse'
1export * from './account' 2export * from './account'
3export * from './actor'
2export * from './application' 4export * from './application'
3export * from './moderation'
4export * from './oauth' 5export * from './oauth'
5export * from './server' 6export * from './server'
6export * from './user' 7export * from './user'
diff --git a/server/types/models/user/user-notification-setting.ts b/server/types/models/user/user-notification-setting.ts
index c674add1b..d1db645e7 100644
--- a/server/types/models/user/user-notification-setting.ts
+++ b/server/types/models/user/user-notification-setting.ts
@@ -1,4 +1,4 @@
1import { UserNotificationSettingModel } from '@server/models/account/user-notification-setting' 1import { UserNotificationSettingModel } from '@server/models/user/user-notification-setting'
2 2
3export type MNotificationSetting = Omit<UserNotificationSettingModel, 'User'> 3export type MNotificationSetting = Omit<UserNotificationSettingModel, 'User'>
4 4
diff --git a/server/types/models/user/user-notification.ts b/server/types/models/user/user-notification.ts
index 7ebb0485d..918614dd1 100644
--- a/server/types/models/user/user-notification.ts
+++ b/server/types/models/user/user-notification.ts
@@ -2,13 +2,13 @@ import { VideoAbuseModel } from '@server/models/abuse/video-abuse'
2import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse' 2import { VideoCommentAbuseModel } from '@server/models/abuse/video-comment-abuse'
3import { ApplicationModel } from '@server/models/application/application' 3import { ApplicationModel } from '@server/models/application/application'
4import { PluginModel } from '@server/models/server/plugin' 4import { PluginModel } from '@server/models/server/plugin'
5import { UserNotificationModel } from '@server/models/user/user-notification'
5import { PickWith, PickWithOpt } from '@shared/core-utils' 6import { PickWith, PickWithOpt } from '@shared/core-utils'
6import { AbuseModel } from '../../../models/abuse/abuse' 7import { AbuseModel } from '../../../models/abuse/abuse'
7import { AccountModel } from '../../../models/account/account' 8import { AccountModel } from '../../../models/account/account'
8import { ActorImageModel } from '../../../models/account/actor-image' 9import { ActorModel } from '../../../models/actor/actor'
9import { UserNotificationModel } from '../../../models/account/user-notification' 10import { ActorFollowModel } from '../../../models/actor/actor-follow'
10import { ActorModel } from '../../../models/activitypub/actor' 11import { ActorImageModel } from '../../../models/actor/actor-image'
11import { ActorFollowModel } from '../../../models/activitypub/actor-follow'
12import { ServerModel } from '../../../models/server/server' 12import { ServerModel } from '../../../models/server/server'
13import { VideoModel } from '../../../models/video/video' 13import { VideoModel } from '../../../models/video/video'
14import { VideoBlacklistModel } from '../../../models/video/video-blacklist' 14import { VideoBlacklistModel } from '../../../models/video/video-blacklist'
diff --git a/server/types/models/user/user-video-history.ts b/server/types/models/user/user-video-history.ts
index 62673ab1b..34e2930e7 100644
--- a/server/types/models/user/user-video-history.ts
+++ b/server/types/models/user/user-video-history.ts
@@ -1,4 +1,4 @@
1import { UserVideoHistoryModel } from '../../../models/account/user-video-history' 1import { UserVideoHistoryModel } from '../../../models/user/user-video-history'
2 2
3export type MUserVideoHistory = Omit<UserVideoHistoryModel, 'Video' | 'User'> 3export type MUserVideoHistory = Omit<UserVideoHistoryModel, 'Video' | 'User'>
4 4
diff --git a/server/types/models/user/user.ts b/server/types/models/user/user.ts
index fa7de9c52..f79220e11 100644
--- a/server/types/models/user/user.ts
+++ b/server/types/models/user/user.ts
@@ -1,7 +1,7 @@
1import { AccountModel } from '@server/models/account/account' 1import { AccountModel } from '@server/models/account/account'
2import { UserModel } from '@server/models/user/user'
2import { MVideoPlaylist } from '@server/types/models' 3import { MVideoPlaylist } from '@server/types/models'
3import { PickWith, PickWithOpt } from '@shared/core-utils' 4import { PickWith, PickWithOpt } from '@shared/core-utils'
4import { UserModel } from '../../../models/account/user'
5import { 5import {
6 MAccount, 6 MAccount,
7 MAccountDefault, 7 MAccountDefault,
diff --git a/server/types/models/video/schedule-video-update.ts b/server/types/models/video/schedule-video-update.ts
index 5d2936000..39fd73501 100644
--- a/server/types/models/video/schedule-video-update.ts
+++ b/server/types/models/video/schedule-video-update.ts
@@ -1,8 +1,4 @@
1import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update' 1import { ScheduleVideoUpdateModel } from '../../../models/video/schedule-video-update'
2import { PickWith } from '@shared/core-utils'
3import { MVideoAPWithoutCaption, MVideoWithBlacklistLight } from './video'
4
5type Use<K extends keyof ScheduleVideoUpdateModel, M> = PickWith<ScheduleVideoUpdateModel, K, M>
6 2
7// ############################################################################ 3// ############################################################################
8 4
@@ -10,10 +6,6 @@ export type MScheduleVideoUpdate = Omit<ScheduleVideoUpdateModel, 'Video'>
10 6
11// ############################################################################ 7// ############################################################################
12 8
13export type MScheduleVideoUpdateVideoAll =
14 MScheduleVideoUpdate &
15 Use<'Video', MVideoAPWithoutCaption & MVideoWithBlacklistLight>
16
17// Format for API or AP object 9// Format for API or AP object
18 10
19export type MScheduleVideoUpdateFormattable = Pick<MScheduleVideoUpdate, 'updateAt' | 'privacy'> 11export type MScheduleVideoUpdateFormattable = Pick<MScheduleVideoUpdate, 'updateAt' | 'privacy'>
diff --git a/server/types/models/video/video-channels.ts b/server/types/models/video/video-channels.ts
index f577807ca..c147567d9 100644
--- a/server/types/models/video/video-channels.ts
+++ b/server/types/models/video/video-channels.ts
@@ -9,7 +9,9 @@ import {
9 MAccountSummaryBlocks, 9 MAccountSummaryBlocks,
10 MAccountSummaryFormattable, 10 MAccountSummaryFormattable,
11 MAccountUrl, 11 MAccountUrl,
12 MAccountUserId, 12 MAccountUserId
13} from '../account'
14import {
13 MActor, 15 MActor,
14 MActorAccountChannelId, 16 MActorAccountChannelId,
15 MActorAPChannel, 17 MActorAPChannel,
@@ -23,7 +25,7 @@ import {
23 MActorSummary, 25 MActorSummary,
24 MActorSummaryFormattable, 26 MActorSummaryFormattable,
25 MActorUrl 27 MActorUrl
26} from '../account' 28} from '../actor'
27import { MVideo } from './video' 29import { MVideo } from './video'
28 30
29type Use<K extends keyof VideoChannelModel, M> = PickWith<VideoChannelModel, K, M> 31type Use<K extends keyof VideoChannelModel, M> = PickWith<VideoChannelModel, K, M>
diff --git a/server/types/models/video/video-playlist.ts b/server/types/models/video/video-playlist.ts
index 79e2daebf..2f9537cf5 100644
--- a/server/types/models/video/video-playlist.ts
+++ b/server/types/models/video/video-playlist.ts
@@ -69,7 +69,7 @@ export type MVideoPlaylistAccountChannelDefault =
69// With all associations 69// With all associations
70 70
71export type MVideoPlaylistFull = 71export type MVideoPlaylistFull =
72 MVideoPlaylist & 72 MVideoPlaylistVideosLength &
73 Use<'OwnerAccount', MAccountDefault> & 73 Use<'OwnerAccount', MAccountDefault> &
74 Use<'VideoChannel', MChannelDefault> & 74 Use<'VideoChannel', MChannelDefault> &
75 Use<'Thumbnail', MThumbnail> 75 Use<'Thumbnail', MThumbnail>
@@ -84,7 +84,7 @@ export type MVideoPlaylistAccountChannelSummary =
84 Use<'VideoChannel', MChannelSummary> 84 Use<'VideoChannel', MChannelSummary>
85 85
86export type MVideoPlaylistFullSummary = 86export type MVideoPlaylistFullSummary =
87 MVideoPlaylist & 87 MVideoPlaylistVideosLength &
88 Use<'Thumbnail', MThumbnail> & 88 Use<'Thumbnail', MThumbnail> &
89 Use<'OwnerAccount', MAccountSummary> & 89 Use<'OwnerAccount', MAccountSummary> &
90 Use<'VideoChannel', MChannelSummary> 90 Use<'VideoChannel', MChannelSummary>
diff --git a/server/types/models/video/video-share.ts b/server/types/models/video/video-share.ts
index b7a783bb6..78f44e58c 100644
--- a/server/types/models/video/video-share.ts
+++ b/server/types/models/video/video-share.ts
@@ -1,6 +1,6 @@
1import { VideoShareModel } from '../../../models/video/video-share'
2import { PickWith } from '@shared/core-utils' 1import { PickWith } from '@shared/core-utils'
3import { MActorDefault } from '../account' 2import { VideoShareModel } from '../../../models/video/video-share'
3import { MActorDefault } from '../actor'
4import { MVideo } from './video' 4import { MVideo } from './video'
5 5
6type Use<K extends keyof VideoShareModel, M> = PickWith<VideoShareModel, K, M> 6type Use<K extends keyof VideoShareModel, M> = PickWith<VideoShareModel, K, M>
diff --git a/server/types/plugins/register-server-option.model.ts b/server/types/plugins/register-server-option.model.ts
index 2432b7ac4..8774bcd8c 100644
--- a/server/types/plugins/register-server-option.model.ts
+++ b/server/types/plugins/register-server-option.model.ts
@@ -1,6 +1,6 @@
1import { Router, Response } from 'express' 1import { Response, Router } from 'express'
2import { Logger } from 'winston' 2import { Logger } from 'winston'
3import { ActorModel } from '@server/models/activitypub/actor' 3import { ActorModel } from '@server/models/actor/actor'
4import { 4import {
5 PluginPlaylistPrivacyManager, 5 PluginPlaylistPrivacyManager,
6 PluginSettingsManager, 6 PluginSettingsManager,
diff --git a/server/types/sequelize.ts b/server/types/sequelize.ts
index 9cd83612d..535113d01 100644
--- a/server/types/sequelize.ts
+++ b/server/types/sequelize.ts
@@ -1,4 +1,5 @@
1import { Model } from 'sequelize-typescript' 1import { AttributesOnly } from '@shared/core-utils'
2import { Model } from 'sequelize'
2 3
3// Thanks to sequelize-typescript: https://github.com/RobinBuschmann/sequelize-typescript 4// Thanks to sequelize-typescript: https://github.com/RobinBuschmann/sequelize-typescript
4 5
@@ -9,7 +10,7 @@ export type Omit<T, K extends keyof T> = { [P in Diff<keyof T, K>]: T[P] }
9 10
10export type RecursivePartial<T> = { [P in keyof T]?: RecursivePartial<T[P]> } 11export type RecursivePartial<T> = { [P in keyof T]?: RecursivePartial<T[P]> }
11 12
12export type FilteredModelAttributes<T extends Model<T>> = RecursivePartial<Omit<T, keyof Model<any>>> & { 13export type FilteredModelAttributes<T extends Model<any>> = Partial<AttributesOnly<T>> & {
13 id?: number | any 14 id?: number | any
14 createdAt?: Date | any 15 createdAt?: Date | any
15 updatedAt?: Date | any 16 updatedAt?: Date | any
diff --git a/server/typings/express/index.d.ts b/server/typings/express/index.d.ts
index 55b6e0039..1a8dc3430 100644
--- a/server/typings/express/index.d.ts
+++ b/server/typings/express/index.d.ts
@@ -1,3 +1,4 @@
1
1import { RegisterServerAuthExternalOptions } from '@server/types' 2import { RegisterServerAuthExternalOptions } from '@server/types'
2import { 3import {
3 MAbuseMessage, 4 MAbuseMessage,
@@ -9,6 +10,8 @@ import {
9 MStreamingPlaylist, 10 MStreamingPlaylist,
10 MVideoChangeOwnershipFull, 11 MVideoChangeOwnershipFull,
11 MVideoFile, 12 MVideoFile,
13 MVideoFormattableDetails,
14 MVideoId,
12 MVideoImmutable, 15 MVideoImmutable,
13 MVideoLive, 16 MVideoLive,
14 MVideoPlaylistFull, 17 MVideoPlaylistFull,
@@ -20,7 +23,7 @@ import { MVideoImportDefault } from '@server/types/models/video/video-import'
20import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element' 23import { MVideoPlaylistElement, MVideoPlaylistElementVideoUrlPlaylistPrivacy } from '@server/types/models/video/video-playlist-element'
21import { MAccountVideoRateAccountVideo } from '@server/types/models/video/video-rate' 24import { MAccountVideoRateAccountVideo } from '@server/types/models/video/video-rate'
22import { HttpMethod } from '@shared/core-utils/miscs/http-methods' 25import { HttpMethod } from '@shared/core-utils/miscs/http-methods'
23import { VideoCreate } from '@shared/models' 26import { PeerTubeProblemDocumentData, ServerErrorCode, VideoCreate } from '@shared/models'
24import { File as UploadXFile, Metadata } from '@uploadx/core' 27import { File as UploadXFile, Metadata } from '@uploadx/core'
25import { RegisteredPlugin } from '../../lib/plugins/plugin-manager' 28import { RegisteredPlugin } from '../../lib/plugins/plugin-manager'
26import { 29import {
@@ -34,12 +37,11 @@ import {
34 MVideoBlacklist, 37 MVideoBlacklist,
35 MVideoCaptionVideo, 38 MVideoCaptionVideo,
36 MVideoFullLight, 39 MVideoFullLight,
37 MVideoIdThumbnail,
38 MVideoRedundancyVideo, 40 MVideoRedundancyVideo,
39 MVideoShareActor, 41 MVideoShareActor,
40 MVideoThumbnail, 42 MVideoThumbnail
41 MVideoWithRights
42} from '../../types/models' 43} from '../../types/models'
44
43declare module 'express' { 45declare module 'express' {
44 export interface Request { 46 export interface Request {
45 query: any 47 query: any
@@ -83,14 +85,27 @@ declare module 'express' {
83 filename: string 85 filename: string
84 } 86 }
85 87
86 // Extends locals property from Response 88 // Extends Response with added functions and potential variables passed by middlewares
87 interface Response { 89 interface Response {
90 fail: (options: {
91 message: string
92
93 title?: string
94 status?: number
95 type?: ServerErrorCode
96 instance?: string
97
98 data?: PeerTubeProblemDocumentData
99 }) => void
100
88 locals: { 101 locals: {
102 docUrl?: string
103
104 videoAPI?: MVideoFormattableDetails
89 videoAll?: MVideoFullLight 105 videoAll?: MVideoFullLight
90 onlyImmutableVideo?: MVideoImmutable 106 onlyImmutableVideo?: MVideoImmutable
91 onlyVideo?: MVideoThumbnail 107 onlyVideo?: MVideoThumbnail
92 onlyVideoWithRights?: MVideoWithRights 108 videoId?: MVideoId
93 videoId?: MVideoIdThumbnail
94 109
95 videoLive?: MVideoLive 110 videoLive?: MVideoLive
96 111